# Release notes agent

> An agent that reads git history through scoped tools, streams a release-notes draft, waits for your approval, and ends with structured notes.

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.

## Prerequisites

- 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](/integrations/overview/#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:

```sh
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.

## Architecture

![Agent loop](/diagrams/diagram-agent-loop.svg)

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.

## Tool executor

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

## Tools

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

### Stateful tool

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

## System prompt

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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](/reference/upgrading/). `with_context_value` fills a template variable.

## Agent

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

## Hooks and streaming

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

## Structured stop

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

## Approval

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

### Turn limit and nudges

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.

## MCP tools

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

## Tracing

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

## Commands

Clone the repository:

```sh
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:

```sh
cargo run -p release-notes-agent -- --version 0.34.0
```

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

```sh
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](/agents/testing/)), and checks the step-limit detection on a hand-built message history.

| Flag                | Default               |                                                                   |
| ------------------- | --------------------- | ----------------------------------------------------------------- |
| `--repo <path>`     | `.`                   | The git repository.                                               |
| `--since <tag>`     | latest tag            | The previous release.                                             |
| `--version <next>`  | proposed by the agent | The version being released.                                       |
| `--output <file>`   | `RELEASE_NOTES.md`    | File the notes are prepended to, relative to the repository root. |
| `--mcp`             | off                   | Read pull requests through the GitHub MCP server.                 |
| `--trace`           | off                   | Send traces to Langfuse.                                          |
| `--model <name>`    | `gpt-5.4`             | Model name, also read from `OPENAI_MODEL`.                        |
| `--responses-api`   | off                   | Use the OpenAI Responses API instead of chat completions.         |
| `--env-file <path>` | nearest `.env`        | Load environment variables from this file.                        |

## Output

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.

```text
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.

## Gotchas

- 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.

## Concept pages

- [Tools](/agents/tools/) and [Executors](/agents/executors/)
- [System prompts](/agents/system-prompts/)
- [Hooks](/agents/hooks/) and [Streaming](/agents/streaming/)
- [Stopping and structured results](/agents/stopping-and-structured-results/) and [Structured output](/models/structured-output/)
- [Human in the loop](/agents/human-in-the-loop/)
- [MCP](/agents/mcp/)
- [Langfuse](/observability/langfuse/)

## Reference

- [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html)
- [`AgentBuilder`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html)
- [`DefaultContext`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html)
- [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html)
- [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html)
- [`ApprovalRequired`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.ApprovalRequired.html)
- [`StopWithArgs`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.StopWithArgs.html)
- [`McpToolbox`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/mcp/struct.McpToolbox.html)
- [`ToolFeedback`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.ToolFeedback.html)
- [`ToolExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolExecutor.html)
- [`AgentContext`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html)
- [`ToolBox`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolBox.html)
- [`Command`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.Command.html)
- [`CommandOutput`](https://docs.rs/swiftide/0.34/swiftide/traits/struct.CommandOutput.html)
- [`ToolOutput`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ToolOutput.html)
- [`Prompt`](https://docs.rs/swiftide/0.34/swiftide/prompt/struct.Prompt.html)
- [`tool`](https://docs.rs/swiftide/0.34/swiftide/attr.tool.html) and [`Tool`](https://docs.rs/swiftide/0.34/swiftide/derive.Tool.html) macros
- [`OpenAI`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/type.OpenAI.html)
- [`LangfuseLayer`](https://docs.rs/swiftide/0.34/swiftide/langfuse/struct.LangfuseLayer.html)

## Next

[Workspace architect](/showcases/workspace-architect/)