Agents: building agents with tools, hooks and executors # Context and message history > Store an agent's message history in memory or Redis, resume it in a new process, and rewrite it while the agent runs. An agent’s message history can outlive the process and be rewritten while the agent runs. The `agents_resume.rs` example saves the history of one agent as JSON (JavaScript Object Notation) and loads it into a second one: ```rust // Let's store the messages in a database, retrieve them back, and start a new agent let stored_history = serde_json::to_string(&first_agent.history().await?)?; let retrieved_history: Vec<_> = serde_json::from_str(&stored_history)?; let restored_context = DefaultContext::default() .with_existing_messages(retrieved_history) .await? .to_owned(); ``` ```rust let mut second_agent = agents::Agent::builder() .llm(&openai) .context(restored_context) // We'll use the one from the first agent, alternatively we could also pop it from the // previous history and add a new one here .no_system_prompt() .build()?; second_agent.query("What did you say?").await?; ``` `Agent::history()` returns every `ChatMessage` the context holds. `ChatMessage` implements `Serialize` and `Deserialize`, so `serde_json` turns the history into a string you can store anywhere. `DefaultContext::with_existing_messages` loads the messages back into a fresh context. The second agent gets that context through the builder’s `context` method and continues the conversation with `query`. The next completion sends the whole restored history to the model. The first agent sets no system prompt, so `no_system_prompt()` changes nothing in this example. [Prompt removal](/agents/system-prompts/#prompt-removal) explains when it matters. `run` without a new message only continues when the history ends in a user message, a tool output or a pending tool call. When it ends in a plain assistant answer, as it does here, `run` stops at once with `StopReason::NoNewMessages`. ## Default context The context stores the message history, decides when the next completion runs and what it contains, holds the tool executor and records feedback for tools that wait on a person. Every agent built without `.context(..)` gets a `DefaultContext`: * the message history is an in-memory `Mutex>`; * tools run commands through a `LocalExecutor` in the current directory; * the agent stops when the last message is an assistant answer and no tool feedback is pending. `DefaultContext::from_executor` keeps those defaults and swaps the tool executor. It takes a value that converts into `Arc`. [Tool executors](/agents/executors/) covers the conversion and the tool executors themselves. `with_stop_on_assistant(false)` turns off the last rule. A text answer then no longer ends the run, and the agent asks the model again. Only a tool that stops the agent, the builder’s `limit`, or a model reply with no content and no tool calls ends the loop. An agent has no limit by default, so set one ([Stop reasons](/agents/stopping-and-structured-results/#stop-reasons)). A clone of a `DefaultContext` shares its history and counters. ## Message history Where the messages live is the job of the `MessageHistory` trait. It has three required methods: * `history` returns all messages in order; * `push_owned` appends one message; * `overwrite` replaces all messages. The trait requires `Send + Sync + Debug` and is declared with `#[async_trait]`, so annotate your impl the same way. `push`, `extend` and `extend_owned` have default implementations on top of `push_owned`. Implement the trait for your database and pass the value to `DefaultContext::with_message_history`. The backend only stores the list. Each completion sends the full history, cut at the latest summary, and the context’s counters only decide whether a new completion runs. ### Custom backend This backend keeps one conversation in a JSON file. It uses the `async-trait` crate for the `#[async_trait]` attribute: ```rust /// Keeps the message history of one conversation in a JSON file. #[derive(Debug)] struct JsonFileHistory { path: PathBuf, // Serializes read-modify-write cycles on the file. lock: Mutex<()>, } impl JsonFileHistory { async fn read(&self) -> anyhow::Result> { match tokio::fs::read_to_string(&self.path).await { Ok(json) => Ok(serde_json::from_str(&json)?), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), Err(err) => Err(err.into()), } } async fn write(&self, messages: &[ChatMessage]) -> anyhow::Result<()> { tokio::fs::write(&self.path, serde_json::to_vec(messages)?).await?; Ok(()) } } #[async_trait] impl MessageHistory for JsonFileHistory { async fn history(&self) -> anyhow::Result> { let _guard = self.lock.lock().await; self.read().await } async fn push_owned(&self, item: ChatMessage) -> anyhow::Result<()> { let _guard = self.lock.lock().await; let mut messages = self.read().await?; messages.push(item); self.write(&messages).await } async fn overwrite(&self, items: Vec) -> anyhow::Result<()> { let _guard = self.lock.lock().await; self.write(&items).await } } ``` ```rust let mut context = DefaultContext::default(); context.with_message_history(JsonFileHistory { path: "session-42.json".into(), lock: Mutex::new(()), }); let mut agent = Agent::builder().llm(&openai).context(context).build()?; ``` Every method takes `&self`, so the backend guards its own state; here a `tokio::sync::Mutex` keeps two writes from interleaving. `history` returns an empty list for a file that does not exist yet. `with_message_history` takes the value by ownership and stores it behind an `Arc`. ## Redis storage The `redis` feature flag adds a `Redis` integration that implements `MessageHistory`: ```sh cargo add swiftide@0.34 --features swiftide-agents,openai,redis ``` ```rust let redis = Redis::try_from_url("redis://localhost:6379", "session-42")?; let mut context = DefaultContext::default(); context.with_message_history(redis); let mut agent = Agent::builder() .llm(&openai) .context(context) .system_prompt("You help maintain the swiftide repository.") .build()?; agent.query("Which crates changed this week?").await?; ``` The messages go into a Redis list under `:message_history`, here `session-42:message_history`, one JSON string per message. Use one prefix per conversation. A new process that builds a context with the same prefix sees the whole conversation again. Build that agent without `.system_prompt(..)`, or with `no_system_prompt()`, as in the first example. Otherwise its first run pushes another system message onto the list. The connection opens on first use. If Redis is unreachable, the agent returns `AgentError::MessageHistoryError`. ## Approvals across restarts Approvals are not part of the message history. They live in the context’s memory. When the agent stops with `StopReason::FeedbackRequired`, store the `ToolCall` it carries; the person’s `ToolFeedback` comes later. Store each decision as a `(ToolCall, ToolFeedback)` pair, since `serde_json` cannot use a struct as a map key. Both types implement `Serialize` and `Deserialize`. Collect the pairs into a `HashMap` for `DefaultContext::with_tool_feedback` in the new process. [Human in the loop](/agents/human-in-the-loop/) covers approvals. ```rust // First process: the agent waits for approval; store the person's decision with its call. if let Some(StopReason::FeedbackRequired { tool_call, .. }) = agent.stop_reason() { let decisions = vec![(tool_call.clone(), ToolFeedback::approved())]; std::fs::write("approvals.json", serde_json::to_string(&decisions)?)?; } // Second process: load the decisions next to the restored message history, then resume. let decisions: Vec<(ToolCall, ToolFeedback)> = serde_json::from_str(&std::fs::read_to_string("approvals.json")?)?; let mut context = DefaultContext::default(); context.with_message_history(Redis::try_from_url("redis://localhost:6379", "session-42")?); context.with_tool_feedback(decisions.into_iter().collect::>()); let mut resumed = Agent::builder() .llm(&openai) .context(context) .no_system_prompt() .build()?; resumed.run().await?; ``` `with_tool_feedback` accepts anything that converts into a `HashMap` and adds the entries to the feedback the context already holds. The restored history still ends in the pending tool call, so `run` calls the tool again, and the tool now finds its approval. ## History rewrites Since 0.33, `AgentContext::replace_history` swaps the whole history while the agent runs. It takes `&self`, so any hook can call it through `agent.context()`. This `after_each` hook shortens old tool outputs once the history passes `MAX_MESSAGES`: ```rust const MAX_MESSAGES: usize = 40; const KEEP_RECENT: usize = 10; let mut agent = Agent::builder() .llm(&openai) .after_each(|agent: &mut Agent| { Box::pin(async move { let history = agent.history().await?; if history.len() <= MAX_MESSAGES { return Ok(()); } let keep_from = history.len() - KEEP_RECENT; let trimmed = history .into_iter() .enumerate() .map(|(index, message)| match message { ChatMessage::ToolOutput(call, ToolOutput::Text(_)) if index < keep_from => { ChatMessage::ToolOutput(call, ToolOutput::text("(output removed)")) } other => other, }) .collect(); agent.context().replace_history(trimmed).await?; Ok(()) }) }) .build()?; ``` The hook only replaces the text of older tool outputs. Each tool call keeps its output, which the providers require. `DefaultContext` writes the new list through `overwrite` and resets its completion counters. The next completion then runs even if no new message was added since the last one. ## Summaries A `ChatMessage::Summary` shortens what the model sees without rewriting storage. When the history holds one, `DefaultContext` sends the system messages plus everything from the latest summary on. Add one with `ChatMessage::new_summary` through `agent.context().add_message(..)`. OpenAI-compatible providers send a summary as an assistant message. `ContextCompression` in agents-contrib writes these summaries for you; see [Contrib](/agents/contrib/). ## Custom contexts For behavior the message history cannot express, implement `AgentContext` yourself. Wrap a `DefaultContext` and forward the calls you do not change. The [upgrading guide](/reference/upgrading/) has a context that counts added messages: ```rust /// Counts the messages an agent adds and delegates everything else. #[derive(Clone)] pub struct CountingContext { inner: DefaultContext, added: Arc, } #[async_trait] impl AgentContext for CountingContext { async fn next_completion(&self) -> anyhow::Result>> { self.inner.next_completion().await } async fn current_new_messages(&self) -> anyhow::Result> { self.inner.current_new_messages().await } async fn add_messages(&self, items: Vec) -> anyhow::Result<()> { self.added .fetch_add(items.len(), std::sync::atomic::Ordering::Relaxed); self.inner.add_messages(items).await } async fn add_message(&self, item: ChatMessage) -> anyhow::Result<()> { self.added .fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.inner.add_message(item).await } #[allow(deprecated)] async fn exec_cmd(&self, cmd: &Command) -> Result { self.inner.exec_cmd(cmd).await } fn executor(&self) -> &Arc { self.inner.executor() } async fn history(&self) -> anyhow::Result> { self.inner.history().await } async fn replace_history(&self, items: Vec) -> anyhow::Result<()> { self.inner.replace_history(items).await } async fn redrive(&self) -> anyhow::Result<()> { self.inner.redrive().await } async fn has_received_feedback(&self, tool_call: &ToolCall) -> Option { self.inner.has_received_feedback(tool_call).await } async fn feedback_received( &self, tool_call: &ToolCall, feedback: &ToolFeedback, ) -> anyhow::Result<()> { self.inner.feedback_received(tool_call, feedback).await } } ``` ## Gotchas * `with_existing_messages` calls `overwrite` on the backend. With Redis it deletes the list under the key and writes the given messages. * `with_existing_messages` and `with_message_history` return `&mut DefaultContext`. Use a `let mut` binding, or end the chain with `.to_owned()` as the example does. * A builder-made `Redis` uses the key `message_history` unless you set `message_history_key(..)` on the builder or call `with_message_history_key` afterwards. Agents that share that key also share one history. * Messages added through `agent.context()` do not trigger `on_new_message` hooks. Only `Agent::add_message` runs them. ## Reference * [`DefaultContext`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html) * [`DefaultContext::with_existing_messages`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html#method.with_existing_messages) * [`DefaultContext::with_message_history`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html#method.with_message_history) * [`DefaultContext::with_tool_feedback`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html#method.with_tool_feedback) * [`AgentContext`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html) * [`AgentContext::replace_history`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html#tymethod.replace_history) * [`MessageHistory`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.MessageHistory.html) * [`Redis`](https://docs.rs/swiftide/0.34/swiftide/integrations/redis/struct.Redis.html) * [`Redis::try_from_url`](https://docs.rs/swiftide/0.34/swiftide/integrations/redis/struct.Redis.html#method.try_from_url) * [`ChatMessage`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ChatMessage.html) * [`ChatMessage::new_summary`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ChatMessage.html#method.new_summary) * [`ToolFeedback`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.ToolFeedback.html) * [`AgentBuilder::context`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.context) * [`AgentBuilder::no_system_prompt`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.no_system_prompt) * [`Agent::history`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html#method.history) ## Next [Tool executors](/agents/executors/) # Contrib tools and hooks > Give an agent file tools (read_file, edit, apply_patch), an update_plan tool, hooks that truncate tool output and compress the conversation, and an executor that starts on first use, from swiftide-agents-contrib. With the components on this page, your agent reads and edits files, keeps a plan, and keeps its context small on long runs. They come from swiftide-agents-contrib. ```rust let openai = OpenAI::builder().default_prompt_model("gpt-5.4").build()?; let executor: Arc = Arc::new(LocalExecutor::new("/srv/checkout")); let mut agent = Agent::builder() .llm(&openai) .context(DefaultContext::from_executor(executor)) .system_prompt("You fix failing tests. Keep a plan with update_plan.") .add_tool(ReadFile::default().with_line_numbers()) .add_tool(ExactReplace) .add_tool(ApplyPatch) .add_tool(UpdatePlan::default()) .limit(50) .build()?; agent .query("The test `parses_empty_input` in src/parser.rs fails. Fix it.") .await?; ``` `limit(50)` caps the run at 50 completions, because a fixing loop can run long. The four contrib tools sit next to the `stop` tool that every agent gets. Every file operation runs as a command through the tool executor of the context, here a `LocalExecutor` rooted at the checkout. ## Feature flags Nothing in the crate is enabled by default. Pick one feature flag per capability: | Feature flag | Adds | | ------------------------------ | ---------------------------------------------------- | | `agents-contrib-file-tools` | `ReadFile`, `ExactReplace`, `ApplyPatch` | | `agents-contrib-planning` | `UpdatePlan`, `PlanItem`, `PlanStatus`, `PlanMemory` | | `agents-contrib-context` | `ToolSummarizer`, `ContextCompression` | | `agents-contrib-lazy-executor` | `LazyExecutor`, `ToolExecutorExt` | Each one turns on `agents-contrib`, which turns on `swiftide-agents`. The plain `agents-contrib` flag only exposes the crate as `swiftide::agents_contrib`: ```sh cargo add swiftide@0.34 --features openai,agents-contrib-file-tools,agents-contrib-planning cargo add tokio --features full cargo add anyhow ``` A feature flag never registers a tool or a hook. You add each component to the builder yourself. [Feature flags](/reference/feature-flags/) lists every flag. ## File tools The model sees these tool names: | Type | Tool name | Arguments | | ----------------------------------------- | ----------------------------- | --------------------------------------------------------------- | | `ReadFile::default()` | `read_file` | `file_name`, optional `offset` and `limit` | | `ReadFile::default().with_line_numbers()` | `read_file_with_line_numbers` | same, output prefixed with `N\|` line numbers | | `ExactReplace` | `edit` | `file_path`, `old_string`, `new_string`, optional `replace_all` | | `ApplyPatch` | `apply_patch` | `patch` | `read_file` returns up to 2,000 lines by default. It cuts single lines at 2,000 characters and refuses a window over 100,000 characters. When it stops early, the output tells the model which offset to read next. `edit` replaces a match only when it is unique, unless the model sets `replace_all`. `apply_patch` takes a patch in this format: ```text *** Begin Patch *** Add File: hello.txt +Hello *** Update File: src/main.rs @@ fn greet() - println!("Hi"); + println!("Hello"); *** End Patch ``` `*** Move to: new/path` after an update header moves a file. `*** Delete File: path` deletes one. Lines starting with a space are context, `-` removes a line and `+` adds one. The file tools need a tool executor with a POSIX (Portable Operating System Interface) shell and the `cat`, `mkdir` and `rm` commands. ## Plans `UpdatePlan` registers `update_plan`. The model sends `items`, each with a `step` and a `status` of `pending`, `in_progress` or `completed`, plus an `explanation` string. Both arguments are required. An empty `items` list returns the current plan without changing it. The tool answers with a `ToolOutput::fail` when a step is empty or spans several lines, or when more than one step is in progress. The stored plan stays as it was. Clones of one `UpdatePlan` share the plan, while two `UpdatePlan::default()` values each have their own. The plan lives in memory and is not persisted. ## Context management Two hooks keep the message history short: ```rust let small_model = OpenAI::builder() .default_prompt_model("gpt-5-mini") .build()?; let summarizer = ToolSummarizer::builder() .llm(Arc::new(small_model.clone()) as Arc) .tools_to_summarize(vec!["run_tests"]) .git_start_sha("HEAD") .build()?; let compression = ContextCompression::builder() .llm(Arc::new(small_model) as Arc) .git_start_sha("HEAD") .git_diff_dir(".") .context_limit(200_000usize) .build()?; builder.after_tool(summarizer.summarize_hook()); compression.register_hooks(builder); ``` Call both on the same `AgentBuilder` before `build()`. `tools_to_summarize` only applies to tools the agent has registered. A listed tool the agent lacks is skipped with a warning. `run_tests` here stands for a tool you define yourself. `ToolSummarizer::summarize_hook` returns an `after_tool` hook. It truncates any tool output over an estimated 10,000 tokens to its first 40 and last 160 lines. It then cuts the result to a character budget of four characters per token, 40,000 by default, so a long line can still end mid-line. Finally, it summarizes the output of the tools named in `tools_to_summarize` with the model you pass to `llm`. Set `summarize_all_at_token_cutoff` to summarize any large output. By default, `skip_summarize_all` exempts the file-read tools from truncation and from `summarize_all_at_token_cutoff`. For the tools in `tools_needing_diff` (default `run_tests` and `run_coverage`), the summary prompt includes `git diff` against `git_start_sha`. `ContextCompression::register_hooks` adds an `after_completion` and an `after_each` hook. They check three triggers in this order: 1. A turn that ends with a file read postpones any summary until the history reaches 90% of `context_limit`. 2. The sixth turn after the last summary triggers a new summary (`num_completions_for_summary`). 3. A history estimated above 70% of `context_limit` triggers one, unless the agent has stopped. The model you pass to `llm` writes the summary from the history, a Git diff and the plan progress. The hook stores the summary as a `ChatMessage::Summary`. A `DefaultContext` then sends only the system messages and everything from the latest summary on ([Summaries](/agents/context-and-history/#summaries)). When the summary interrupts a tool turn, the hook also appends a user message `Continue` to the message history. After the first summary, the hook adds a `read_original_instructions` tool so the model can recover the first user message. It adds the tool only when the history holds a user message. `ContextCompression`, and `ToolSummarizer` for the tools in `tools_needing_diff`, run `git diff` through the agent’s tool executor. Point that tool executor at a Git repository. ## Lazy executor `LazyExecutor` defers expensive setup, such as cloning a repository or starting a container, until the agent first needs it: ```rust let lazy = LazyExecutor::new(|| async { // Clone a repository, start a container, ... before the first command runs. Ok(Arc::new(LocalExecutor::new("/srv/checkout")) as Arc) }); assert!(!lazy.is_started()); let executor: Arc = Arc::new(lazy); let agent = Agent::builder() .llm(openai) .context(DefaultContext::from_executor(executor)) .add_tool(ReadFile::default()) .build()?; ``` `LazyExecutor::new` takes a closure whose future resolves to an `anyhow::Result>`. The first command, file stream or download calls it. Clones share the started executor. A failed start is retried on the next use. `is_started` returns true once the closure has succeeded. `ToolExecutorExt` adds `is_started`, `cleanup` and `download` to a tool executor, so bring the trait into scope to call them. It is implemented for `LocalExecutor`, for `LazyExecutor` itself and for a `ScopedExecutor` whose inner executor implements `ToolExecutorExt` and `Clone`. `cleanup` before the start does nothing. `download` runs `dd`, `base64` and `sha256sum` in the tool executor. ## Gotchas * `LocalExecutor` does not isolate the file system. `read_file` and `edit` pass absolute paths to the tool executor unchanged. `apply_patch` only takes relative paths without `..` components, but it follows symlinks like the other tools. Use an isolated executor, such as the separate swiftide-docker-executor crate, for repositories you do not trust. * Patches are not transactions. `apply_patch` runs its operations in order and can partly succeed, so the model has to read the result report. * Tools are unique by name. If you already have a tool called `read_file` or `edit`, `add_tool` replaces it with the contrib tool, or the other way around. * The default `context_limit` is 400,000,000. Set one that matches your model, or compression only triggers on the turn count. * `ContextCompression` keeps its turn counter behind an `Arc`, shared with clones. Build a fresh one for every independent agent. * Summaries lose detail. Keep facts the model must not forget in the system prompt. * Debug logs contain the summary prompts and summaries, including file contents. ## Reference The links point at the source on GitHub. * [swiftide-agents-contrib README](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/README.md) * [`ReadFile`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/src/tools/read_file.rs), [`ExactReplace`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/src/tools/exact_replace.rs), [`ApplyPatch`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/src/tools/apply_patch/mod.rs) * [`UpdatePlan`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/src/tools/update_plan.rs) * [`ToolSummarizer`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/src/hooks/tool_summarizer.rs), [`ContextCompression`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/src/hooks/context_compression.rs) * [`LazyExecutor`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/src/executors/lazy.rs), [`ToolExecutorExt`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/src/executors/traits.rs) * [contrib examples](https://github.com/bosun-ai/swiftide/tree/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/swiftide-agents-contrib/examples) * [`AgentBuilder::add_tool`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.add_tool), [`AgentBuilder::after_tool`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.after_tool) * [`DefaultContext::from_executor`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html#method.from_executor) * [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html) * [Feature flags](/reference/feature-flags/) ## Next [Testing agents](/agents/testing/) # Tool executors > Run agent tool commands through a ToolExecutor: a LocalExecutor with a working directory, timeout and environment, scoped executors, streamed output, and model arguments validated before they reach a shell. Your tools run shell commands and file operations through a tool executor instead of spawning processes themselves. You end up with one that works in a chosen directory, stops slow commands, streams their output, and only receives arguments your code has checked. The local executor needs the `swiftide-agents` feature flag: ```sh cargo add swiftide@0.34 --features swiftide-agents ``` ## Local executor ```rust let executor = LocalExecutor::builder() .workdir("/srv/checkout") .default_timeout(Some(Duration::from_secs(30))) .env_remove(vec!["OPENAI_API_KEY".to_owned(), "GITHUB_TOKEN".to_owned()]) .envs(HashMap::from([( "CARGO_TERM_COLOR".to_owned(), "never".to_owned(), )])) .build()?; ``` The builder sets where and how commands run: * `workdir` sets the directory commands start in. It defaults to `.`. `LocalExecutor::new` only sets this field. * `default_timeout` takes an `Option` for commands without a timeout of their own. * `env_remove` drops variables, here secrets that the model’s commands do not need. * `envs` sets variables. They apply after `env_clear` and `env_remove`. * `env_clear(true)` (not shown) starts every command with an empty environment. That also drops `PATH` and `HOME`, so pass the ones your commands need back through `envs`. The agent gets the executor through its context. [Context and message history](/agents/context-and-history/) shows how `DefaultContext::from_executor` swaps it in. ## Tool executor trait `ToolExecutor` has three methods: * `exec_cmd` runs a `Command` and returns its `CommandOutput` or a `CommandError`. * `exec_cmd_streaming` also hands each piece of output to a closure. The default implementation reports all output once the command has finished. * `stream_files` returns the files below a path as an `IndexingStream` for an indexing pipeline; see [Loading](/indexing/loading/). A tool reaches the executor with `context.executor()`. `AgentContext::exec_cmd` is deprecated. ### Custom executor You write your own tool executor to run commands somewhere else, such as a remote machine, or to put a policy in front of another executor. This one wraps any executor and refuses file writes: ```rust /// Runs commands through another executor, but refuses to write files. #[derive(Clone)] pub struct ReadOnly { inner: E, } impl ReadOnly { fn check(cmd: &Command) -> Result<(), CommandError> { if matches!(cmd, Command::WriteFile { .. }) { return Err(CommandError::ExecutorError(anyhow::anyhow!( "this executor is read-only" ))); } Ok(()) } } #[async_trait] impl ToolExecutor for ReadOnly { async fn exec_cmd(&self, cmd: &Command) -> Result { Self::check(cmd)?; self.inner.exec_cmd(cmd).await } async fn exec_cmd_streaming( &self, cmd: &Command, output: &mut CommandOutputSink<'_>, ) -> Result { Self::check(cmd)?; self.inner.exec_cmd_streaming(cmd, output).await } async fn stream_files( &self, path: &Path, extensions: Option>, ) -> anyhow::Result> { self.inner.stream_files(path, extensions).await } } ``` The trait uses `#[async_trait]`, so add the `async-trait` crate. The trait extends `DynClone`, so your type must implement `Clone`. `exec_cmd` and `stream_files` are required. `ReadOnly` forwards `exec_cmd_streaming` as well; without it, the default would run `exec_cmd` and report the output only at the end. `Command` is non-exhaustive, so match it with a wildcard arm or `matches!`. Return `CommandError::ExecutorError` when the executor refuses or fails, and `NonZeroExit` when the command ran and failed. An executor that cannot stream can return `IndexingStream::from(anyhow::Error)`, so a loader reports the error instead of panicking. Give the agent your executor the same way as a local one: ```rust let executor: Arc = Arc::new(ReadOnly { inner: LocalExecutor::new("/srv/checkout"), }); let agent = Agent::builder() .llm(&openai) .context(DefaultContext::from_executor(executor)) .build()?; ``` `ReadOnly` only blocks `Command::WriteFile`. A shell string such as `echo hi > notes.md` still writes, so a real policy also has to check or forbid `Command::Shell`. ## Output streaming ```rust let (chunks, mut received) = tokio::sync::mpsc::unbounded_channel(); let printer = tokio::spawn(async move { while let Some(chunk) = received.recv().await { match chunk { CommandOutputChunk::Stdout(bytes) => print!("{}", String::from_utf8_lossy(&bytes)), CommandOutputChunk::Stderr(bytes) => eprint!("{}", String::from_utf8_lossy(&bytes)), } } }); let cmd = Command::shell("cargo test").with_timeout(Duration::from_secs(600)); let result = executor .exec_cmd_streaming(&cmd, &mut |chunk: &CommandOutputChunk| { // The sink runs inside the executor's read loop: hand the chunk off, never block. let _ = chunks.send(chunk.clone()); }) .await; drop(chunks); printer.await?; let output = result?; println!("{} bytes of output", output.len()); ``` `exec_cmd_streaming` passes each chunk to a sink while the command runs. The sink is a `CommandOutputSink`: a `FnMut(&CommandOutputChunk) + Send` closure. The executor calls the sink from its read loop, so hand each chunk off instead of doing work in it. The local executor streams shell commands and reports file commands once they finish. The return value matches `exec_cmd`. ## Commands You build a `Command` with `Command::shell`, `Command::read_file` or `Command::write_file`, as in the streaming example above and the tool further down. `Command::shell` runs a string with `sh -c`. The file commands skip the shell, so a path never needs quoting. Two methods set options on one command: * `with_current_dir` sets the working directory of the command. * `with_timeout` sets the timeout of the command. It wins over `default_timeout`, also when it is longer. How a relative directory or path resolves is up to the executor. The local executor joins a relative `current_dir` onto its `workdir` and uses an absolute one as it is. File paths resolve against the result. Its `write_file` creates missing parent directories and replaces the file’s content. ## Command output ```rust let cmd = Command::shell("cargo test").with_current_dir("crates/core"); match context.executor().exec_cmd(&cmd).await { Ok(output) => { let stdout: Vec = output.stdout().flat_map(|b| b.iter().copied()).collect(); println!("{}", String::from_utf8_lossy(&stdout)); } Err(CommandError::NonZeroExit(output)) => { for chunk in output.stderr() { eprintln!("{}", String::from_utf8_lossy(chunk)); } } Err(other) => return Err(other.into()), } ``` `CommandOutput` keeps stdout and stderr apart. It holds `CommandOutputChunk::Stdout` and `CommandOutputChunk::Stderr` chunks in the order the executor read them. `stdout()` and `stderr()` iterate over one stream; `to_string_lossy()` and `Display` join both. A `CommandOutput` converts into a `ToolOutput` with the joined text. A failed command returns an error that still carries its output: * `CommandError::NonZeroExit` holds the output of a command that exited with a non-zero code. An input/output (I/O) error, such as reading a missing file, also becomes `NonZeroExit`, with the error message as output. * `CommandError::TimedOut` holds the timeout and the output read so far. For a shell command, the local executor kills the process (its process group on Unix). A file command that times out returns empty output. * `CommandError::ExecutorError` means the executor itself failed. A tool that returns such an error with `?` sends its text to the model and counts toward the tool retry limit. See [Tool errors and retries](/agents/tools/#tool-errors-and-retries). Match `NonZeroExit` yourself when failing is a normal answer, like a failing test. ## Shebang scripts ```rust let script = "#!/usr/bin/env python3\nimport sys\nprint(sys.version_info.major)"; let output = executor.exec_cmd(&Command::shell(script)).await?; println!("python {}", output.to_string_lossy().trim()); ``` When a shell command starts with `#!`, the local executor starts the interpreter named on that line and writes the rest of the string to its stdin. Everything after the interpreter on the first line is passed as one argument, so `#!/usr/bin/env python3` runs `/usr/bin/env` with `python3`. A model can send a multi-line script without escaping it for `sh -c`. ## Scoped executors ```rust let checkout = LocalExecutor::new("/srv/checkout"); // Borrowed: one command in /srv/checkout/docs. let docs = checkout.scoped("docs"); let listing = docs.exec_cmd(&Command::shell("ls")).await?; println!("{listing}"); // Owned: commands without an absolute directory or path run in /srv/checkout/crates/core. let executor: Arc = Arc::new(checkout.scoped_owned("crates/core")); let agent = Agent::builder() .llm(&openai) .context(DefaultContext::from_executor(executor)) .build()?; ``` `ExecutorExt` adds `scoped`, which borrows the executor, and `scoped_owned`, which takes it so it can live as long as an agent. Both return a `ScopedExecutor`: * A command without a working directory runs in the scope. * A relative `current_dir` joins onto the scope. The local executor then joins the result onto its `workdir`. * An absolute `current_dir` is left alone, and so is an absolute file path. A scope does not keep a tool inside it. * `stream_files` joins a relative path onto the scope. The local executor does not add its `workdir` to that path; see the gotchas below. ## Argument validation A working directory is not a sandbox: it only chooses where a command starts. An absolute path, a `..` or a `;` in a shell string still reaches the rest of the machine. Parse model arguments into a type that cannot hold a bad value first. The release-notes-agent showcase validates every argument with `Revision` and `RepoPath` in [`src/git.rs`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/release-notes-agent/src/git.rs). The same pattern for your own tool: ```rust /// A path inside the executor's working directory: relative and without `..`. #[derive(Debug, Clone)] pub struct WorkspacePath(PathBuf); impl FromStr for WorkspacePath { type Err = anyhow::Error; fn from_str(raw: &str) -> anyhow::Result { let path = Path::new(raw.trim()); ensure!(!path.as_os_str().is_empty(), "path is empty"); for component in path.components() { match component { Component::Normal(_) | Component::CurDir => {} Component::ParentDir => bail!("`{raw}` must not contain `..`"), Component::RootDir | Component::Prefix(_) => { bail!("`{raw}` must be relative to the workspace root") } } } Ok(Self(path.to_path_buf())) } } /// Counts the lines of a file in the workspace. /// /// # Arguments /// - `path`: File path relative to the workspace root. #[swiftide::tool] pub async fn count_lines(context: &dyn AgentContext, path: &str) -> Result { let path: WorkspacePath = path.parse()?; let content = context .executor() .exec_cmd(&Command::read_file(&path.0)) .await?; Ok(ToolOutput::text( content.to_string_lossy().lines().count().to_string(), )) } ``` Prefer `Command::read_file` and `Command::write_file` over `cat` or `>` in a shell string: no shell ever sees the path. ## Docker executor For isolation, the [Docker executor](https://github.com/bosun-ai/swiftide-docker-executor) runs the commands in a container. It is a separate crate; pass it to `DefaultContext::from_executor`, and check that its swiftide version matches yours. ## Gotchas * `DefaultContext::from_executor` takes `Into>`. A bare `LocalExecutor` does not convert, so wrap it in an `Arc` annotated as `Arc`, as the scoped example does. * `LocalExecutor::stream_files` uses the path as given, not relative to `workdir`. A relative path resolves against your process’s current directory, also through a scope. Scope it with an absolute path or pass an absolute path. * Symlinks inside the working directory can still point elsewhere. Validated paths do not change that; a container does. ## Reference * [`ToolExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolExecutor.html) * [`IndexingStream`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.IndexingStream.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) * [`CommandOutputChunk`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.CommandOutputChunk.html) * [`CommandOutputSink`](https://docs.rs/swiftide/0.34/swiftide/traits/type.CommandOutputSink.html) * [`CommandError`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.CommandError.html) * [`ExecutorExt`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ExecutorExt.html) * [`ScopedExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/struct.ScopedExecutor.html) * [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html) * [`DefaultContext::from_executor`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html#method.from_executor) * [`AgentContext::executor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html#tymethod.executor) ## Next [Lifecycle hooks](/agents/hooks/) # Lifecycle hooks > Register agent hooks that log tool calls, change completion requests, rewrite tool output and report why an agent stopped. You end up with hooks that print each tool call, change a completion request before it goes out, trim tool output before the model reads it, and report why an agent stopped. A hook is a closure that the agent calls at a fixed point in its loop. The closure returns a boxed future that the agent awaits. Hooks need the `swiftide-agents` feature flag. The release-notes showcase prints one line per tool call and one per result: examples/release-notes-agent/src/progress.rs ```rust /// 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(()) }) }); } ``` ## Hook registration You pass a closure to an `AgentBuilder` method such as `before_tool`. The closure returns `Box::pin(async { Ok(()) })`, which resolves to `anyhow::Result<()>`. An `async` closure does not satisfy the hook traits. The closure must be `Clone`, `Send` and `Sync`. Share state through an `Arc`. Capturing a value that is not `Clone`, such as an `mpsc::Receiver`, fails to compile. Hooks of one kind run in the order you register them. `add_hook` appends a `Hook` value, and `hooks` replaces all earlier hooks. This is the start of a real run on the Swiftide repository, with `claude-sonnet-5-5` behind an OpenAI-compatible gateway: ```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 swiftide-agents-contrib/Cargo.toml 71fa4587^..71fa4587 → git_diff Cargo.toml 71fa4587^..71fa4587 → git_diff swiftide-integrations/src/aws_bedrock_v2/mod.rs 3b1ad22e^..3b1ad22e → git_diff swiftide/Cargo.toml 71fa4587^..71fa4587 git_diff ✓ 15 lines git_diff ✓ 72 lines git_diff ✓ 75 lines git_diff ✓ No changes to Cargo.toml in 71fa4587^..71fa4587. git_diff ✓ 18 lines git_diff ✓ 32 lines ``` The `→` lines come from `before_tool` and the `✓` lines from `after_tool`. The agent runs `before_tool` for a call and then starts that call, so the tool calls of one completion run concurrently. An earlier tool can already be running while `before_tool` runs for a later one. It then waits for the results and runs `after_tool` in call order. ## Hook points | Builder method | When it runs | What it receives | | ------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------- | | `before_all` | Once, on the first run, before the system prompt renders and toolboxes load | `&mut Agent` | | `on_start` | On every `query` or `run`, before its first completion | `&mut Agent` | | `before_completion` | Before each completion request | `&mut Agent`, `&mut ChatCompletionRequest` | | `on_stream` | For each stream chunk, when the agent streams | `&Agent`, `&ChatCompletionResponse` | | `after_completion` | After each completion, before the agent acts on it | `&mut Agent`, `&mut ChatCompletionResponse` | | `on_new_message` | When `Agent::add_message` adds a message | `&Agent`, `&mut ChatMessage` | | `before_tool` | Before each tool call starts | `&mut Agent`, `&ToolCall` | | `after_tool` | After each tool call, before its output is recorded | `&mut Agent`, `&ToolCall`, `&mut Result` | | `after_each` | After a completion and its tool calls | `&mut Agent` | | `on_stop` | When the agent records a stop reason | `&mut Agent`, `StopReason`, `Option<&AgentError>` | Since 0.33, most hooks receive `&mut Agent`, so they can stop the agent, read its history or change its tools: ```rust let mut agent = Agent::builder() .llm(&openai) .after_each(|agent: &mut Agent| { Box::pin(async move { if agent.history().await?.len() > 40 { agent.stop("history too long").await; } Ok(()) }) }) .build()?; ``` Only the `on_stream` builder method turns streaming on. A `Hook::OnStream` passed to `add_hook` or `hooks` never fires unless streaming is on. [Streaming](/agents/streaming/) covers stream chunks. ## Completion request and response edits ```rust let agent = Agent::builder() .llm(openai) .before_completion(|_agent, request| { println!( "sending {} messages and {} tools", request.messages().len(), request.tools_spec().len() ); // Only this request gets the reminder; the message history stays as it is. request .messages .to_mut() .push(ChatMessage::new_user("Reply in at most three sentences.")); Box::pin(async { Ok(()) }) }) .after_completion(|_agent, response| { if let Some(usage) = &response.usage { println!("used {} tokens", usage.total_tokens); } Box::pin(async { Ok(()) }) }) .build()?; ``` A change to `messages` or `tools_spec` applies to this one request. The reminder reaches the model but never enters the message history. Removing an entry from `tools_spec` hides a tool for one turn. In `after_completion`, changing `response.message` or `response.tool_calls` changes what the agent records and which tools it runs. `response.usage` is `None` when the provider reports no usage. ## Tool output rewrites ```rust const KEEP_LINES: usize = 200; let agent = Agent::builder() .llm(openai) .after_tool(|_agent, tool_call, result| { if let Ok(ToolOutput::Text(text)) = result { let lines: Vec<&str> = text.lines().collect(); if lines.len() > KEEP_LINES { let dropped = lines.len() - KEEP_LINES; let tail = lines[dropped..].join("\n"); *text = format!( "[{dropped} lines of {} output cut]\n{tail}", tool_call.name() ); } } Box::pin(async { Ok(()) }) }) .build()?; ``` Whatever you leave in the result is what the agent records in the message history and its tracing spans. The hook runs before the agent looks at errors. Replacing an `Err` with `Ok(ToolOutput::fail(..))` still tells the model what went wrong, but no longer counts toward `tool_retry_limit`. [Tool errors and retries](/agents/tools/#tool-errors-and-retries) explains the retry limit. ## Stop notifications ```rust let agent = Agent::builder() .llm(openai) .on_stop(|_agent, reason, error| { match error { Some(error) => eprintln!("agent failed: {error}"), None => println!("agent stopped: {reason:?}"), } Box::pin(async { Ok(()) }) }) .build()?; ``` `on_stop` runs once per stop and before the agent records its new state, so read the reason from the argument. [Stopping and structured results](/agents/stopping-and-structured-results/) lists every stop reason. `on_stop` is not a failure signal. The error is `Some` only when a completion step fails. Errors before the loop, such as a failed toolbox load, skip `on_stop`. A tool over `tool_retry_limit` fires `on_stop` with `StopReason::ToolCallsOverLimit` and no error, yet `query` returns `Err`. Check the result of `query` for failures. ## Hooks from methods ```rust /// Counts tool calls per tool, shared between the hook and the code that reads it. #[derive(Clone, Default)] pub struct ToolCallCounter { counts: Arc>>, } impl ToolCallCounter { /// `use<>` keeps `&self` out of the returned type, so the hook is `'static`. pub fn hook(&self) -> impl AfterToolFn + use<> { let counts = Arc::clone(&self.counts); move |_agent, tool_call, _result| { let mut counts = counts.lock().unwrap_or_else(PoisonError::into_inner); *counts.entry(tool_call.name().to_owned()).or_default() += 1; Box::pin(async { Ok(()) }) } } pub fn count(&self, tool_name: &str) -> usize { let counts = self.counts.lock().unwrap_or_else(PoisonError::into_inner); counts.get(tool_name).copied().unwrap_or_default() } } pub async fn count_tool_calls(openai: &OpenAI) -> anyhow::Result<()> { let counter = ToolCallCounter::default(); let mut agent = Agent::builder() .llm(openai) .after_tool(counter.hook()) .build()?; agent.query("List the files in this directory").await?; println!("stop was called {} times", counter.count("stop")); Ok(()) } ``` Builder methods only accept `'static` hooks. In Rust 2024, `fn hook(&self) -> impl AfterToolFn` captures the lifetime of `&self`. Clone the `Arc` into the closure and add `use<>` to the return type. ## Gotchas * The future may only borrow the agent. Touch the request, response, tool call or message before `Box::pin`. The `after_tool` result is the exception. * A hook that returns `Err` is logged with `tracing::error!` and the agent carries on. * `agent.stop(..)` lets the current step finish. A stop in `before_completion` still sends that request, and one in `before_tool` still runs the tool. In `before_all` and `on_start`, the agent fires `on_stop` but runs the loop anyway. * In `before_all`, `agent.tools()` does not yet hold Model Context Protocol (MCP) toolbox tools. It can fill template values through `system_prompt_mut`, as [System prompts](/agents/system-prompts/) shows. * `on_new_message` skips the system prompt and user queries, because the agent writes those to the context directly. * A resumed tool call that waited for approval runs again, so a counter like the one above counts it twice. Its tool hooks run before `on_start` while the agent is still stopped, so a stop from them is ignored. [Human in the loop](/agents/human-in-the-loop/) covers approvals. * A call to a tool the agent does not have is skipped, and no tool hook runs for it. ## Reference * [`hooks` module](https://docs.rs/swiftide/0.34/swiftide/agents/hooks/index.html) * [`Hook`](https://docs.rs/swiftide/0.34/swiftide/agents/hooks/enum.Hook.html) * [`AfterToolFn`](https://docs.rs/swiftide/0.34/swiftide/agents/hooks/trait.AfterToolFn.html) * [`AgentBuilder`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html) * [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html) * [`ChatCompletionRequest`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionRequest.html) * [`ChatCompletionResponse`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionResponse.html) * [`ChatMessage`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ChatMessage.html) * [`ToolCall`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCall.html) * [`ToolOutput`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ToolOutput.html) * [`ToolError`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/errors/enum.ToolError.html) * [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) * [Feature flags](/reference/feature-flags/) ## Next [System prompts](/agents/system-prompts/) # Human in the loop > Wrap a tool in ApprovalRequired so the agent stops with StopReason::FeedbackRequired, then approve or refuse the call and resume the agent with run() or query(). An agent can stop before a tool runs, wait for a person to approve or refuse the call, and then continue where it stopped. The agent types on this page live in `swiftide::agents`, behind the `swiftide-agents` feature flag. The example also uses the `openai` integration and the default `macros` feature flag: ```sh cargo add swiftide@0.34 --features openai,swiftide-agents ``` ## Approval wrapper ```rust // ApprovalRequired is a simple wrapper. You can also implement your own approval // flows by returning a `ToolOutput::FeedbackRequired` in a tool, // you can then use `has_received_feedback` and `received_feedback` on the context // to build your custom workflow. let guess_with_approval = ApprovalRequired::new(guess_a_number()); ``` The comment in the example gets one method name wrong: the method on the context that stores feedback is `feedback_received`. ```rust let mut agent = agents::Agent::builder() .llm(&openai) .tools(vec![guess_with_approval]) // Every message added by the agent will be printed to stdout .on_new_message(move |_, msg| { println!("{msg}"); Box::pin(async move { Ok(()) }) }) .limit(5) .build()?; ``` Register the wrapper in place of the bare tool. [`ApprovalRequired`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.ApprovalRequired.html) wraps any tool. It keeps the inner tool’s name and spec, so the model sees the same tool. When the model calls it, the wrapper asks the context for feedback on that tool call: * No feedback yet: it returns `ToolOutput::FeedbackRequired(None)`. The agent stops with `StopReason::FeedbackRequired { tool_call, payload }` and records no tool output, so the tool call stays open. * Approved: it runs the inner tool and returns its output. * Refused: it returns the text `This tool call was refused`, which the model reads as the tool output. `query` returns `Ok(())` when the agent stops this way. Read the stop reason to find the call that waits: ```rust // First query the agent, the agent will stop with a reason that feedback is required agent .query("Guess a number between 0 and 100 using the `guess_a_number` tool") .await?; // The agent stopped, lets get the tool call let Some(StopReason::FeedbackRequired { tool_call, .. }) = agent.stop_reason() else { panic!("expected a tool call to approve") }; // Alternatively, you can also get the stop reason from the agent state // agent.state().stop_reason().unwrap().feedback_required().unwrap() // Register that this tool call is ok. println!("Approving number guessing"); agent .context() .feedback_received(tool_call, &ToolFeedback::approved()) .await .unwrap(); // Run the agent again and it will pick up where it stopped. agent.run().await.unwrap(); ``` `feedback_received` on the context stores a [`ToolFeedback`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.ToolFeedback.html) for that tool call. `run` adds no user message. Before it asks the model for anything, it invokes the tool calls of the last assistant message again, provided no tool output follows that message. This time the wrapper finds the approval and runs the inner tool, and the loop carries on as usual. ## Approval loop The release notes showcase wraps its `write_changelog` tool as `OneEntryPerChange::new(ApprovalRequired::new(WriteChangelog::new(..)))` and loops until a maintainer approves a draft or gives up: examples/release-notes-agent/src/session.rs ```rust /// 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 { 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), } } } ``` The arguments of the waiting call are the proposal. The session parses them into the arguments struct that `#[derive(Tool)]` generates and hands them to a `Reviewer`, a small trait with a terminal implementation in `terminal.rs`. After an approval the write can still fail, so the session checks the history for a `ToolOutput::Text` answering that call before it counts the notes as written. ## Captured approval This is the approval excerpt of a real run on the Swiftide repository. The model was `claude-sonnet-5-5` behind an OpenAI-compatible gateway, and `y` was piped into stdin, which the reviewer echoes: ```text → 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 ``` The `→` lines come from a `before_tool` hook and the result lines from an `after_tool` hook. `⏸ waiting for approval` is the hook seeing `ToolOutput::FeedbackRequired`. The second `→ write_changelog` is the same tool call, invoked again by `run()` after the approval. ## Refusals and revisions A refusal is an answer, not an error. `ToolFeedback::refused()` makes the wrapper return its refusal text, and the agent records it as the tool output. `query(feedback)` queues the person’s message until that output is recorded, so the history reads: tool call, refusal, feedback. The model then drafts again and calls `write_changelog` with a new tool call, which starts a new approval round. Neither `approved()` nor `refused()` takes an argument. To attach a payload, chain `.with_payload(serde_json::Value)` after the constructor. `ApprovalRequired` ignores it; your own tools can read it. ## Draft checks `OneEntryPerChange` in the showcase’s `tools.rs` wraps the approval tool. It parses the draft and returns `ToolOutput::fail` when one pull request or commit appears in more than one entry; otherwise it calls the wrapped tool. The model reads the failure and fixes the draft, and the maintainer only sees drafts that pass. A `ToolOutput::fail` does not count toward the tool retry limit ([Tool errors and retries](/agents/tools/#tool-errors-and-retries)). Put checks outside the approval wrapper, so they run before anyone is asked. ## Custom feedback tools A tool can ask for feedback itself by returning `ToolOutput::feedback_required(Some(payload))`. The payload reaches the caller as the `payload` of `StopReason::FeedbackRequired`. The tool needs the `ToolCall` to look up feedback, so it implements [`Tool`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/trait.Tool.html) by hand; `#[swiftide::tool]` functions and `#[derive(Tool)]` structs only receive the context and their arguments. ```rust #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] struct RefundArgs { /// The order to refund. order_id: String, /// The amount to refund, in cents. amount_cents: u64, } #[derive(Clone)] struct IssueRefund; #[swiftide::reexports::async_trait::async_trait] impl Tool for IssueRefund { async fn invoke( &self, context: &dyn AgentContext, tool_call: &ToolCall, ) -> Result { let args = tool_call .args() .ok_or_else(|| ToolError::missing_arguments("order_id, amount_cents"))?; let refund: RefundArgs = serde_json::from_str(args)?; match context.has_received_feedback(tool_call).await { // No decision yet: stop the agent and show the reviewer what it asks for. None => Ok(ToolOutput::feedback_required(Some(json!({ "order_id": refund.order_id, "amount_cents": refund.amount_cents, })))), // The reviewer can lower the amount in the approval payload. Some(ToolFeedback::Approved { payload }) => { let amount_cents = payload .as_ref() .and_then(|payload| payload["amount_cents"].as_u64()) .unwrap_or(refund.amount_cents); send_refund(&refund.order_id, amount_cents).await?; Ok(ToolOutput::text(format!( "Refunded {amount_cents} cents for order {}", refund.order_id ))) } Some(ToolFeedback::Refused { payload }) => { let reason = payload .as_ref() .and_then(|payload| payload["reason"].as_str()) .unwrap_or("no reason given"); Ok(ToolOutput::text(format!( "A person refused the refund: {reason}" ))) } } } fn name(&self) -> Cow<'_, str> { Cow::Borrowed("issue_refund") } fn tool_spec(&self) -> ToolSpec { ToolSpec::builder() .name("issue_refund") .description("Refund (part of) an order. A person approves every refund.") .parameters_schema(schema_for!(RefundArgs)) .build() .expect("the refund arguments form a strict schema") } } ``` `DefaultContext` removes the feedback when `has_received_feedback` returns it, so each decision applies to one invocation. The caller can change what the tool does through the payload of the approval: ```rust if let Some(StopReason::FeedbackRequired { tool_call, payload }) = agent.stop_reason() { println!("approve {payload:?}?"); let tool_call = tool_call.clone(); let feedback = ToolFeedback::approved().with_payload(json!({ "amount_cents": 2_500 })); agent .context() .feedback_received(&tool_call, &feedback) .await?; agent.run().await?; } ``` ## Gotchas * Have the model call an approval tool on its own. `run` and `query` only resume open tool calls when no tool output follows the assistant message. If the same completion also called another tool, that tool’s output is recorded and the approved call never runs. The showcase prompt says “Call `write_changelog` with the Markdown, on its own.” * The stop reason names only the first tool call that asked for feedback in a completion. * Calling `run()` or `query()` before giving feedback invokes the wrapper again, which asks again, and the agent returns `AgentError::UnfulfilledToolCalls`. A message passed to that `query` stays queued, and the next successful `run()` or `query()` adds it to the history after the tool output. Retry with `run()` so the message does not appear twice. * Hooks see the resumed call twice ([Lifecycle hooks](/agents/hooks/#gotchas)). * Feedback lives in the context, not the message history. To approve in another process, see [Context and history](/agents/context-and-history/). ## Reference * [`ApprovalRequired`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.ApprovalRequired.html) * [`ToolFeedback`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.ToolFeedback.html) * [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html) * [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html) (`query`, `run`, `stop_reason`, `context`) * [`AgentContext`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html) (`feedback_received`, `has_received_feedback`) * [`DefaultContext`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html) (`with_tool_feedback`) * [`ToolOutput`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ToolOutput.html) * [`Tool`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/trait.Tool.html), [`ToolCall`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCall.html), [`ToolSpec`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolSpec.html) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) ## Next [Streaming](/agents/streaming/) # MCP tools > Start a Model Context Protocol (MCP) server as a child process, wrap it in an McpToolbox with a whitelist, and add its tools to a Swiftide agent. Here you give an agent a few tools from a Model Context Protocol (MCP) server that runs as a child process and hide the rest from the model. Swiftide speaks MCP through the [`rmcp`](https://docs.rs/rmcp/2.1) crate. Turn on the `mcp` feature flag, which also enables `swiftide-agents`, and add `rmcp` with a client and the transport you use: ```sh cargo add swiftide@0.34 --features openai,mcp cargo add rmcp@2.1 --features client,transport-child-process cargo add tokio --features macros,rt-multi-thread,process,time ``` ## Child process toolbox The release-notes-agent showcase reads pull requests through the GitHub MCP server when you pass `--mcp`: examples/release-notes-agent/src/mcp.rs ```rust /// 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 { 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 `:`; 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) } ``` `McpToolbox::try_from_transport` takes any `rmcp` client transport, such as a child process or a Transmission Control Protocol (TCP) stream. It performs the MCP handshake with a default client info named `swiftide` and returns the toolbox. The handshake waits for `npx` to download the package on a first run, so the showcase puts a one-minute timeout around it. Calling `available_tools` up front turns a missing tool into a startup error instead of a failure in the middle of a run. `src/agent.rs` adds the toolbox with `builder.add_toolbox(github_tools)`. Run it from the swiftide repository. You need `OPENAI_API_KEY`, Node.js, a token in `GITHUB_PERSONAL_ACCESS_TOKEN` or `GITHUB_TOKEN`, and an `origin` remote on GitHub. Without the remote, the showcase skips the GitHub tools: ```sh cargo run -p release-notes-agent -- --mcp ``` ## Running service toolbox When you need your own client info or capabilities, start the `rmcp` service yourself and hand it over: ```rust // First set up our client info to identify ourselves to the server let client_info = ClientInfo::new( ClientCapabilities::default(), Implementation::new("swiftide-example", env!("CARGO_PKG_VERSION")), ); // Use `rmcp` to start the server let running_service = client_info .serve(TokioChildProcess::new( tokio::process::Command::new("npx").configure(|cmd| { cmd.args(["-y", "@modelcontextprotocol/server-everything"]); }), )?) .await?; // Create a toolbox from the running server, and only use the `add` tool // // A toolbox reveals it's tools to the swiftide agent the first time it starts (if the state of // the agent was pending). You can add as many toolboxes as you want. MCP services are an // implmenentation of a toolbox. A list of tools is another. let everything_toolbox = McpToolbox::from_running_service(running_service) .with_whitelist(["add"]) .to_owned(); ``` `ClientInfo::serve` comes from `rmcp`’s `ServiceExt` and returns a `RunningService`. `McpToolbox::from_running_service` wraps it without contacting the server. Serve from a `ClientInfo`, not from `()`. The filter methods return `&mut Self`, so finish a chain on a temporary with `.to_owned()`. ```rust agents::Agent::builder() .llm(&openai) // Add the toolbox to the agent .add_toolbox(everything_toolbox) // Every message added by the agent will be printed to stdout .on_new_message(move |_, msg| { let msg = msg.to_string(); let tx = tx.clone(); Box::pin(async move { tx.send(msg).unwrap(); Ok(()) }) }) .build()? .query("Use the add tool to add 1 and 2") .await?; ``` `add_toolbox` accepts any owned `ToolBox + 'static`. You can add as many toolboxes as you like. A `Vec>` is a toolbox too. The agent lists a toolbox’s tools when it first starts, after the `before_all` hooks and the system prompt, not when you call `build`. If listing fails, `query` returns `AgentError::ToolBoxFailedToLoad`. After a successful listing the agent keeps the list, so tools the server adds later do not reach it. A failed listing is retried on the next `query`, and the `before_all` hooks run again. The example needs `OPENAI_API_KEY` and Node.js: ```sh cargo run -p swiftide-examples --example agents-mcp ``` It sends the prefixed name `:add` to OpenAI unchanged. If OpenAI rejects it, wrap the toolbox as described under [Tool names](/agents/mcp/#tool-names). ## Filters A toolbox holds one filter, and each call replaces it: * `with_whitelist(names)` keeps only the named tools. * `with_blacklist(names)` keeps every tool except the named ones. * `with_filter(ToolFilter::Whitelist(..))` or `with_filter(ToolFilter::Blacklist(..))` sets a filter built elsewhere, such as from configuration. `ToolFilter` implements `Serialize` and `Deserialize`. Names match the tool names the server reports, exactly and without the server prefix. A whitelisted name that the server does not offer is skipped without an error. A blacklist suits a server where most tools are safe to expose. This toolbox keeps the GitHub server’s read tools and hides three that write: ```rust // `github` is an `McpToolbox` from `try_from_transport` or `from_running_service`. github .with_name("github") .with_blacklist(["create_or_update_file", "push_files", "merge_pull_request"]); let agent = Agent::builder() .llm(openai) .add_toolbox(github) .build()?; ``` `with_name` and `with_blacklist` both return `&mut Self`, so they chain on a `let mut` binding, and the toolbox itself then moves into `add_toolbox`. A tool that a new version of the server adds passes a blacklist, but not a whitelist. ## Tool names Since 0.32, Swiftide registers each MCP tool as `:`. The server name comes from the handshake, and falls back to `mcp` when it is missing or empty. The model sees the prefixed name, and Swiftide calls the server with the original one. `with_name` only names the toolbox in logs and does not change the prefix. OpenAI accepts tool names made of letters, digits, `_` and `-`, so it rejects the `:`. Swiftide does not rewrite the names. The showcase wraps its toolbox in `OpenAiToolNames`, a `ToolBox` in `src/mcp.rs` that replaces every other character with `_`. The model then sees `github-mcp-server_get_pull_request`. Tool names are unique per agent. When a toolbox tool has the same name as a tool the agent already has, the agent keeps the existing one. ## Results Since 0.33, Swiftide reads the content blocks of `rmcp` 2 tool results: * Text blocks are joined with newlines into a text tool output. Other block kinds, such as images, are dropped. * Only a result with no content blocks at all uses its `structured_content`, serialized as JSON (JavaScript Object Notation). * A successful result with no text left becomes `Tool executed successfully`. That includes a result with only image blocks, even when it carries `structured_content`. * A result with `is_error` set becomes `ToolError::Unknown` with the server’s text, or `Unknown error` when there is none. The model reads the error as the tool output, and the failure counts toward the tool retry limit (`tool_retry_limit`). See [Tool errors and retries](/agents/tools/#tool-errors-and-retries). * Arguments that are not valid JSON return `ToolError::WrongArguments` before the server is called. ## Shutdown `McpToolbox::cancel` disconnects from the server and stops the `rmcp` service. A toolbox is `Clone`, and clones share one connection with the tools they listed. Bind a clone with `let mut` before you pass the toolbox to `add_toolbox`, and call `cancel().await` on it once the agent is done. After `cancel`, calls to the server’s tools return a tool error that says the server is not running, and listing the tools fails. A second `cancel` logs a warning and returns `Ok`. ## Gotchas * Swiftide supports MCP tools only. Prompts, resources and sampling from the server are not used. * Use `rmcp` 2. Another major version brings its own `RunningService` type, which `from_running_service` does not accept. * Every tool that passes the filter needs a description, and its input schema must parse as a JSON Schema that Swiftide accepts. Otherwise `query` returns `AgentError::ToolBoxFailedToLoad` for the whole toolbox, so filter out the tools you do not need. * A whitelist limits what the model can call, not what the server can do. Pick read-only tools when the model’s arguments are untrusted, as the showcase does. ## Reference * [`McpToolbox`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/mcp/struct.McpToolbox.html) * [`ToolFilter`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/mcp/enum.ToolFilter.html) * [`AgentBuilder::add_toolbox`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.add_toolbox) * [`ToolBox`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolBox.html) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) * [`ToolError`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/errors/enum.ToolError.html) * [`rmcp` transports](https://docs.rs/rmcp/2.1/rmcp/transport/index.html) ## Next [Providers and reasoning](/agents/providers-and-reasoning/) # How agents work > Build a Swiftide agent, run its completion and tool-call loop, and read why it stopped. After this page you can build an agent, run it, continue its conversation, and read why it stopped. The larger example is the [release-notes-agent showcase](/showcases/release-notes-agent/), which drafts release notes from git history. Agents live in `swiftide::agents`, behind the `swiftide-agents` feature flag: ```sh cargo add swiftide@0.34 --features openai,swiftide-agents cargo add tokio --features full cargo add anyhow ``` ## A minimal agent ```rust // Reads OPENAI_API_KEY from the environment. let openai = OpenAI::builder() .default_prompt_model("gpt-5-mini") .build()?; let mut agent = Agent::builder() .llm(&openai) .system_prompt("You answer questions about Rust crates in two sentences.") .limit(10) .build()?; // A failed run, including a tool that fails too often, returns `Err` here. agent.query("What does the tokio crate do?").await?; assert!(agent.is_stopped()); match agent.stop_reason() { Some(StopReason::RequestedByTool(..)) => println!("a tool stopped the agent"), Some(StopReason::NoNewMessages) => println!("no new messages, or the limit was reached"), other => println!("stopped: {other:?}"), } // A second query appends to the same message history and runs the loop again. agent.query("And how does it compare to async-std?").await?; println!("{} messages in the history", agent.history().await?.len()); ``` `OpenAI::builder()` reads `OPENAI_API_KEY` from the environment. `llm` takes any `ChatCompletion` that is `Clone` and clones it into the agent. `build` fails without it. `system_prompt` takes anything that converts into a `Prompt`. The agent has no system prompt unless you set one. `limit` caps the completions in one run. `query` adds a user message and loops until the agent stops. A stopped agent keeps its message history, so the second `query` continues the same conversation. `history` returns every message in the context. ## The agent loop ![Agent loop](/diagrams/diagram-agent-loop.svg) The agent sends the message history and the spec of every tool to the model. When the completion holds tool calls, the agent runs the tools and records their outputs in the history. Then it asks the model again. Hooks run around every step. The loop ends when a tool stops the agent, or when the step limit is reached. With `DefaultContext`, it also ends when the last message is an answer without a tool call. `DefaultContext::with_stop_on_assistant(false)` turns that off. A tool that returns `ToolOutput::FeedbackRequired` stops the agent and leaves its tool call open, without an output. A call to a tool the agent does not have is skipped with a warning and gets no output either. ## State and stop reasons `agent.state()` returns a `State`: `Pending` before the first run, `Running` during one, and `Stopped` with a `StopReason` after it. `agent.stop_reason()` tells you why it stopped: * a tool returned `ToolOutput::Stop` (`RequestedByTool`, which holds that tool call) * an answer without a tool call, the step limit, or the end of a `query_once` or `run_once` step (`NoNewMessages`) * a pending approval (`FeedbackRequired`) * a failure (`ToolCallsOverLimit`, `AgentFailed`, `Error`) For `ToolCallsOverLimit` and `Error`, `query` also returns the error, so the `?` in the snippet returns before the `match`. `StopReason` is marked non-exhaustive, so a `match` on it needs a wildcard arm. [Stopping and structured results](/agents/stopping-and-structured-results/) covers each reason. ## Runs `query_once` adds the message and runs one step. `run` and `run_once` do the same without a new message, which is how you resume after an approval. A run on an agent whose state is `Running` returns `AgentError::AlreadyRunning`. Cloning an agent shares its hooks and context, so both clones write to the same message history. Each clone gets its own copy of the tools, so `add_tool` on one does not change the other. The clone copies the state and starts with an empty tool retry counter. To run an agent as one step of a larger workflow, wrap it in a task node; see [Agents as task nodes](/tasks/agents-as-nodes/). ## A larger builder The release-notes-agent showcase builds its agent in one function: examples/release-notes-agent/src/agent.rs ```rust /// Builds the agent. All tools run through `executor`, whose working directory is the repo. pub fn build( llm: &(impl ChatCompletion + Clone + 'static), executor: Arc, task: &ReleaseTask, github_tools: Option, ) -> Result { 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()?) } ``` The showcase passes an `OpenAI` client pointed at any OpenAI-compatible endpoint, but the function accepts any provider. `context` sets where the message history lives and which tool executor runs commands. `DefaultContext::from_executor` keeps the history in memory and uses the executor that `run` creates: examples/release-notes-agent/src/main.rs ```rust 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 = Arc::new( LocalExecutor::builder() .workdir(&root) .default_timeout(Some(Duration::from_mins(1))) .build()?, ); ``` `Agent::builder()` starts with one tool, `stop`, which the model calls when it is done. The showcase replaces it with a `stop` tool whose arguments are the release notes, so it calls `without_default_stop_tool` before `tools`. The list mixes tools from `#[swiftide::tool]` functions, a tool wrapped in `ApprovalRequired`, and a `StopWithArgs` built from a JavaScript Object Notation (JSON) schema. `add_toolbox` adds the GitHub tools from a Model Context Protocol (MCP) server when you pass `--mcp` in a repository hosted on GitHub. The MCP toolbox needs the `mcp` feature flag, which the showcase enables. The two `progress` functions take `&mut AgentBuilder` and register hooks, which keeps the builder short. The [hooks page](/agents/hooks/) shows a captured run of this agent with `claude-sonnet-5-5`. ### Agent names `AgentBuilder::name` labels an agent, and `Agent::name()` reads the label back: ```rust let agent = Agent::builder() .llm(openai) .name("release-checker") .build()?; assert_eq!(agent.name(), "release-checker"); // Without `name`, every agent is called "unnamed_agent". let unnamed = Agent::builder().llm(openai).build()?; assert_eq!(unnamed.name(), "unnamed_agent"); ``` The name defaults to `unnamed_agent`. Swiftide only shows it in the agent’s `Debug` output, so use it in your own logs or to tell agents apart in a multi-agent setup. ## Agent and application responsibilities Besides running the loop, the agent: * renders the system prompt on the first run and appends it to the history, after any messages that `BeforeAll` hooks or an existing history put there * removes duplicate keys from the JSON arguments of a tool call * wraps queries, steps, tools and hooks in tracing spans ([Tracing](/observability/tracing/)) * lists toolbox tools when it first starts You provide the model, the tools, the system prompt, the tool executor, the hooks and, when you want a typed result, the schema of the stop tool. ## Gotchas * A hook that returns an error is logged with tracing. The agent carries on. * When a completion holds only calls to tools the agent does not have, no output is recorded. The next `query` returns `AgentError::UnfulfilledToolCalls`. * Some errors return from `query` without setting a stop reason, for example a message history error or a tool task that fails to join. The agent then stays `Running`, and the next run returns `AgentError::AlreadyRunning`. * A clone of a `Pending` agent renders the system prompt again on its first run and appends it to the shared history. ## 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) * [`State`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.State.html) * [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) * [`StopWithArgs`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.StopWithArgs.html) * [`ApprovalRequired`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.ApprovalRequired.html) * [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html) * [`ChatCompletion`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ChatCompletion.html) * [`OpenAI`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/type.OpenAI.html) ## Next [Tools](/agents/tools/) # Providers and reasoning > Run one agent definition on OpenAI, Azure, Anthropic, Ollama, AWS Bedrock or any OpenAI-compatible gateway, keep reasoning items across turns, and know how each provider treats tool schemas. You end up with one agent definition that runs on whichever provider you pick at startup, with the model’s reasoning carried from one turn to the next. ```rust /// Builds the same agent for any chat completion client. fn changelog_agent(llm: &(impl ChatCompletion + Clone + 'static)) -> anyhow::Result { let agent = Agent::builder() .llm(llm) .system_prompt("You summarize git history for release notes.") .limit(20) .build()?; Ok(agent) } ``` ```rust let mut agent = match std::env::var("LLM_PROVIDER").as_deref() { Ok("anthropic") => changelog_agent( &Anthropic::builder() .default_prompt_model("claude-sonnet-4-5") .build()?, )?, Ok("ollama") => changelog_agent(&Ollama::builder().default_prompt_model("qwen3").build()?)?, Ok("azure") => { let config = AzureConfig::new() .with_api_base("https://my-resource.openai.azure.com") .with_api_version("2024-10-21") .with_deployment_id("gpt-4o-mini") .with_api_key(std::env::var("AZURE_OPENAI_API_KEY")?); let azure: GenericOpenAI = GenericOpenAIBuilder::default() .client(async_openai::Client::with_config(config)) .default_prompt_model("gpt-4o-mini") .build()?; changelog_agent(&azure)? } _ => changelog_agent( &OpenAI::builder() .default_prompt_model("gpt-5-mini") .build()?, )?, }; agent .query("Summarize the commits since the last tag.") .await?; ``` `AgentBuilder::llm` takes a reference to any client that implements `ChatCompletion + Clone + 'static`. It stores a boxed clone, so `Agent` has no type parameter for the provider. The tools, hooks and system prompt stay the same whichever client you pass. The Azure client needs the `async-openai` crate as a direct dependency, because Swiftide does not re-export its client type. `api_base` and `api_key` on the builder only configure the `OpenAI` client, so Azure passes a full client to `client(..)`: ```sh cargo add async-openai@0.41 --no-default-features ``` Use the same version Swiftide depends on, or the client types do not match. ## Chat providers Each chat client sits behind its own feature flag. The snippet above needs `swiftide-agents` and three provider flags: ```sh cargo add swiftide@0.34 --features swiftide-agents,openai,anthropic,ollama ``` Every chat client implements `ChatCompletion`. [Providers](/models/overview/#providers) lists each client with its feature flag, and [OpenAI-compatible endpoints](/models/overview/#openai-compatible-endpoints) covers gateways and base URLs. The ask-your-codebase showcase builds its client from a base URL, a key and a flag for the Responses API, so the same binary runs against OpenAI or a gateway. It sets them with `api_base` and `api_key`: examples/ask-your-codebase/src/llm.rs ```rust impl LlmEndpoint { /// Builds a client for this endpoint. /// /// Rate limits and transient errors are retried with exponential backoff, which matters when /// indexing fires many prompts concurrently. After `retry_budget` the error is returned, so /// an unreachable or broken endpoint fails instead of hanging. pub fn client(&self, retry_budget: Duration) -> Result> { let openai = OpenAI::builder() .api_base(&self.base_url) .api_key(&self.api_key) .default_prompt_model(&self.model) .use_responses_api(matches!(self.api, OpenAiApi::Responses)) .build()?; let backoff = BackoffConfiguration { max_elapsed_time_sec: retry_budget.as_secs(), ..BackoffConfiguration::default() }; Ok(Arc::new(LanguageModelWithBackOff::new(openai, backoff))) } } ``` ## Bedrock agents `AwsBedrock` talks to the Bedrock Converse API. It reads AWS credentials and the region from your usual Amazon Web Services (AWS) configuration, such as a command-line interface (CLI) profile or environment variables: ```rust let bedrock = AwsBedrock::builder() .default_prompt_model("global.anthropic.claude-sonnet-4-6") .build()?; ``` The model ID here is a global inference profile. The agent itself looks like any other: ```rust let mut agent = agents::Agent::builder() .llm(&bedrock) .tools(vec![current_utc_time(), format_timestamp()]) .on_new_message(|_, msg| { let rendered = msg.to_string(); Box::pin(async move { println!("{rendered}"); Ok(()) }) }) .limit(6) .build()?; ``` Setup and credentials are on [AWS Bedrock](/integrations/aws-bedrock/). ## Reasoning items On `OpenAI`, reasoning models return their reasoning only through the Responses API. `AwsBedrock` returns signed Claude reasoning as `ReasoningItem` values without extra setup. Reasoning on `OpenAI` needs the Responses API and a reasoning effort; [Responses API and reasoning](/models/overview/#responses-api-and-reasoning) covers the client options. ```rust // Reasoning models require the Responses API. Enabling reasoning effort also asks for a // summary and encrypted reasoning content (enabled by default). If your OpenAI org is not // verified for reasoning access, summaries may be absent. Disable with // `reasoning_features(false)` if desired. let openai = OpenAI::builder() .default_prompt_model("o3-mini") .default_options(Options::builder().reasoning_effort(ReasoningEffort::Low)) .use_responses_api(true) .build()?; ``` Each `ReasoningItem` has a `summary` list and an optional `encrypted_content`. This helper joins the summaries: ```rust fn reasoning_summary(reasoning: Option<&[ReasoningItem]>) -> Option { let summary = reasoning .unwrap_or(&[]) .iter() .flat_map(|item| item.summary.iter()) .cloned() .collect::>() .join("\n"); if summary.is_empty() { None } else { Some(summary) } } ``` The agent reads them in an `after_completion` hook: ```rust let mut agent = Agent::builder() .llm(&openai) .on_new_message(|_, message| { if let ChatMessage::Assistant(content, _) = message && let Some(content) = content.as_deref() { println!("Assistant: {content}"); } Box::pin(async move { Ok(()) }) }) .after_completion(|_, response| { if let Some(summary) = reasoning_summary(response.reasoning.as_deref()) { println!("Reasoning summary:\n{summary}"); } let has_encrypted = response .reasoning .as_ref() .is_some_and(|items| items.iter().any(|item| item.encrypted_content.is_some())); println!("Encrypted reasoning content present: {has_encrypted}"); Box::pin(async move { Ok(()) }) }) .build()?; ``` After the `after_completion` hooks run, the agent adds every item in `response.reasoning` to the message history as a `ChatMessage::Reasoning`. It adds them before the assistant message, through `Agent::add_message`, so `on_new_message` hooks see them too. A hook that empties `response.reasoning` keeps the items out of the history. ## Reasoning replay What happens to those items on the next request depends on the client: * The Responses API client replays them only when `reasoning_effort` is set on that client. It then sends back every item with a non-empty `encrypted_content` and sets `store` to false, so the reasoning lives in your message history and survives a saved and restored history (see [Context and history](/agents/context-and-history/)). Without `reasoning_effort`, it drops every `ChatMessage::Reasoning`. * `AwsBedrock` replays an item only when it has non-empty `content` and a non-empty `encrypted_content`, which it sends as the signature. It drops anything else. * The Chat Completions client and `Anthropic` leave `ChatMessage::Reasoning` out of their requests. ## Bedrock reasoning effort `AwsBedrock` has its own `Options` and its own `ReasoningEffort` enum in `swiftide::integrations::aws_bedrock_v2`. Set `reasoning_effort` in those options and pass them to `default_options` on the builder. For the Opus 4.5 beta header and the other fields, see [AWS Bedrock settings](/models/overview/#aws-bedrock-settings). ## Tool schema policies `ToolSpec::builder().build()` checks every schema against Swiftide’s strict contract (see [Tools](/agents/tools/)) and rewrites what it can: a nullable type such as `["string", "null"]` becomes an `anyOf` with a null branch, a two-entry `oneOf` with a null branch becomes an `anyOf`, annotations next to a `$ref` are dropped, and every object gets `additionalProperties: false`. Each provider then applies its own policy when it builds a request. Only `AwsBedrock` lets you change it. * `OpenAI` and the other `GenericOpenAI` clients remove `$schema` and Rust integer formats such as `int32`, and list every property in `required`. They reject any remaining `oneOf` or array-valued `type`, and send the schema with `strict: true`. * `Anthropic` and `AwsBedrock` send the strict schema unchanged. `AwsBedrock` marks tools strict unless you set `tool_strict` to `false` in its `Options`. ## Gotchas * The OpenAI schema policy runs when the request is built, not when the tool is built. A schema it rejects fails the first completion with `AgentError::CompletionsFailed`, wrapping a permanent error that says `tool schema must be OpenAI compatible`. * Gateways do not always speak the Responses API. Against the gateway used for the showcase runs, the Responses API fails because the gateway streams events with `"output": null`, which the client cannot parse. Use it with OpenAI itself. * OpenAI rejects the `:` in Model Context Protocol (MCP) tool names; see [Tool names](/agents/mcp/#tool-names). * Reasoning items are tied to the provider that made them. Bedrock items carry a Bedrock signature in `encrypted_content`, and an OpenAI Responses client with `reasoning_effort` set sends that to OpenAI as encrypted reasoning. OpenAI items usually have no `content`, so `AwsBedrock` drops them silently. Remove `ChatMessage::Reasoning` entries from the message history before you switch providers. * `AwsBedrock::builder().build()` loads the AWS configuration with `block_in_place`, so it needs a multi-threaded Tokio runtime. A default `#[tokio::test]` or `flavor = "current_thread"` panics. Pass your own client with `.client(..)` otherwise. * A client’s `on_usage` callback counts every call on that client, including other agents that share it; see [Usage callbacks](/models/errors-backoff-and-usage/#usage-callbacks). ## Reference * [`AgentBuilder::llm`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.llm) * [`AgentBuilder::after_completion`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.after_completion) * [`AgentBuilder::on_new_message`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.on_new_message) * [`Agent::add_message`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html#method.add_message) * [`ChatCompletion`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ChatCompletion.html) * [`GenericOpenAI`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/struct.GenericOpenAI.html) * [`GenericOpenAIBuilder::use_responses_api`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/struct.GenericOpenAIBuilder.html#method.use_responses_api) * [`Options` (OpenAI)](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/struct.Options.html) * [`AzureConfig`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/struct.AzureConfig.html) * [`Anthropic`](https://docs.rs/swiftide/0.34/swiftide/integrations/anthropic/struct.Anthropic.html) * [`Ollama`](https://docs.rs/swiftide/0.34/swiftide/integrations/ollama/struct.Ollama.html) * [`AwsBedrock`](https://docs.rs/swiftide/0.34/swiftide/integrations/aws_bedrock_v2/struct.AwsBedrock.html) * [`Options` (AWS Bedrock)](https://docs.rs/swiftide/0.34/swiftide/integrations/aws_bedrock_v2/struct.Options.html) * [`ReasoningEffort` (AWS Bedrock)](https://docs.rs/swiftide/0.34/swiftide/integrations/aws_bedrock_v2/enum.ReasoningEffort.html) * [`ChatCompletionResponse`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionResponse.html) * [`ChatMessage`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ChatMessage.html) * [`ReasoningItem`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ReasoningItem.html) * [`ToolSpec`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolSpec.html) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) ## Next [Contrib tools and hooks](/agents/contrib/) # Stopping and structured results > Replace the stop tool with StopWithArgs and a JSON schema to get a typed result, report failures with AgentCanFail, cap runs with limit and tool_retry_limit, and handle every StopReason. Your agent ends its run by handing your code a typed value, such as `ReleaseNotes`, or a typed report of why it failed. Your code can also tell why any other run ended. `Agent::builder()` starts with one tool, named `stop`, which takes no arguments and ends the run with `StopReason::RequestedByTool(tool_call, None)`. With the default context, an answer without a tool call also ends the run, with `StopReason::NoNewMessages`. ## Structured results The examples need the `swiftide-agents` feature flag and your own `schemars` 1.x, `serde` and `serde_json`: ```sh cargo add swiftide@0.34 --features openai,swiftide-agents cargo add schemars@1 serde serde_json --features serde/derive ``` The [release-notes-agent showcase](/showcases/release-notes-agent/) returns its notes as data. It describes them as a type and turns the type’s JSON (JavaScript Object Notation) Schema into a stop tool: examples/release-notes-agent/src/notes.rs ```rust /// 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, /// Changes that require users to update their code. Empty if there are none. pub breaking_changes: Vec, /// Bug fixes users may notice. pub fixes: Vec, } #[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, } 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)) } } ``` `StopWithArgs::with_parameters_schema` builds a tool that is also named `stop`. The model calls it with arguments that follow the schema. The doc comments on the fields end up in the schema, so the model reads them as instructions. `deny_unknown_fields` gives both objects `additionalProperties: false`. With a custom schema, the tool passes the whole argument object on as the payload. The agent stops with `StopReason::RequestedByTool(tool_call, Some(payload))`, where `payload` is a `serde_json::Value`. Match that arm and turn the payload into your type with `ReleaseNotes::deserialize(payload)` (a `&serde_json::Value` is a deserializer) or `serde_json::from_value(payload.clone())`. [Human in the loop](/agents/human-in-the-loop/) shows the whole loop. `StopWithArgs::default()` is the other form. Its schema has a single `output` string, and the payload is that string. `tools` keeps the default `stop`, so call `without_default_stop_tool` before `tools`, or add yours with `add_tool`. In this example, `stop_tool` is `StopWithArgs::with_parameters_schema(stop_schema())`: ```rust let mut builder = Agent::builder(); builder .llm(&openai) .system_prompt( "You are a workflow finisher. Summarize completed work and recommend next actions. \ When done, call the `stop` tool with the provided JSON schema. Always include \ `details`, using null when there is nothing to add. Never fabricate task status \ values outside the schema.", ) .without_default_stop_tool() .tools([stop_tool.clone()]) .on_stop(|_, reason, _| { Box::pin(async move { if let StopReason::RequestedByTool(_, payload) = reason && let Some(payload) = payload { println!( "agent stopped with structured payload:\n{}", to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()), ); } Ok(()) }) }); ``` The `on_stop` hook receives the same `StopReason`, so you can read the payload there instead of after `query` returns. The showcase’s output with `claude-sonnet-5-5`, through an OpenAI-compatible gateway, ends with the stop call and the deserialized notes: ```text → 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] ``` ## Failure reports `AgentCanFail` is a second control tool, named `task_failed`. The agent does not add it unless you pass it to `tools`. `AgentCanFail::default()` takes a `reason` string, and the agent stops with `StopReason::AgentFailed(Some(reason))`. `query` still returns `Ok`, so you check the stop reason. `AgentCanFail::with_parameters_schema` accepts your own schema: ```rust /// What the agent reports when it cannot finish. #[derive(Debug, Deserialize, JsonSchema)] #[schemars(deny_unknown_fields)] struct FailureReport { /// What went wrong, in one sentence. summary: String, /// What a person has to fix before the task can be retried. blocked_by: Vec, } let mut agent = Agent::builder() .llm(&openai) .tools([AgentCanFail::with_parameters_schema(schema_for!( FailureReport ))]) .build()?; agent.query("Restore last night's database backup.").await?; // With a custom schema the arguments arrive as one JSON-encoded string. if let Some(StopReason::AgentFailed(Some(Value::String(json)))) = agent.stop_reason() { let report: FailureReport = serde_json::from_str(json)?; println!("failed: {}", report.summary); for blocker in &report.blocked_by { println!(" blocked by: {blocker}"); } } ``` With a custom schema the tool serializes the arguments back to text. The payload is a `Value::String` that holds JSON, so you parse it a second time with `serde_json::from_str`. ## Stop reasons This run caps itself at 20 completions, lets one tool call fail twice, adds `task_failed`, and stops itself from a hook when the message history grows too long: ```rust let mut agent = Agent::builder() .llm(&openai) .system_prompt("Check that the release branch builds. Call `stop` when you are done.") .tools([AgentCanFail::default()]) .limit(20) .tool_retry_limit(2) .after_each(|agent| { Box::pin(async move { if agent.history().await?.len() > 200 { agent.stop("the history grew past 200 messages").await; } Ok(()) }) }) .build()?; let result = agent.query("Is the release branch green?").await; match agent.stop_reason() { Some(StopReason::RequestedByTool(_call, payload)) => println!("done: {payload:?}"), Some(StopReason::AgentFailed(reason)) => println!("the model gave up: {reason:?}"), Some(StopReason::ToolCallsOverLimit(call)) => println!("`{}` kept failing", call.name()), Some(StopReason::FeedbackRequired { tool_call, .. }) => { println!("`{}` waits for approval", tool_call.name()); } Some(StopReason::Error) => println!("the run failed"), Some(StopReason::NoNewMessages) => println!("turn ended without a tool call, or limit hit"), Some(StopReason::Other(reason)) => println!("stopped by our hook: {reason}"), Some(other) => println!("stopped: {other:?}"), None => println!("the agent did not stop; see the returned error"), } // `ToolCallsOverLimit` and `Error` also come back as an `Err`. result?; ``` `limit` caps the number of completions in one run. The counter starts at zero with every `query` or `run`. When it is reached, the agent logs a warning and stops with `StopReason::NoNewMessages`, and `query` returns `Ok`. No limit is set by default. `tool_retry_limit` caps how often the same tool call may fail before the agent stops with `StopReason::ToolCallsOverLimit` and `query` returns `AgentError::ToolError`. [Tools](/agents/tools/) explains how failures are counted. Each variant means something different for your code: * `RequestedByTool(tool_call, payload)`: a tool returned `ToolOutput::Stop`, usually a `stop` tool. `payload` is `None` for the default tool, the `output` value for `StopWithArgs::default()`, and the argument object for a custom schema. Treat it as success. * `AgentFailed(payload)`: a tool returned `ToolOutput::AgentFailed`, usually `task_failed`. Show the reason to a person, or retry with more context. * `ToolCallsOverLimit(tool_call)`: one call failed too often. `query` also returns the error. Fix the tool or its description, or raise `tool_retry_limit`. * `FeedbackRequired { tool_call, payload }`: a tool waits for approval. Record a `ToolFeedback` on the context and call `run`, as [Human in the loop](/agents/human-in-the-loop/) shows. * `Error`: a step failed, for example a completion request, and `query` returns the `AgentError`. * `NoNewMessages`: the model answered without a tool call, sent an empty reply, or the run reached `limit`. `query_once` and `run_once` also end with it after one step, unless a tool stopped the agent first. A new `query` continues the same message history. * `Other(text)`: your code stopped the agent. `agent.stop("text")` accepts any string. Hooks that receive `&mut Agent`, such as `after_each` and `after_tool`, can call it. `StopReason` is marked `#[non_exhaustive]`, so a `match` needs a wildcard arm. `agent.stop_reason()` returns `None` while the agent is `Pending` or `Running`. That includes a run that returned an error without recording a stop. Each variant also has an `is_*` method, and most have an `as_*` accessor such as `as_requested_by_tool`. ### Tool outputs `ToolOutput` has the same kind of accessors. `as_text` and `as_fail` return the text of a `Text` or `Fail` output; `as_stop` and `as_agent_failed` return the payload of a `Stop` or `AgentFailed` output. This `after_tool` hook reads them, and turns a failed `deploy` call into a failed run with `ToolOutput::agent_failed`: ```rust let agent = Agent::builder() .llm(openai) .after_tool(|_agent, tool_call, result| { if let Ok(output) = result { if let Some(text) = output.as_text() { println!("`{}` returned {} bytes", tool_call.name(), text.len()); } else if let Some(error) = output.as_fail().map(str::to_owned) { // A failed deploy ends the run instead of going back to the model. if tool_call.name() == "deploy" { *output = ToolOutput::agent_failed(json!({ "deploy": error })); } } else if let Some(payload) = output.as_stop() { println!("stopping with {payload}"); } else if let Some(reason) = output.as_agent_failed() { println!("giving up: {reason}"); } } Box::pin(async { Ok(()) }) }) .build()?; ``` The agent checks the output after the `after_tool` hooks run, so the rewritten output stops it with `StopReason::AgentFailed(Some(payload))`. `as_stop` and `as_agent_failed` return `None` when the output carries no payload, as with the default `stop` tool; match on `ToolOutput::Stop` or `ToolOutput::AgentFailed` to test the variant itself. ## Gotchas * To tell the cases of `NoNewMessages` apart, check whether the last assistant message has tool calls, or turn off the text-answer stop with `with_stop_on_assistant(false)` (see [Context and history](/agents/context-and-history/)). * `without_default_stop_tool` takes effect when you call `tools`, so call it first. * A `StopWithArgs` or `AgentCanFail` call whose arguments are not valid JSON is a tool error. It goes back to the model and counts toward `tool_retry_limit`. The default `Stop` ignores its arguments. * `to_string_pretty` on an `AgentCanFail` payload prints a quoted, escaped string; parse it with `serde_json::from_str` first. * Once the agent has stopped, `Agent::stop` does nothing. The first stop reason stays, and `on_stop` fires once. Tools the model called in the same completion as `stop` still run. * The payload is whatever the model sent. Handle a failed deserialization, as the showcase does. ## 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) * [`State`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.State.html) * [`Stop`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.Stop.html) * [`StopWithArgs`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.StopWithArgs.html) * [`AgentCanFail`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.AgentCanFail.html) * [`ToolCall`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCall.html) * [`ToolOutput`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ToolOutput.html) * [`ToolFeedback`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.ToolFeedback.html) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) ## Next [Human in the loop](/agents/human-in-the-loop/) # Streaming > Turn on streaming with on_stream, read the text and the tool call arguments in each stream chunk, and switch OpenAI-compatible clients to deltas only. You end up with an agent that prints the model’s text, and the arguments of its tool calls, while the model is still writing them. Streaming changes only how a completion arrives. The agent needs the `swiftide-agents` feature flag, and each model client needs its own: `openai`, `anthropic` or `aws-bedrock`. None of them is on by default. ```sh cargo add swiftide@0.34 --features swiftide-agents,openai ``` ## Streaming switch ```rust agents::Agent::builder() .llm(&openai) .on_stream(|_agent, response| { // We print the message chunk if it exists. Streamed responses also include // the full response (without tool calls) in `message` and an `id` to map them to // previous chunks for convenience. // // The agent uses the full assembled response at the end of the stream. if let Some(delta) = &response.delta { print!( "{}", delta .message_chunk .as_deref() .map(str::to_string) .unwrap_or_default() ); }; // If `stream_full` is disabled, response.message() will be the accumulated response // response.message() Box::pin(async move { Ok(()) }) }) // Every message added by the agent will be printed to stdout .on_new_message(move |_, msg| { let msg = msg.to_string(); Box::pin(async move { println!("\n---\nFinal message:\n {msg}"); Ok(()) }) }) .limit(5) .build()? .query("Why is the rust programming language so good?") .await?; ``` `openai` is an `OpenAI` client built with `stream_full(false)`; [Deltas only](#deltas-only) shows it. Registering `on_stream` turns streaming on. The agent then calls `complete_stream` on the model instead of `complete`, and runs the hook once for every stream chunk. Without a hook, `streaming(true)` on the builder streams as well. The hook receives `&Agent` and the chunk, a `ChatCompletionResponse`. The example prints `delta.message_chunk`, the text that arrived with this chunk. It never flushes stdout, so the text can arrive in bursts. The `on_new_message` hook prints every message the agent adds to the message history. For the assistant, that is the assembled message after the stream has ended. ## Stream chunks Each stream chunk is a whole `ChatCompletionResponse`. With the default settings it carries: * `id`, the same for every chunk of one completion. * `message`, the text accumulated so far. * `tool_calls`, `usage` and `reasoning`, the tool calls rebuilt, the token counts and the reasoning items so far. * `delta.message_chunk`, the text that arrived with this chunk. * `delta.tool_calls_chunk`, a map from a per-client index to a `ToolCallAccum` with `id`, `name` and `arguments`. `arguments` holds everything received so far, not only the newest fragment. The map stays on every later chunk of the completion, so remember what you already showed. The key differs by client. OpenAI chat completions use the tool call’s index, the Responses API the output item index. Anthropic and Bedrock use the content block index, which also counts text blocks. Match calls on `id` or `name`, not on the key. When the stream ends, the agent keeps the last chunk and uses it as the completion. It records that chunk’s message in the message history and runs its tool calls. A stream that yields nothing fails with `AgentError::EmptyStream`. A client without its own `complete_stream` sends one chunk with the whole response and no `delta`. ## Tool argument previews The release-notes-agent showcase streams its draft. The release notes are the `markdown` argument of the `write_changelog` tool, so they arrive as tool call arguments, not as text: examples/release-notes-agent/src/progress.rs ```rust /// 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(()) }) }); } ``` The hook writes `message_chunk` to stdout. It then walks the entries of `tool_calls_chunk`, keeps the `write_changelog` call, and hands its id and accumulated arguments to `DraftPreview`. It flushes stdout after every chunk. [`DraftPreview`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/release-notes-agent/src/draft.rs) lives in `draft.rs`. Every chunk repeats the whole argument text, so the preview tracks how much it has shown. It decodes the complete part of the `markdown` value in the growing JSON (JavaScript Object Notation) text, stopping before an unfinished escape sequence. It returns only the text it has not returned before. A new call id starts a fresh preview. The hook is a `Fn` closure, so it cannot change what it captures, and it must be `Clone`. State that changes from chunk to chunk goes behind an `Arc>`, as `preview` does here. ## Captured stream These lines come from the showcase’s `OUTPUT.md`, a real run with `claude-sonnet-5-5` through an OpenAI-compatible gateway. `OUTPUT.md` cuts the draft after its first lines with `...`: ```text git_diff ✓ 17 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 ``` The draft appears before the `→ write_changelog` line. `on_stream` runs while the completion arrives, and the `before_tool` hook runs only after it is complete. After the approval, the agent runs the pending tool call without a new completion, so the draft streams once. ## Deltas only By default, the OpenAI-compatible clients clone the accumulated response, which grows with the answer, into every chunk. Turn that off with `stream_full(false)`: ```rust let openai = swiftide::integrations::openai::OpenAI::builder() .default_embed_model("text-embeddings-3-small") .default_prompt_model("gpt-4o-mini") // Only streams the delta, leave this out to stream the full response .stream_full(false) .build()?; ``` The last chunk is still the complete response, so the agent behaves the same. `delta.tool_calls_chunk` carries the same accumulated arguments with either setting, so the showcase’s preview works with the default too. [Deltas only](/models/overview/#deltas-only) lists the clients that have the setting and the fields each chunk drops. ## Gotchas * Before 0.34, the `GenericOpenAI` chat completions stream (OpenAI, Ollama, Groq, Gemini, OpenRouter and Dashscope) repeated the previous `message_chunk` on tool call fragments, the stop and usage frames, and the final chunk, so a hook that printed it printed text more than once. [#1216](https://github.com/bosun-ai/swiftide/pull/1216) fixed this. * The fix covers only the chat completions stream. The OpenAI Responses API (`use_responses_api(true)`, which the showcase’s `--responses-api` flag turns on), `Anthropic` and `AwsBedrock` still keep the previous `message_chunk` on tool call events. With them, both examples print the last text again for every tool call event. Their chunks carry the accumulated `message`, so printing the part of `message` beyond what you already printed avoids repeats. * The agent awaits the hook for every chunk before it reads the next one. A slow hook slows the stream. Hand slow work to a channel or a spawned task. * An error returned from `on_stream` is logged with `tracing` and does not stop the agent. * `on_stream` receives `&Agent`, so it cannot stop the agent or change its tools. Do that from `after_completion`. * Rust’s stdout is line buffered. `print!` without a newline may show nothing until a line ends or the buffer fills, so flush after each chunk. The first example does not flush; the showcase does. ## Reference * [`AgentBuilder::on_stream`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.on_stream) * [`OnStreamFn`](https://docs.rs/swiftide/0.34/swiftide/agents/hooks/trait.OnStreamFn.html) * [`ChatCompletionResponse`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionResponse.html) * [`ChatCompletionResponseDelta`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionResponseDelta.html) * [`ToolCallAccum`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCallAccum.html) * [`ChatCompletion`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ChatCompletion.html) * [`GenericOpenAIBuilder::stream_full`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/struct.GenericOpenAIBuilder.html#method.stream_full) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) * [Feature flags](/reference/feature-flags/) ## Next [MCP tools](/agents/mcp/) # System prompts > Give an agent a system prompt as a Prompt, fill its Tera template from runtime values, change it before the first run, or leave it out. Here you build an agent whose system prompt is a Tera template, filled with values your program only knows at run time. The example is the system prompt of the [release notes agent](/showcases/release-notes-agent/). The code on this page needs the `swiftide-agents` feature flag, and the `openai` feature flag for the model: ```sh cargo add swiftide@0.34 --features openai,swiftide-agents ``` ## Prompt as system message examples/release-notes-agent/src/agent.rs ```rust /// 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 } ``` The system prompt of an agent is a `Prompt`. `AgentBuilder::system_prompt` takes anything that converts into one, so a `&'static str`, a `String` or a finished `Prompt` all work. The showcase passes the result of this method to `system_prompt` in its `build` function. `SYSTEM_PROMPT` is [`src/system_prompt.md`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/release-notes-agent/src/system_prompt.md), included at compile time with `include_str!`. It is a Tera template, the Jinja-style template language Swiftide renders prompts with. Its last lines show how it handles optional values: ```text {% if github %} - Reference pull requests as Markdown links, exactly like `[#1207](https://github.com/{{ github }}/pull/1207)`. Reference a commit by its short hash only when it has no pull request.{% else %} - Reference pull requests as `#1207`, or commits by their short hash.{% endif %} {% if next_version %}- The version is `{{ next_version }}`.{% else %}- Choose the version yourself: before 1.0, bump the minor version of `{{ since }}` for new features or breaking changes, and the patch version for fixes only.{% endif %} ``` The template uses these constructs: * `{{ since }}` prints the tag the release starts from. Every run has one, so the method always sets it. * `{% if github_tools and github %}` adds the instructions for the GitHub tools only when the agent has them and the repository is on GitHub. * `{% if github %} ... {% else %} ... {% endif %}` picks the style of pull request references, and `{{ github }}` prints the repository as `owner/name`. * `{% if next_version %}` switches between “use this version” and “choose a version”, and `{{ next_version }}` prints it. * `{{ next_version | default(value="") }}` prints the version in the heading, or a placeholder when the user gave none. The optional values stay undefined when they are absent. Tera treats an undefined value in an `if` as false, and the `default` filter replaces it. That is why the method inserts `next_version` and `github` only when it has them. ## Template values `with_context_value` takes the prompt by value and returns it, so it chains onto `Prompt::from`. `insert_context_value` takes `&mut self`, which suits values you add conditionally, as the showcase does. Both accept anything that converts into a `tera::Value`: strings, numbers, booleans, and `serde_json::Value`. `with_context` merges a whole `tera::Context` at once. [Prompts and templates](/models/prompts-and-templates/#template-syntax) covers the template syntax, loops and the filters that are available. It also lists the `&str` and `format!` pitfalls. ## Values from a before_all hook Some values are only known when the agent starts. A `before_all` hook can read them and fill them in: ```rust let mut agent = Agent::builder() .llm(openai) .system_prompt("You review the changes on the branch `{{ branch }}`.") .before_all(|agent: &mut Agent| { Box::pin(async move { let branch = agent .context() .executor() .exec_cmd(&Command::shell("git branch --show-current")) .await? .into_string_lossy(); if let Some(prompt) = agent.system_prompt_mut() { prompt.insert_context_value("branch", branch.trim()); } Ok(()) }) }) .build()?; ``` The hook asks the tool executor for the current git branch and inserts it as `branch`. With the default local executor, the command runs in the working directory of your program. With a Docker executor, it runs in the container. On the first run, the `before_all` hooks run, then the agent renders the prompt and stores it in the context as a `ChatMessage::System`. A `before_all` hook is therefore the last place where a change still counts. `Agent::system_prompt_mut` returns `None` when the agent has no system prompt, so the hook only touches a prompt that exists. Before you call `build`, `AgentBuilder::system_prompt_mut` gives the same access on the builder. It also returns `None` when no prompt is set. A render error stops the run before the first completion. `query` returns `AgentError::FailedToRenderSystemPrompt`. The agent then stays pending, so the next `query` runs the `before_all` hooks and renders the prompt again. That gives a hook the chance to fill the missing value on a retry. ## Named templates A system prompt that several agents share can live in a named template: ```rust let mut templates = tera::Tera::default(); templates.add_raw_templates([ ( "release_rules", "Name the crate and the feature flag in every entry.", ), ( "release_manager", "You write the release notes for {{ project }}.\n\n{% include \"release_rules\" %}", ), ])?; Prompt::extend(&templates)?; let agent = Agent::builder() .llm(openai) .system_prompt( Prompt::from_compiled_template("release_manager") .with_context_value("project", "swiftide"), ) .build()?; ``` `Prompt::extend` merges your Tera instance into one template repository for the whole process. `Prompt::from_compiled_template` stores only the template name and looks it up when the agent renders the prompt. Named templates can include each other, so shared rules live in one partial, here `release_rules`. Swiftide does not re-export Tera. Add it to your own crate: ```sh cargo add tera@1.20 --no-default-features ``` ## Prompt removal `no_system_prompt` removes the system prompt from the builder, including one set earlier: ```rust let system_prompt = "You answer questions about the swiftide repository."; let mut first_agent = Agent::builder() .llm(openai) .system_prompt(system_prompt) .build()?; first_agent.query("Which crates does it have?").await?; // The stored history starts with the rendered system message. let history = first_agent.history().await?; let mut second_agent = Agent::builder() .llm(openai) .context( DefaultContext::default() .with_existing_messages(history) .await? .to_owned(), ) .system_prompt(system_prompt) // Removes the prompt set above, so the history keeps a single system message. .no_system_prompt() .build()?; second_agent.query("Which one holds the agents?").await?; ``` The second agent continues a stored message history that already holds a system message. Without `no_system_prompt`, its first run appends a second one. The call is useful when a shared builder function sets a prompt you do not want for a resumed run. [Context and history](/agents/context-and-history/) shows how the history is restored. ## Default prompt An agent has no system prompt unless you set one. Code written for 0.32 or earlier relied on a built-in default; [Upgrading](/reference/upgrading/#system-prompts) lists what to change. The model only sees what you write. A prompt for an agent with tools usually says what the task is, which tools to use in which order, and when to call `stop`. The release notes prompt does all three in its “How to work” list. ## Gotchas * A `Prompt::from` prompt without any context value is sent verbatim, without rendering. `{{ since }}` then reaches the model as literal text. Named templates always render. * An undefined value outside an `if` or the `default` filter fails rendering. Guard every optional value. * Changes to the system prompt after the first render do not reach the stored message. To change the instructions of a running conversation, replace the system message with `AgentContext::replace_history`, as [Context and history](/agents/context-and-history/) shows. * Setup errors after the render, such as a toolbox that fails to load, also leave the agent pending. The next `query` then adds the system message a second time. * Filters, functions and partials registered with `Prompt::extend` only work in named templates. A `Prompt::from` prompt renders with a fresh Tera instance. ## Reference * [`Prompt`](https://docs.rs/swiftide/0.34/swiftide/prompt/struct.Prompt.html) * [`AgentBuilder`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html) * [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) * [`AgentContext`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html) * [`ToolExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolExecutor.html) * [`ChatMessage`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ChatMessage.html) * [Tera documentation](https://keats.github.io/tera/docs/) ## Next [Stopping and structured results](/agents/stopping-and-structured-results/) # Testing agents > Test Swiftide agents without a provider by scripting the model, calling tools directly, or matching exact requests with MockChatCompletion. You can test an agent end to end without provider credentials or network access: a scripted model decides which tools to call, and your test asserts on the tool outputs, the requests and the stop reason. `Agent`, `DefaultContext` and `StopReason` sit behind the `swiftide-agents` feature flag, which is off by default. The tests also need tokio for its test macros, serde_json and async-trait: ```sh cargo add swiftide@0.34 --features swiftide-agents cargo add --dev tokio --features macros,rt-multi-thread cargo add --dev serde_json async-trait anyhow ``` Swiftide re-exports serde_json, async_trait and anyhow under `swiftide::reexports`, so you can use those paths instead of adding the crates. ## Scripted models ```rust /// A model that answers with scripted responses and records every request it receives. #[derive(Clone, Default)] pub struct ScriptedLlm { replies: Arc>>, requests: Arc>>>, } impl ScriptedLlm { pub fn new(replies: impl IntoIterator) -> Self { Self { replies: Arc::new(Mutex::new(replies.into_iter().collect())), requests: Arc::default(), } } /// The messages of every request, in the order the agent sent them. pub fn requests(&self) -> Vec> { self.requests.lock().unwrap().clone() } } #[async_trait] impl ChatCompletion for ScriptedLlm { async fn complete( &self, request: &ChatCompletionRequest<'_>, ) -> Result { self.requests .lock() .unwrap() .push(request.messages().to_vec()); self.replies .lock() .unwrap() .pop_front() .ok_or_else(|| LanguageModelError::permanent("the script has no more replies")) } } /// A scripted reply that calls one tool with JSON arguments. pub fn reply_calling( name: &str, args: serde_json::Value, ) -> anyhow::Result { let call = ToolCall::builder() .id(format!("call_{name}")) .name(name) .args(args.to_string()) .build()?; ChatCompletionResponse::builder() .tool_calls(vec![call]) .build() } ``` An agent talks to its model through the [`ChatCompletion`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ChatCompletion.html) trait. Only `complete` is required. The default `complete_stream` wraps `complete` in a stream of one response, so a scripted model also works for an agent with `on_stream` hooks. `ScriptedLlm` hands out one prepared [`ChatCompletionResponse`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionResponse.html) per completion and records the messages of every request. When the script runs out, it returns a permanent `LanguageModelError`, and `query` fails with `AgentError::CompletionsFailed`. The state sits behind `Arc>` because `AgentBuilder::llm` takes a reference and stores a boxed clone. Your test keeps the original, and both share the same queue and request log. `reply_calling` builds a response that calls one tool. The arguments are a JSON (JavaScript Object Notation) string, as a provider would send them. ## Agent tests The tool under test is a plain function tool: ```rust /// Counts the words in a piece of text. /// /// # Arguments /// - `text`: The text to count the words of. #[swiftide::tool] pub async fn count_words(_context: &dyn AgentContext, text: &str) -> Result { Ok(text.split_whitespace().count().to_string().into()) } ``` The test scripts two turns: the model calls `count_words`, then the `stop` tool. ```rust #[tokio::test] async fn counts_the_words_then_stops() -> anyhow::Result<()> { let llm = ScriptedLlm::new([ reply_calling("count_words", json!({ "text": "one two three" }))?, reply_calling("stop", json!({}))?, ]); let mut agent = Agent::builder().llm(&llm).tools([count_words()]).build()?; agent .query("How many words are in `one two three`?") .await?; let history = agent.history().await?; let counted = history.iter().find_map(|message| match message { ChatMessage::ToolOutput(call, output) if call.name() == "count_words" => { output.as_text() } _ => None, }); assert_eq!(counted, Some("3")); assert!(matches!( agent.stop_reason(), Some(StopReason::RequestedByTool(call, None)) if call.name() == "stop" )); let requests = llm.requests(); assert_eq!(requests.len(), 2); assert!(matches!( requests[1].last(), Some(ChatMessage::ToolOutput(..)) )); Ok(()) } ``` The agent runs the real tool, records its output in the message history and sends the history back to the model. `stop` ends the run with `StopReason::RequestedByTool` and no payload. The test then checks three things: * the `ChatMessage::ToolOutput` for `count_words` holds `3` * the stop reason names the `stop` tool * the second request ended with the tool output, so the model saw the result Because the agent has no system prompt by default, the first request holds only the user message. To test a system prompt, set one on the builder and assert on the first `ChatMessage::System` in `llm.requests()`. A reply without tool calls, such as `ChatCompletionResponse::builder().message("Done.").build()`, scripts a turn where the model only answers in text. The default context stops the agent after such a turn, with `StopReason::NoNewMessages`. ## Direct tool invocation Most tool logic needs no agent at all. Call `invoke` on the tool with a context and a [`ToolCall`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCall.html): ```rust #[tokio::test] async fn count_words_rejects_a_misspelled_argument() -> anyhow::Result<()> { let tool = count_words(); let context = DefaultContext::default(); let call = ToolCall::builder() .id("call_1") .name("count_words") .args(json!({ "text": "one two" }).to_string()) .build()?; let output = tool.invoke(&context, &call).await?; assert_eq!(output.as_text(), Some("2")); let typo = ToolCall::builder() .id("call_2") .name("count_words") .args(json!({ "txt": "one two" }).to_string()) .build()?; let error = tool.invoke(&context, &typo).await.unwrap_err(); assert!(matches!(error, ToolError::WrongArguments(_))); Ok(()) } ``` [`DefaultContext::default()`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html) runs commands through a `LocalExecutor` in the current directory, so tools that call `context.executor()` work too. Point it at a temporary directory with `DefaultContext::from_executor` when the tool touches files. Arguments that do not deserialize into the generated arguments struct return `ToolError::WrongArguments`. In the test above, the misspelled `txt` key leaves the required `text` argument missing. A call without arguments returns `ToolError::MissingArguments`. Extra keys are ignored: `deny_unknown_fields` only applies to the JSON schema the model sees. A misspelled key for an `Option` argument becomes `None` without an error. Inside an agent, both go back to the model as a failed tool output and count toward `tool_retry_limit`. ## MockChatCompletion For tests that pin down the exact requests, swiftide-core ships `MockChatCompletion` behind its `test-utils` feature flag. The swiftide crate has a `test-utils` feature too, but it does not re-export the module, so add swiftide-core as a dev dependency and import `swiftide_core::test_utils::MockChatCompletion`: ```sh cargo add --dev swiftide-core@0.34 --features test-utils ``` ```rust #[tokio::test] async fn sends_the_tool_output_back_to_the_model() -> anyhow::Result<()> { let llm = MockChatCompletion::new(); let question = ChatMessage::new_user("How many words are in `one two`?"); let count = ToolCall::builder() .id("call_1") .name("count_words") .args(json!({ "text": "one two" }).to_string()) .build()?; let stop = ToolCall::builder().id("call_2").name("stop").build()?; llm.expect_complete( ChatCompletionRequest::builder() .messages(vec![question.clone()]) .tools(Agent::default_tools()) .tool(count_words()) .build()?, Ok(ChatCompletionResponse::builder() .tool_calls(vec![count.clone()]) .build()?), ); llm.expect_complete( ChatCompletionRequest::builder() .messages(vec![ question, ChatMessage::new_assistant(None::, Some(vec![count.clone()])), ChatMessage::new_tool_output(count, ToolOutput::text("2")), ]) .tools(Agent::default_tools()) .tool(count_words()) .build()?, Ok(ChatCompletionResponse::builder() .tool_calls(vec![stop]) .build()?), ); let mut agent = Agent::builder().llm(&llm).tools([count_words()]).build()?; agent.query("How many words are in `one two`?").await?; assert!(agent.is_stopped()); Ok(()) } ``` Each `expect_complete` queues an expected [`ChatCompletionRequest`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionRequest.html) and the response to return. Expectations are consumed in the order you add them. A request that differs from the next expectation fails the test with both requests printed. An `Err` response reaches the agent as a permanent `LanguageModelError`. Request equality covers the messages and the full set of tool specs. The agent sends the spec of every tool it has, including the default `stop` tool, so the expected request lists `Agent::default_tools()` next to your own tools. When the last clone of the mock is dropped with expectations left over, it panics. A test that stops early fails even when every request it made matched. ## Showcase tests The release notes showcase has 27 tests. From the swiftide repository root: ```sh cargo test -p release-notes-agent ``` The session tests exercise the approval loop in `session.rs`: examples/release-notes-agent/src/session.rs ```rust /// 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 { 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), } } } ``` Five of the six session tests drive this loop with a scripted model like the one above. The showcase’s own `ScriptedLlm`, a simpler variant of the one above, takes `(tool name, arguments)` pairs, and an empty name scripts a turn without a tool call. A `ScriptedReviewer` plays the person who approves or asks for a revision, and those tests run against a temporary repository. They cover: * a revision round, then approval and the write * stopping without a write, which is an error * one nudge after a turn without a tool call, and an error after the second one * a draft that lists a pull request twice, which goes back to the model before review The duplicate check also ran against a live model. The other paths ran only in these tests. The sixth session test is a synchronous unit test. It builds a history by hand and checks that reaching the step limit is not mistaken for a turn without a tool call, although both report the same stop reason. The remaining tests are plain unit tests in the other modules. They cover system prompt rendering, decoding a streamed draft however it is chunked, and restarting the draft preview for a new tool call. They also cover finding a pull request listed in two entries, the stop tool schema, rendering the notes, tool names, clipping tool output, shell quoting, GitHub remote parsing, and rejecting unsafe revisions and paths. ## Gotchas * `swiftide_agents::test_utils` (`MockTool`, `MockHook`, `chat_request!`) is compiled only in test and debug builds, and its macros expand to `swiftide_core` paths. It serves swiftide’s own tests; do not build on it. * `MockChatCompletion::complete_stream` splits the response text on whitespace inside a spawned task. On a response without text that task panics, the stream closes empty, and `query` returns `AgentError::EmptyStream`. A streamed reply that only calls tools never delivers its tool calls. Script a message on every reply when the agent streams. * The agent rewrites JSON object arguments before it stores the assistant message, to drop duplicate keys. Build expected arguments with `serde_json`, as above, rather than as hand-typed strings with their own spacing. * The unit context `()` implements `AgentContext` for tools that never run commands. Its `executor()` panics, and it approves every feedback request. * A tool call that names a tool the agent does not have is skipped with a warning and gets no output. With a typo in a scripted tool name, the run ends with `StopReason::NoNewMessages` instead of the scripted stop. The next `query` or `run` on that agent fails with `AgentError::UnfulfilledToolCalls`. ## Reference * [`ChatCompletion`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ChatCompletion.html) * [`ChatCompletionRequest`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionRequest.html) * [`ChatCompletionResponse`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionResponse.html) * [`ChatMessage`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ChatMessage.html) * [`ToolCall`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCall.html) * [`ToolError`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/errors/enum.ToolError.html) * [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.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) * [`MockChatCompletion`](https://docs.rs/swiftide-core/0.34/swiftide_core/test_utils/struct.MockChatCompletion.html) * [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html) ## Next [How tasks work](/tasks/overview/) # Tools > Write agent tools as async functions with #[swiftide::tool], as stateful structs with #[derive(Tool)], or by implementing Tool by hand with your own schema, and control how tool errors are retried. You end up with three kinds of tool: async functions the model can call, a tool with its own state, and a hand-written tool with a custom argument schema. The examples come from the release notes agent in `examples/release-notes-agent`, which drafts release notes from git history. Add the dependencies the macros expect: ```sh cargo add swiftide@0.34 --features swiftide-agents,openai cargo add serde --features derive cargo add schemars@1 async-trait ``` examples/release-notes-agent/src/tools.rs ```rust /// 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, ) -> Result { 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 { 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 { 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.", ))) } ``` ## Function tools `#[swiftide::tool]` turns an async function into a tool. The first argument must be `&dyn AgentContext`. Every other argument becomes a tool argument that the model fills in. The macro generates three items from `git_log_since`: * a struct `GitLogSince` that implements `Tool`, * a function `git_log_since()` that returns it as a `Box`, ready for `AgentBuilder::tools`, * an argument struct `GitLogSinceArgs` that derives serde and `JsonSchema`. Its schema forbids extra properties, but deserialization ignores keys it does not know. The tool name the model sees is the function name, `git_log_since`. Since 0.33, the description is the doc comment text before the first heading. Each item under `# Arguments` describes one argument. A `# Parameters` heading works the same way. The tools parse the model’s strings into `Revision` and `RepoPath` before they call `context.executor()`, because the `LocalExecutor` in this example does not sandbox anything. See [Executors](/agents/executors/). A hook printed the first tool call of a real run with `claude-sonnet-5-5`: ```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 ``` ## Argument types An argument type must implement serde’s `Serialize` and `Deserialize`, `schemars::JsonSchema` and `Debug`, because the generated argument struct derives all four. The macro builds the JavaScript Object Notation (JSON) Schema of the arguments with `schemars::schema_for!`: ```rust /// An inclusive range of lines. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] pub struct LineRange { /// First line, starting at 1 pub start: u32, /// Last line, inclusive pub end: u32, } /// Reads line ranges from files in the workspace. /// /// # Arguments /// - `paths`: Files to read, relative to the workspace root. /// - `range`: Optional line range to limit the output. Use null for the whole file. #[swiftide::tool] async fn read_lines( context: &dyn AgentContext, paths: Vec, range: Option, ) -> Result { let _ = (context, range); Ok(ToolOutput::text(paths.join("\n"))) } ``` * `&str`, `&[T]`, `&Vec` and `&Option` are deserialized as their owned types. Take any other type, such as `LineRange`, by value. * An `Option` argument is optional. The OpenAI integration marks every property as required, so there the model sends `null` instead of leaving the argument out. The `range` description tells the model so. * The doc comments of a struct argument become field descriptions. * A map type such as `HashMap` compiles, but the generated `tool_spec` panics when the agent builds its first request. Arguments that do not parse never reach your function: the model gets a `ToolError::WrongArguments` back. ## Stateful tools When a tool needs configuration, derive `Tool` on a struct: examples/release-notes-agent/src/tools.rs ```rust /// 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) -> Self { Self { file: file.into() } } async fn write_changelog( &self, context: &dyn AgentContext, markdown: &str, ) -> Result { 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()))) } } ``` The derive calls the method named after the struct in snake case, here `write_changelog`. The model never chooses the file: it comes from the struct. * The derive does not read doc comments. `description` is required, and each argument needs a `param`. * A `param` without a type is a `String`, so the method takes `&str`. * The struct must be `Clone`. Register the tool with `add_tool(WriteChangelog::new(path))`, or pass `WriteChangelog::new(path).into()` to `tools`. ## Hand-written tools Implement `Tool` yourself to write the tool spec by hand and parse the arguments in your own code. Describe the arguments as a type: ```rust #[derive(Clone)] struct WorkflowTool; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[schemars( title = "WorkflowInstruction", description = "Choose a workflow action and optional payload", deny_unknown_fields )] struct WorkflowInstruction { #[schemars(description = "Which workflow action to execute")] action: WorkflowAction, #[schemars(description = "Optional payload forwarded to the workflow engine")] #[serde(default, skip_serializing_if = "Option::is_none")] payload: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] enum WorkflowAction { Start, Stop, Status, } ``` Then return that schema from `tool_spec`: ```rust #[swiftide::reexports::async_trait::async_trait] impl Tool for WorkflowTool { async fn invoke( &self, _agent_context: &dyn AgentContext, _tool_call: &ToolCall, ) -> Result { Ok(ToolOutput::text( "Workflow execution not implemented in this example", )) } fn name<'tool>(&'tool self) -> Cow<'tool, str> { Cow::Borrowed("workflow_tool") } fn tool_spec(&self) -> ToolSpec { ToolSpec::builder() .name("workflow_tool") .description("Executes a workflow action with strict input choices") .parameters_schema(workflow_schema()) .build() .expect("tool spec should be valid") } } fn workflow_schema() -> Schema { schema_for!(WorkflowInstruction) } ``` `invoke` receives the `ToolCall`, whose `args()` is the JSON string of the arguments. The agent drops duplicate keys and re-serializes the JSON first, so the string can differ from what the model sent. `name()` must match the `ToolSpec` name: the agent looks tools up by `name()`, the model calls the spec name. `ToolSpec::builder().build()` returns a `Result`. It checks the schema against Swiftide’s strict contract, so a `HashMap` field fails; see [Tool schema policies](/agents/providers-and-reasoning/#tool-schema-policies). The example calls `expect`, so an invalid schema panics at the first request. Cover it with a unit test that calls `tool_spec()`. A hand-written tool can wrap another one. `OneEntryPerChange` in the same `tools.rs` delegates to `WriteChangelog` and rejects drafts that list a change twice. ## Tool output A tool returns `Result`. `ToolOutput` is non-exhaustive and has these variants: * `Text(String)`: the result, added to the message history. `&str`, `String` and `CommandOutput` convert into it. * `Fail(String)`: a failure the model can fix. * `Stop(Option)`: stops the agent with `StopReason::RequestedByTool`. See [Stopping and structured results](/agents/stopping-and-structured-results/). * `FeedbackRequired(Option)`: stops the agent with `StopReason::FeedbackRequired` and leaves the tool call open. See [Human in the loop](/agents/human-in-the-loop/). * `AgentFailed(Option)`: stops the agent with `StopReason::AgentFailed`. ## Tool errors and retries `ToolError` has four variants: `WrongArguments`, `MissingArguments`, `ExecutionFailed` and `Unknown`. It converts from `serde_json::Error`, `CommandError` and `anyhow::Error`, so `?` works. ```rust /// Runs the tests of one crate in the workspace and returns their output. /// /// # Arguments /// - `package`: Name of the crate to test, e.g. `swiftide-core`. #[swiftide::tool] async fn run_tests(context: &dyn AgentContext, package: &str) -> Result { let is_crate_name = !package.is_empty() && package .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); if !is_crate_name { return Ok(ToolOutput::fail(format!( "`{package}` is not a crate name. Use a package name from Cargo.toml." ))); } let command = Command::shell(format!("cargo test --package {package}")); match context.executor().exec_cmd(&command).await { Ok(output) => Ok(output.into()), Err(CommandError::NonZeroExit(output)) => { Ok(ToolOutput::text(format!("Tests failed:\n{output}"))) } Err(error) => Err(ToolError::execution_failed(error)), } } ``` A bad crate name returns `Ok(ToolOutput::fail(..))`, and the model tries again. An executor error returns `Err`: the agent records it as a `Fail` output and counts the failure. `tool_retry_limit` sets how many failures of the same call the agent feeds back to the model: ```rust let mut agent = Agent::builder() .llm(openai) .tools([run_tests()]) .tool_retry_limit(2) .build()?; if let Err(error) = agent.query("Run the tests of swiftide-core.").await { if let Some(StopReason::ToolCallsOverLimit(call)) = agent.stop_reason() { eprintln!("gave up on `{}`: {error}", call.name()); } } ``` With a limit of 2, the third identical failure stops the agent. The full rules for `tool_retry_limit` and `limit` are on [Stopping and structured results](/agents/stopping-and-structured-results/#stop-reasons). ## Attribute descriptions Instead of doc comments you can pass `description` and `param` attributes. An explicit attribute wins over the doc comment: ```rust #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] struct FormatTimestampRequest { /// Prefix to prepend to the timestamp. prefix: String, /// Timestamp to format. timestamp: String, } #[swiftide::tool(description = "Get the current UTC date and time in RFC3339 format")] async fn current_utc_time(context: &dyn AgentContext) -> Result { let command_output = context .executor() .exec_cmd(&Command::shell("date -u +\"%Y-%m-%dT%H:%M:%SZ\"")) .await?; Ok(command_output.into()) } #[swiftide::tool( description = "Format a timestamp with a caller-provided prefix", param(name = "request", description = "Timestamp formatting input") )] async fn format_timestamp( _context: &dyn AgentContext, request: FormatTimestampRequest, ) -> Result { Ok(ToolOutput::text(format!( "{}{}", request.prefix, request.timestamp ))) } ``` `description` also accepts a path to a `const`, such as `description = READ_FILE`. ## Gotchas * The generated code refers to `::swiftide::` and `::serde` paths. The crate that uses the macros needs `swiftide` with the default `macros` feature flag and `serde` with `derive`. * Every argument after the context must be listed under `# Arguments` or as a `param`. A doc bullet with an empty description is dropped, and a missing argument fails to compile with “parameters are missing from the spec”. * Tools are unique by name. `add_tool` replaces a tool with the same name, and `tools` replaces every tool added before it. To bring your own `stop`, see [Stopping and structured results](/agents/stopping-and-structured-results/). * The tool calls of one completion run concurrently on separate Tokio tasks, so two calls to a tool that writes the same file can race. ## Reference * [`#[swiftide::tool]`](https://docs.rs/swiftide/0.34/swiftide/attr.tool.html) and [`#[derive(Tool)]`](https://docs.rs/swiftide/0.34/swiftide/derive.Tool.html) * [`Tool`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/trait.Tool.html) * [`ToolSpec`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolSpec.html) and [`ToolSpecBuilder`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolSpecBuilder.html) * [`ToolCall`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCall.html) * [`ToolOutput`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ToolOutput.html) * [`ToolError`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/errors/enum.ToolError.html) * [`AgentContext`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html) * [`AgentBuilder::tool_retry_limit`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.tool_retry_limit) * [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html) ## Next [Context and message history](/agents/context-and-history/)