Skip to content

Release notes agent

You end up with a command-line agent that drafts release notes for the commits since your last tag. It streams the draft to your terminal and writes RELEASE_NOTES.md only after you answer y. The code lives in examples/release-notes-agent of the swiftide repository.

  • Rust 1.97.1 and a git repository with at least one tag.
  • OPENAI_API_KEY. Set OPENAI_BASE_URL to use any OpenAI-compatible endpoint instead of https://api.openai.com/v1.
  • For --mcp: Node.js (npx) and GITHUB_PERSONAL_ACCESS_TOKEN or GITHUB_TOKEN.
  • For --trace: LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY, and optionally LANGFUSE_URL.

The CLI also reads variables from a .env file, found in the current directory or the nearest parent, or from the file you pass with --env-file. TLS needs no setup, because swiftide’s HTTP clients bring their own rustls crypto provider (TLS).

The crate turns off default features and enables the feature flags macros, swiftide-agents, openai, mcp and langfuse. The excerpts below also need async-trait, because #[derive(Tool)] expands to #[async_trait], plus schemars and rmcp. The model client sets the endpoint with api_base and api_key on the OpenAI builder, which arrived in 0.34. To build the same thing in your own crate:

Terminal window
cargo add swiftide@0.34 --no-default-features --features macros,swiftide-agents,openai,mcp,langfuse
cargo add async-trait schemars --features schemars/derive
cargo add rmcp --features client,transport-child-process

The example itself builds against the swiftide workspace through a path dependency.

Agent loop

The app has one agent and one tool executor. The agent reads git history with three tools, drafts the notes, and calls write_changelog. That tool waits for approval, which the session loop collects outside the agent. A custom stop tool ends the run with the notes as structured data.

examples/release-notes-agent/src/main.rs
let root = Git::new(&LocalExecutor::new(&cli.repo))
.top_level()
.await
.with_context(|| format!("{} is not a git repository", cli.repo.display()))?;
// Every tool the agent runs, runs with the repository as its working directory.
let executor: Arc<dyn ToolExecutor> = Arc::new(
LocalExecutor::builder()
.workdir(&root)
.default_timeout(Some(Duration::from_mins(1)))
.build()?,
);

Source: examples/release-notes-agent/src/main.rs#L93-L103

LocalExecutor runs every command with the repository root as its working directory. A command without its own timeout is stopped after one minute. It does not sandbox anything: absolute paths and .. still reach outside the repository. The tools therefore parse revisions and paths from the model into value types, Revision and RepoPath, before they build a command. The output path comes from the CLI, not from the model.

examples/release-notes-agent/src/tools.rs
/// Lists every commit since a release tag, oldest first: hash, subject, body and the files it
/// changed.
///
/// # Arguments
/// - `tag`: Tag to start from, e.g. `v0.33.0`. Use null for the latest tag.
#[swiftide::tool]
pub async fn git_log_since(
context: &dyn AgentContext,
tag: &Option<String>,
) -> Result<ToolOutput, ToolError> {
let git = Git::new(context.executor().as_ref());
let since = match tag {
Some(tag) => tag.parse()?,
None => git.latest_tag().await?,
};
let log = git.log_since(&since).await?;
Ok(ToolOutput::text(clip(
log,
MAX_LOG_LINES,
"Use git_diff on a specific path for details.",
)))
}
/// Shows the diff of one file or directory over a revision range.
///
/// # Arguments
/// - `range`: Revision range, e.g. `v0.33.0..HEAD`, or a single commit hash.
/// - `path`: File or directory relative to the repository root.
#[swiftide::tool]
pub async fn git_diff(
context: &dyn AgentContext,
range: &str,
path: &str,
) -> Result<ToolOutput, ToolError> {
let (range, path): (Revision, RepoPath) = (range.parse()?, path.parse()?);
let diff = Git::new(context.executor().as_ref())
.diff(&range, &path)
.await?;
if diff.trim().is_empty() {
return Ok(ToolOutput::text(format!(
"No changes to {path} in {range}."
)));
}
Ok(ToolOutput::text(clip(
diff,
MAX_DIFF_LINES,
"Diff a narrower path to see the rest.",
)))
}
/// Reads a file from the repository, e.g. to check a public API or the style of the changelog.
///
/// # Arguments
/// - `path`: File path relative to the repository root.
#[swiftide::tool]
pub async fn read_file(context: &dyn AgentContext, path: &str) -> Result<ToolOutput, ToolError> {
let path: RepoPath = path.parse()?;
let content = context
.executor()
.exec_cmd(&Command::read_file(path.as_path()))
.await?;
Ok(ToolOutput::text(clip(
content.into_string_lossy(),
MAX_FILE_LINES,
"The file is longer than shown.",
)))
}

Source: examples/release-notes-agent/src/tools.rs#L32-L101

#[swiftide::tool] turns each async function into a tool. It also generates a constructor with the same name that returns a boxed tool, which is why the agent calls git_log_since(). The tool description comes from the doc comment, and the parameter descriptions come from its # Arguments list. Every tool sends its Command through executor() on the context, so all commands share the executor above. The git queries run as shell commands. Command::read_file and Command::write_file go straight to the filesystem.

examples/release-notes-agent/src/tools.rs
/// Prepends the approved release notes to the release notes file.
///
/// The file is fixed by the CLI (`--output`), never chosen by the model. Wrap this tool in
/// `ApprovalRequired` so a human signs off before anything is written.
#[derive(Clone, Tool)]
#[tool(
description = "Prepends the finished release notes to the release notes file. A maintainer \
reviews the notes before they are written.",
param(
name = "markdown",
description = "The complete release notes for this version, as GitHub flavored Markdown"
)
)]
pub struct WriteChangelog {
file: PathBuf,
}
impl WriteChangelog {
pub fn new(file: impl Into<PathBuf>) -> Self {
Self { file: file.into() }
}
async fn write_changelog(
&self,
context: &dyn AgentContext,
markdown: &str,
) -> Result<ToolOutput, ToolError> {
let executor = context.executor();
// A missing file is started; any other failure must not overwrite the existing notes.
let existing = if file_exists(executor.as_ref(), &self.file).await? {
executor
.exec_cmd(&Command::read_file(&self.file))
.await
.map(CommandOutput::into_string_lossy)?
} else {
String::new()
};
executor
.exec_cmd(&Command::write_file(
&self.file,
prepend_notes(markdown, &existing),
))
.await?;
Ok(ToolOutput::text(format!("wrote {}", self.file.display())))
}
}

Source: examples/release-notes-agent/src/tools.rs#L105-L152

The Tool derive macro builds a tool from a struct. Here the struct holds the output file, which the CLI fixes, so the model cannot choose where to write. The macro calls the method named after the tool, write_changelog, and generates the argument type WriteChangelogArgs. The session deserializes the pending call into that type to report how many lines the draft has and where it goes.

examples/release-notes-agent/src/agent.rs
/// The system prompt is a Tera template; optional values are left undefined when absent.
///
/// GitHub tools are only mentioned when the repository is on GitHub, since the prompt tells
/// the agent which repository to query.
fn system_prompt(&self, github_tools: bool) -> Prompt {
let mut prompt = Prompt::from(SYSTEM_PROMPT)
.with_context_value("since", self.since.as_str())
.with_context_value("github_tools", github_tools && self.github.is_some());
if let Some(version) = &self.next_version {
prompt.insert_context_value("next_version", version.as_str());
}
if let Some(github) = &self.github {
prompt.insert_context_value("github", github.to_string());
}
prompt
}

Source: examples/release-notes-agent/src/agent.rs#L69-L84

The system prompt is a Prompt, a Tera template read from src/system_prompt.md. Since 0.33, Prompt replaces SystemPrompt; see Upgrading. with_context_value fills a template variable.

examples/release-notes-agent/src/agent.rs
/// Builds the agent. All tools run through `executor`, whose working directory is the repo.
pub fn build(
llm: &(impl ChatCompletion + Clone + 'static),
executor: Arc<dyn ToolExecutor>,
task: &ReleaseTask,
github_tools: Option<impl ToolBox + 'static>,
) -> Result<Agent> {
let mut builder = Agent::builder();
builder
.llm(llm)
.context(DefaultContext::from_executor(executor))
.system_prompt(task.system_prompt(github_tools.is_some()))
// Our own `stop` tool replaces the default one; call this before `tools`.
.without_default_stop_tool()
.tools([
git_log_since(),
git_diff(),
read_file(),
// Drafts that describe a change twice go back to the model before anyone reviews them.
OneEntryPerChange::new(ApprovalRequired::new(WriteChangelog::new(&task.notes_file)))
.into(),
ReleaseNotes::stop_tool().into(),
])
.limit(STEP_LIMIT);
if let Some(github_tools) = github_tools {
builder.add_toolbox(github_tools);
}
progress::report_tool_calls(&mut builder);
progress::stream_draft(&mut builder);
Ok(builder.build()?)
}

Source: examples/release-notes-agent/src/agent.rs#L104-L136

DefaultContext::from_executor gives the agent’s context the executor, so tools get it through executor(). Call without_default_stop_tool before tools, because tools adds the default stop tool at the moment you call it. The write_changelog tool sits inside two wrappers. ApprovalRequired holds the call until you decide. OneEntryPerChange, defined in the example, sends a draft that lists one pull request twice back to the model before you see it. limit(STEP_LIMIT) caps one run at 40 completions.

examples/release-notes-agent/src/progress.rs
/// Prints each tool call and its result on one line.
pub fn report_tool_calls(builder: &mut AgentBuilder) {
builder
.before_tool(|_agent, tool_call| {
println!(" → {}", describe_call(tool_call));
Box::pin(async { Ok(()) })
})
.after_tool(|_agent, tool_call, result| {
println!(" {} {}", tool_call.name(), describe_result(result));
Box::pin(async { Ok(()) })
});
}

Source: examples/release-notes-agent/src/progress.rs#L16-L27

Since 0.33, before_tool and after_tool receive &mut Agent. after_tool also gets the tool result as &mut, so a hook could rewrite it.

examples/release-notes-agent/src/progress.rs
/// Streams the model's text, and the release notes while they are being drafted.
pub fn stream_draft(builder: &mut AgentBuilder) {
let preview = Arc::new(Mutex::new(DraftPreview::default()));
// Registering `on_stream` switches the agent to streaming completions.
builder.on_stream(move |_agent, response| {
let Some(delta) = &response.delta else {
return Box::pin(async { Ok(()) });
};
let mut out = stdout().lock();
if let Some(text) = &delta.message_chunk {
let _ = write!(out, "{text}");
}
// The notes arrive as the (JSON) argument of `write_changelog`; show them as they
// are written.
let drafts = delta.tool_calls_chunk.iter().flat_map(HashMap::values);
for call in drafts.filter(|call| call.name.as_deref() == Some(WRITE_CHANGELOG)) {
if let (Some(id), Some(args)) = (&call.id, &call.arguments) {
let mut preview = preview.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(text) = preview.advance(id, args) {
let _ = write!(out, "{text}");
}
}
}
let _ = out.flush();
Box::pin(async { Ok(()) })
});
}

Source: examples/release-notes-agent/src/progress.rs#L31-L58

Registering on_stream turns on streaming completions for the agent. The notes arrive as the JSON argument of write_changelog, so the hook reads tool_calls_chunk and decodes the argument as it grows. The client in src/llm.rs sets stream_full(false) on the OpenAI builder. Each chunk then leaves out the accumulated message, tool calls and usage. message_chunk holds only the new text, but tool_calls_chunk still holds the argument accumulated so far.

examples/release-notes-agent/src/notes.rs
/// Structured release notes. The agent hands these over when it calls `stop`.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ReleaseNotes {
/// The version being released, without a leading `v`.
pub version: String,
/// One or two sentences on what this release is about, written for users.
pub summary: String,
/// New features and notable improvements.
pub highlights: Vec<Item>,
/// Changes that require users to update their code. Empty if there are none.
pub breaking_changes: Vec<Item>,
/// Bug fixes users may notice.
pub fixes: Vec<Item>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct Item {
/// Short, user-facing title.
pub title: String,
/// What changed and why it matters, in one or two sentences.
pub details: String,
/// Pull requests or commits as plain text, e.g. `#1207` or `90bcadc7`, not Markdown links.
pub references: Vec<String>,
}
impl ReleaseNotes {
/// A `stop` tool whose arguments must match [`ReleaseNotes`].
///
/// With a custom schema the arguments are forwarded as-is, and end up in
/// `StopReason::RequestedByTool`.
pub fn stop_tool() -> StopWithArgs {
StopWithArgs::with_parameters_schema(schema_for!(ReleaseNotes))
}
}

Source: examples/release-notes-agent/src/notes.rs#L12-L47

StopWithArgs::with_parameters_schema creates a tool named stop whose arguments must match the schema of ReleaseNotes. With a custom schema the whole argument payload is forwarded, without an output wrapper. It arrives in StopReason::RequestedByTool, where the session deserializes it into ReleaseNotes. render in src/notes.rs prints that value as the summary at the end of a run.

examples/release-notes-agent/src/session.rs
/// Asks the agent for release notes and loops through review rounds until they are written or
/// rejected.
pub async fn draft_release_notes(
agent: &mut Agent,
task: Prompt,
reviewer: &mut impl Reviewer,
) -> Result<Outcome> {
agent.query(task).await?;
let mut written = false;
let mut nudged = false;
loop {
// `write_changelog` is wrapped in `ApprovalRequired`: the agent stops and waits.
let tool_call = match agent.stop_reason() {
Some(StopReason::FeedbackRequired { tool_call, .. }) => tool_call.clone(),
// The custom `stop` tool carries the structured release notes.
Some(StopReason::RequestedByTool(_, Some(notes))) if written => {
let notes = ReleaseNotes::deserialize(notes)
.context("the agent stopped with malformed release notes")?;
return Ok(Outcome::Written { notes: Some(notes) });
}
_ if written => return Ok(Outcome::Written { notes: None }),
// A turn without a tool call, or the step limit.
Some(StopReason::NoNewMessages) => {
nudge_once(agent, &mut nudged).await?;
continue;
}
other => bail!("the agent stopped without writing the notes: {other:?}"),
};
let proposal: WriteChangelogArgs =
serde_json::from_str(tool_call.args().unwrap_or_default())?;
match reviewer.review(&proposal)? {
Verdict::Approve => {
agent
.context()
.feedback_received(&tool_call, &ToolFeedback::approved())
.await?;
// Resumes the pending tool call, which now runs, and continues the loop.
agent.run().await?;
// The write itself can still fail; the agent then hears why and carries on.
written |= succeeded(agent, &tool_call).await?;
}
Verdict::Revise(feedback) => {
agent
.context()
.feedback_received(&tool_call, &ToolFeedback::refused())
.await?;
// The refusal is recorded as the tool's output, followed by the feedback.
agent.query(feedback).await?;
}
Verdict::Reject => return Ok(Outcome::Rejected),
}
}
}

Source: examples/release-notes-agent/src/session.rs#L41-L96

ApprovalRequired returns ToolOutput::FeedbackRequired until the context holds feedback for that tool call. The agent then stops with StopReason::FeedbackRequired, which carries the pending tool call. To approve, record ToolFeedback::approved() with feedback_received and call run(). The pending call then runs the inner tool. To ask for changes, record ToolFeedback::refused() and send your feedback with query(). The model sees “This tool call was refused” as the tool output, followed by your message.

The session reports success only when the approved write returned text. A write that fails goes back to the model, and the loop continues.

The step counter resets on every query() and run(). When the agent reaches the limit, it stops with StopReason::NoNewMessages, the same stop reason as a turn without tool calls. The session tells the two apart from the message history. After a turn without a tool call it asks the model once to continue. At the limit, or after a second empty turn, it gives up.

examples/release-notes-agent/src/mcp.rs
/// Starts `@modelcontextprotocol/server-github` with `npx` and exposes a few of its tools.
///
/// Errors explain what is missing, so the caller can carry on without GitHub.
pub async fn github_toolbox() -> Result<impl ToolBox> {
let token = env::var("GITHUB_PERSONAL_ACCESS_TOKEN")
.or_else(|_| env::var("GITHUB_TOKEN"))
.context("set GITHUB_PERSONAL_ACCESS_TOKEN (or GITHUB_TOKEN)")?;
let server = Command::new("npx").configure(|cmd| {
cmd.args(["-y", "@modelcontextprotocol/server-github"])
.env("GITHUB_PERSONAL_ACCESS_TOKEN", token);
});
let (transport, _stderr) = TokioChildProcess::builder(server)
.stderr(Stdio::null())
.spawn()
.context("could not run `npx`; is Node.js installed?")?;
let mut toolbox = timeout(
Duration::from_mins(1),
McpToolbox::try_from_transport(transport),
)
.await
.context("the GitHub MCP server did not start within a minute")?
.context("the GitHub MCP server failed to start")?;
toolbox.with_whitelist(GITHUB_TOOLS);
// Swiftide names MCP tools `<server>:<tool>`; OpenAI rejects ':', see `OpenAiToolNames` below.
let toolbox = OpenAiToolNames(toolbox);
// Fail early, rather than mid-run, if the server does not offer the tools we expect.
let available = toolbox.available_tools().await?;
if available.len() != GITHUB_TOOLS.len() {
bail!("the GitHub MCP server does not offer {GITHUB_TOOLS:?}");
}
Ok(toolbox)
}

Source: examples/release-notes-agent/src/mcp.rs#L18-L52

--mcp starts the GitHub Model Context Protocol (MCP) server as a child process. McpToolbox::try_from_transport connects to it, and with_whitelist keeps two read-only tools, get_pull_request and get_issue. Swiftide registers MCP tools as <server>:<tool>, and OpenAI rejects : in tool names. The example wraps the toolbox to replace those characters with _, so the model sees the two tools with the server name and _ in front. Without npx, a token or a GitHub origin, the CLI continues without these tools.

examples/release-notes-agent/src/telemetry.rs
/// Logs warnings to stderr (override with `RUST_LOG`) and, with `trace`, sends traces to
/// Langfuse.
pub fn init(trace: bool) -> Result<Self> {
let logs = tracing_subscriber::fmt::layer()
.compact()
.with_writer(io::stderr)
.with_ansi(io::stderr().is_terminal())
.with_filter(
EnvFilter::try_from_default_env()
// The agent logs stops and approvals as warnings, and errors it also returns;
// the CLI reports both itself.
.unwrap_or_else(|_| EnvFilter::new("warn,swiftide_agents=off")),
);
let langfuse = if trace {
for key in ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"] {
ensure!(env::var_os(key).is_some(), "--trace needs {key}");
}
// Reads the keys and `LANGFUSE_URL` (default http://localhost:3000) from the env.
Some(LangfuseLayer::default())
} else {
None
};
tracing_subscriber::registry()
.with(logs)
.with(
langfuse
.clone()
// Langfuse data is logged at debug level; only send Swiftide's spans, not
// those of every dependency.
.map(|layer| {
layer
.with_filter(Targets::new().with_target("swiftide", LevelFilter::DEBUG))
}),
)
.try_init()?;
Ok(Self { langfuse })
}

Source: examples/release-notes-agent/src/telemetry.rs#L23-L62

LangfuseLayer::default() reads the keys and LANGFUSE_URL (default http://localhost:3000) from the environment. It panics when a key is missing or empty. init checks that the keys are set first, but an empty value still passes that check. The Targets filter matches by prefix, so the layer receives spans and events from every target that starts with swiftide, at debug level and above. main calls flush before the process exits, so buffered events reach Langfuse.

Clone the repository:

Terminal window
git clone https://github.com/bosun-ai/swiftide
cd swiftide

From the checkout root, this drafts notes for swiftide itself and writes RELEASE_NOTES.md there:

Terminal window
cargo run -p release-notes-agent -- --version 0.34.0

Point it at another OpenAI-compatible endpoint, or at another repository:

Terminal window
OPENAI_BASE_URL=http://127.0.0.1:8080/v1 OPENAI_MODEL=claude-sonnet-5-5 \
cargo run -p release-notes-agent -- --version 0.34.0
cargo run -p release-notes-agent -- --repo ../my-crate --since v1.2.0 --mcp --trace

Answer y or yes to write the notes. Any other answer, including an empty one, asks what should change. An empty answer to that second question stops without writing.

cargo test -p release-notes-agent runs the revise round and the nudge against a scripted model (Testing), and checks the step-limit detection on a hand-built message history.

FlagDefault
--repo <path>.The git repository.
--since <tag>latest tagThe previous release.
--version <next>proposed by the agentThe version being released.
--output <file>RELEASE_NOTES.mdFile the notes are prepended to, relative to the repository root.
--mcpoffRead pull requests through the GitHub MCP server.
--traceoffSend traces to Langfuse.
--model <name>gpt-5.4Model name, also read from OPENAI_MODEL.
--responses-apioffUse the OpenAI Responses API instead of chat completions.
--env-file <path>nearest .envLoad environment variables from this file.

This run used claude-sonnet-5-5 through an OpenAI-compatible gateway in front of AWS Bedrock, with chat completions. It covers the 7 commits since v0.33.0, released as 0.34.0, with y piped into stdin. The tool calls in a group run in parallel, but the agent awaits them in the order they were issued, so their results print in call order.

Drafting release notes for the 7 commits since v0.33.0 with claude-sonnet-5-5 at http://127.0.0.1:8080/v1 (chat completions)
→ git_log_since v0.33.0
git_log_since ✓ 346 lines
→ git_diff swiftide-integrations/Cargo.toml 3b1ad22e^..3b1ad22e
→ git_diff …/src/chat_completion/chat_completion_response.rs 90bcadc7^..90bcadc7
...
git_diff ✓ 15 lines
git_diff ✓ 72 lines
...
## 0.34.0
This release adds the opt-in `swiftide-agents-contrib` crate and Perl support for tree-sitter. It also fixes duplicated text in OpenAI streaming and resolves security advisories, which removes AWS Console login from the Bedrock client.
...
→ write_changelog <32 lines>
write_changelog ⏸ waiting for approval
── proposed: 32 lines into a new file, RELEASE_NOTES.md
Write these release notes? [y/N] y
→ write_changelog <32 lines>
write_changelog ✓ wrote RELEASE_NOTES.md
→ stop (version 0.34.0: highlights 2, breaking 1, fixes 1)
stop ■ done
✔ RELEASE_NOTES.md
0.34.0 — This release adds the opt-in `swiftide-agents-contrib` crate and Perl support for tree-sitter. It also fixes duplicated text in OpenAI streaming and resolves security advisories, which removes AWS Console login from the Bedrock client.
highlights (2)
• Agent tools, hooks and executors in `swiftide-agents-contrib` [#1207]
• Perl support for tree-sitter [#1198]
breaking changes (1)
• AWS Console login removed from the Bedrock client [#1215]
fixes (1)
• Repeated text in OpenAI streaming [#1216]

Everything these notes describe, swiftide-agents-contrib, Perl tree-sitter support, the Bedrock change (#1215) and the streaming fix (#1216), shipped in 0.34.

  • The notes are a draft. Check each entry against git show before you approve.
  • --responses-api failed against the gateway used for this run, because the gateway streams Responses events that async-openai cannot parse. Use it with OpenAI itself.
  • --trace is untested against a live Langfuse; check that traces arrive before relying on them.

Workspace architect