Skip to content

Contrib tools and hooks

since 0.34

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.

let openai = OpenAI::builder().default_prompt_model("gpt-5.4").build()?;
let executor: Arc<dyn ToolExecutor> = 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?;

Source: snippets/src/agents/contrib.rs#L20-L36

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.

Nothing in the crate is enabled by default. Pick one feature flag per capability:

Feature flagAdds
agents-contrib-file-toolsReadFile, ExactReplace, ApplyPatch
agents-contrib-planningUpdatePlan, PlanItem, PlanStatus, PlanMemory
agents-contrib-contextToolSummarizer, ContextCompression
agents-contrib-lazy-executorLazyExecutor, 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:

Terminal window
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 lists every flag.

The model sees these tool names:

TypeTool nameArguments
ReadFile::default()read_filefile_name, optional offset and limit
ReadFile::default().with_line_numbers()read_file_with_line_numberssame, output prefixed with N| line numbers
ExactReplaceeditfile_path, old_string, new_string, optional replace_all
ApplyPatchapply_patchpatch

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:

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

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.

Two hooks keep the message history short:

let small_model = OpenAI::builder()
.default_prompt_model("gpt-5-mini")
.build()?;
let summarizer = ToolSummarizer::builder()
.llm(Arc::new(small_model.clone()) as Arc<dyn SimplePrompt>)
.tools_to_summarize(vec!["run_tests"])
.git_start_sha("HEAD")
.build()?;
let compression = ContextCompression::builder()
.llm(Arc::new(small_model) as Arc<dyn ChatCompletion>)
.git_start_sha("HEAD")
.git_diff_dir(".")
.context_limit(200_000usize)
.build()?;
builder.after_tool(summarizer.summarize_hook());
compression.register_hooks(builder);

Source: snippets/src/agents/contrib.rs#L44-L62

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

LazyExecutor defers expensive setup, such as cloning a repository or starting a container, until the agent first needs it:

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<dyn ToolExecutorExt>)
});
assert!(!lazy.is_started());
let executor: Arc<dyn ToolExecutor> = Arc::new(lazy);
let agent = Agent::builder()
.llm(openai)
.context(DefaultContext::from_executor(executor))
.add_tool(ReadFile::default())
.build()?;

Source: snippets/src/agents/contrib.rs#L70-L81

LazyExecutor::new takes a closure whose future resolves to an anyhow::Result<Arc<dyn ToolExecutorExt>>. 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.

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

The links point at the source on GitHub.

Testing agents