Showcases: complete example programs and what they produce
# Ask your codebase
> A CLI that indexes a Rust codebase locally with tree-sitter, FastEmbed and LanceDB, and answers questions about it citing the files it used.
This showcase is a command-line tool with two commands. `index` turns a codebase into searchable chunks, and `ask` answers a question from those chunks and lists the files it used. Everything except the large language model (LLM) runs on your machine. You need no Docker and no database server.
## Prerequisites
* Rust 1.97.1, which the swiftide workspace pins in `rust-toolchain.toml`.
* `OPENAI_API_KEY` for any OpenAI-compatible endpoint. `OPENAI_BASE_URL` is optional and defaults to `https://api.openai.com/v1`. `OPENAI_MODEL` is optional and defaults to `gpt-5.4-mini`. An empty `OPENAI_API_KEY` or `OPENAI_BASE_URL` counts as unset. An empty `OPENAI_MODEL` does not: it sets the model to an empty name, so unset it instead.
* About 600MB of disk for the code embedding model. FastEmbed downloads it into `.fastembed_cache/` in the current directory on first use.
The crate turns off swiftide’s default features and enables the `tree-sitter`, `fastembed`, `lancedb`, `redb` and `openai` feature flags. To build the same thing in your own crate:
```sh
cargo add swiftide@0.34 --no-default-features --features tree-sitter,fastembed,lancedb,redb,openai
cargo add fastembed@5 tree-sitter@0.26 --no-default-features
```
The crate also depends on `fastembed` to pick the embedding model and `tree-sitter` to find unit tests, each at the version swiftide uses. TLS needs no setup: swiftide’s HTTP clients bring their own rustls crypto provider ([TLS](/integrations/overview/#tls)).
## Architecture
The tool runs two pipelines that share one local embedder. The indexing pipeline loads files, chunks them, describes each chunk with the LLM, embeds the result and stores it. The query pipeline embeds your question with the same model, retrieves the closest chunks and asks the LLM to answer from them.

The index lives next to the code, in `/.ask-your-codebase/`. It holds `cache.redb`, the node cache, and `lancedb/`, the vector table.
## Indexing pipeline
examples/ask-your-codebase/src/index.rs
```rust
let stats = indexing::Pipeline::from_loader(
FileLoader::new(codebase.root()).with_extensions(&languages.extensions()),
)
.filter(test_code::not_test_files(codebase.clone()))
// Drop unit tests before the cache, which keys on path and content. Changing the content
// after it would make the cache miss on every run.
.then(test_code::without_unit_tests)
.then(files_found.count())
// Skip files whose path and content are unchanged since the last successful run
.filter_cached(codebase.node_cache()?)
.then(files_changed.count())
.then(record_relative_path(codebase.clone()))
// Split along syntax boundaries (functions, impls, ...) with tree-sitter
.then_chunk(ChunkByLanguage::try_new(languages, CHUNK_SIZE)?)
// Ask the LLM which questions each chunk answers; stored as metadata and embedded
.then(
MetadataQACode::from_client(llm)
.prompt_template(QUESTIONS_PROMPT)
.num_questions(QUESTIONS_PER_CHUNK)
.concurrency(concurrent_requests)
.build()?,
)
// Embed chunk + metadata locally, in batches
.then_in_batch(Embed::new(embedding::local_model()?).with_batch_size(embedding::BATCH_SIZE))
.then_store_with(codebase.vector_store()?)
.run()
.await?;
```
`FileLoader` respects `.gitignore`. `filter_cached` skips files whose path and content are unchanged since the last successful run. `then_in_batch` sends 32 chunks to the embedder per call. The two `count` closures count files for the summary line. `run` returns the `PipelineStats` of the run, and its `nodes_stored` is the number of chunks in the summary.
The order of the test steps matters. The node cache keys on the file path and its content. If unit tests were removed after `filter_cached`, the content would differ on every run and the cache would never hit.
### Test filtering
examples/ask-your-codebase/src/test_code.rs
```rust
/// Drops test files before they are cached or chunked: files in a `tests/` directory, test
/// modules in their own file (`src/agent/tests.rs`) and test support modules such as
/// `src/test_utils.rs`.
///
/// Errors are kept, so they still surface.
pub fn not_test_files(codebase: Codebase) -> impl Fn(&Result) -> bool + Send + Sync {
move |file| {
!file
.as_ref()
.is_ok_and(|file| is_test_file(codebase.relative_path(&file.path)))
}
}
/// Removes unit tests (`#[cfg(test)] mod tests { .. }`, `#[test] fn ..`) from Rust files.
///
/// This runs on whole files, before chunking, so a chunk never mixes implementation and tests.
pub fn without_unit_tests(mut file: TextNode) -> Result {
if file
.path
.extension()
.is_some_and(|extension| extension == "rs")
{
file.chunk = strip_test_items(&file.chunk)?;
}
Ok(file)
}
```
`Pipeline::filter` takes a closure over `&Result` and keeps the result when the closure returns true. The predicate keeps errors on purpose, so a failed load still surfaces. The second function is an ordinary transformer: any `Fn(TextNode) -> Result` works, and this one rewrites `chunk` for `.rs` files.
### Node cache and storage
examples/ask-your-codebase/src/codebase.rs
```rust
/// Remembers which files were fully indexed, so unchanged files are skipped next time.
pub fn node_cache(&self) -> Result {
std::fs::create_dir_all(&self.data_dir)?;
Redb::builder()
.database_path(self.data_dir.join("cache.redb"))
.table_name("indexed_files")
.build()
}
/// Embedded vector store with one vector per chunk and the source path next to it.
pub fn vector_store(&self) -> Result {
let uri = self.lancedb_dir();
LanceDB::builder()
.uri(uri.to_str().context("Index path is not valid UTF-8")?)
.table_name("code_chunks")
.vector_size(embedding::DIMENSIONS)
.with_vector(EmbeddedField::Combined)
.with_metadata(PATH_FIELD)
.build()
}
```
`Redb` is the node cache, stored in one table of a local database file. `LanceDB` is the storage: one vector column over `EmbeddedField::Combined`, sized at 768 for the embedding model, plus a `path` metadata column. Since 0.33, the pipeline marks files in the node cache only after the whole run finishes without an error. If any file fails, or you interrupt the run, nothing from that run is cached. The next run prompts and embeds every file again, not only the one that failed.
### Chunking per language
examples/ask-your-codebase/src/languages.rs
```rust
/// Splits each file with a tree-sitter `ChunkCode` for the file's language.
///
/// `ChunkCode` is built for a single language; this chunker picks the right one per file from
/// its extension, so one pipeline can index a polyglot codebase.
#[derive(Clone)]
pub struct ChunkByLanguage {
chunkers: Vec<(SupportedLanguages, ChunkCode)>,
}
impl ChunkByLanguage {
/// Chunks are between `chunk_size.start` and `chunk_size.end` bytes; smaller ones are dropped.
pub fn try_new(languages: &SourceLanguages, chunk_size: Range) -> Result {
let chunkers = languages
.0
.iter()
.map(|&language| {
let chunker =
ChunkCode::try_for_language_and_chunk_size(language, chunk_size.clone())?;
Ok((language, chunker))
})
.collect::>()?;
Ok(Self { chunkers })
}
fn chunker_for(&self, path: &Path) -> Option<&ChunkCode> {
let extension = path.extension()?.to_str()?;
self.chunkers
.iter()
.find(|(language, _)| language.file_extensions().contains(&extension))
.map(|(_, chunker)| chunker)
}
}
#[async_trait]
impl ChunkerTransformer for ChunkByLanguage {
type Input = String;
type Output = String;
async fn transform_node(&self, node: TextNode) -> IndexingStream {
match self.chunker_for(&node.path) {
Some(chunker) => chunker.transform_node(node).await,
None => anyhow!("No chunker for {}", node.path.display()).into(),
}
}
}
```
`ChunkCode` is built for one language. This chunker implements `ChunkerTransformer`, holds one `ChunkCode` per language and picks one by file extension through `SupportedLanguages::file_extensions`. The chunker aims for 10 to 4096 bytes, which keeps most functions in one piece. Chunks of 10 bytes or less are dropped. When tree-sitter finds a syntax error, the whole file becomes one chunk, whatever its size.
### Question metadata
examples/ask-your-codebase/src/index.rs
````rust
/// Asks for the questions a developer would ask that a chunk answers.
///
/// The default `MetadataQACode` prompt asks generic questions ("What does this code do?").
/// Questions about behaviour, phrased the way a developer would ask them, match real questions
/// much better.
const QUESTIONS_PROMPT: &str = r"
# Task
List the questions a developer could ask about this codebase that the code below answers, each
with a short answer. They are stored with the code to help a search find it.
Focus on behaviour: when and in which order things happen, what starts or ends a process, which
conditions and configuration change the outcome, what happens on errors, and which extension
points (traits, hooks, builders, options) exist and when they apply.
# Constraints
- Write {{ questions }} questions and answers.
- Phrase questions the way a developer would ask them, e.g. 'When does the agent stop?'.
- Name the relevant types, functions and variants in the answers.
- Only use what can be derived from the code.
- Respond only in this format:
Q1:
A1:
# Code
```
{{ node.chunk }}
```
";
````
The default `MetadataQACode` prompt asks generic questions. This template asks for questions about behavior, phrased the way a developer asks them. It is a Tera template: `MetadataQACode` fills in `questions` with the count and `node` with the chunk. The answer is stored under the metadata key `Questions and Answers (code)`. It is embedded with the chunk, but LanceDB has no column for it.
examples/ask-your-codebase/src/index.rs
```rust
/// Stores the file path, relative to the codebase root, as metadata on the node.
///
/// The vector store keeps this field next to each chunk, so answers can cite their sources.
fn record_relative_path(
codebase: Codebase,
) -> impl Fn(TextNode) -> Result + Clone + Send + Sync + 'static {
move |mut node| {
let path = codebase
.relative_path(&node.path)
.to_string_lossy()
.into_owned();
node.metadata.insert(PATH_FIELD, path);
Ok(node)
}
}
```
A closure that returns `Result` is a transformer too. This one writes the relative path into the metadata, where the `path` column picks it up.
### Local embeddings
examples/ask-your-codebase/src/embedding.rs
```rust
/// A code-aware embedding model that runs on the CPU and needs no API key.
///
/// The model (~600MB) is downloaded on first use.
pub fn local_model() -> Result {
let model = TextEmbedding::try_new(
InitOptions::new(EmbeddingModel::JinaEmbeddingsV2BaseCode).with_max_length(MAX_TOKENS),
)?;
FastEmbed::builder()
.embedding_model(model)
.batch_size(BATCH_SIZE)
.build()
}
```
`JinaEmbeddingsV2BaseCode` is a code embedding model with 768 dimensions. It runs on the CPU and needs no key. Both pipelines call `local_model`, because query vectors and chunk vectors must come from the same model. After you change the model, delete the index and build it again.
## Query pipeline
examples/ask-your-codebase/src/query.rs
```rust
let search = SimilaritySingleEmbedding::default().with_top_k(top_k);
let pipeline = query::Pipeline::from_search_strategy(search)
// Rephrase the question into several, to widen what the search can match
.then_transform_query(
GenerateSubquestions::builder()
.client(llm.clone())
.prompt_template(SUBQUESTIONS_PROMPT.into())
.build()?,
)
// Embed with the same local model that embedded the chunks
.then_transform_query(Embed::from_client(embedding::local_model()?))
.then_retrieve(codebase.vector_store()?)
// Answer the original question from the retrieved code, each chunk under its file path
.then_answer(
answers::Simple::builder()
.client(llm)
.prompt_template(ANSWER_PROMPT.into())
.document_template(CHUNK_TEMPLATE)
.build()?,
);
let answered = pipeline.query(question).await?;
```
`SimilaritySingleEmbedding` returns 10 chunks by default, and `with_top_k` sets the `--top-k` value instead. `with_top_k` takes the strategy by value and returns it, so it chains onto `default()`. `default()` is only implemented for the unfiltered strategy, so the filter type is inferred as `()`, meaning no filter. `GenerateSubquestions` rewrites the question into several related ones. The `Embed` query transformer embeds the rewritten text, the question and its subquestions together, as one vector with the local model. `query` consumes the pipeline and returns the answered query.
### Prompts and document template
examples/ask-your-codebase/src/query.rs
````rust
/// Widens the question into the follow-up questions its answer involves.
///
/// They are phrased like the questions generated for each chunk while indexing, so the two
/// embed close together.
const SUBQUESTIONS_PROMPT: &str = r"
Your job is to help a search find the code that answers a developer's question about a codebase.
Question: {{ question }}
Write {{ num_questions }} short follow-up questions that the code answering this question would also
answer. Think about the mechanisms involved: the main loop, the types and enum variants, the
conditions that are checked, the functions that are called, and the configuration that changes
the outcome.
## Constraints
* Phrase them the way a developer would ask about this codebase, e.g. 'When does the agent stop?'.
* Do not guess names of types or functions.
* Respond with the original question and the follow-up questions only, one per line, prefixed
with '- '.
";
/// Renders a retrieved chunk under its file path, so the answer can cite it.
///
/// `metadata.path` is the `PATH_FIELD` metadata; a test keeps the two in step.
const CHUNK_TEMPLATE: &str = r"### {{ metadata.path }}
```
{{ content }}
```
";
/// Answers from the retrieved code and cites files.
const ANSWER_PROMPT: &str = r"
Answer a developer's question about a codebase, using snippets of its source code.
Question: {{ question }}
## Constraints
* Only use the snippets below. If they do not answer the question, say briefly what is missing.
* Be concrete: name the types, functions and variants involved and explain when they apply.
* Describe behaviour: what calls what, in which order, and under which conditions.
* Cite the file each statement comes from in backticks, e.g. (`src/lib.rs`).
* Use concise markdown. Add a short code example only if it shows how to use the API.
## Snippets
{{ documents }}
";
````
The subquestion template receives `question` and `num_questions`, which defaults to 5. The answer template receives `question` and `documents`. With `document_template`, each retrieved document is rendered on its own, with `content` and `metadata.path` available, and the results are joined with `---` lines. That is how the answer sees which file each chunk came from.
### Sources
examples/ask-your-codebase/src/query.rs
```rust
impl From> for Answer {
/// Keeps the distinct file paths of the retrieved chunks, most relevant first.
fn from(query: Query) -> Self {
let mut sources: Vec = Vec::new();
for document in query.documents() {
let Some(path) = document.metadata().get(PATH_FIELD).and_then(|v| v.as_str()) else {
continue;
};
if !sources.iter().any(|source| source == path) {
sources.push(path.to_owned());
}
}
Self {
text: query.answer().to_owned(),
sources,
}
}
}
```
`documents` returns the retrieved documents in relevance order. The `path` column comes back as a `path` metadata entry. The tool keeps each path once and prints the list under the answer.
## Model client
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)))
}
}
```
The client is always swiftide’s `OpenAI` integration. `api_base` points it at `OPENAI_BASE_URL` and `api_key` sets the key, without building an `async-openai` client yourself. `use_responses_api` switches from Chat Completions to the Responses API when you pass `--responses-api`. `LanguageModelWithBackOff` retries rate limits and transient errors with exponential backoff. `index` keeps retrying a failing prompt for up to 60 seconds, because it sends many prompts at once and expects rate limits. `ask` stops retrying after 30 seconds.
## Commands
The crate lives in `examples/ask-your-codebase` of the swiftide repository. Clone the repository and run from its root:
```sh
git clone https://github.com/bosun-ai/swiftide && cd swiftide
export OPENAI_API_KEY=sk-...
cargo run -p ask-your-codebase -- index swiftide-agents
cargo run -p ask-your-codebase -- ask --path swiftide-agents "How does an agent decide to stop?"
```
`index` and `ask` are separate runs, so each can use its own model. Through a gateway that serves Anthropic models, a small model writes the indexing prompts and a stronger one writes the answers:
```sh
export OPENAI_BASE_URL=http://127.0.0.1:8080/v1 # an OpenAI-compatible gateway that serves these models
cargo run -p ask-your-codebase -- index swiftide-agents --model claude-haiku-5-5
cargo run -p ask-your-codebase -- ask --path swiftide-agents --model claude-sonnet-5-5 "How does an agent decide to stop?"
```
| Flag | Applies to | Default | Description |
| -------------------- | ---------- | -------------- | ------------------------------------------------------------------- |
| `--model ` | both | `gpt-5.4-mini` | Model for chunk metadata or answers. Also read from `OPENAI_MODEL`. |
| `--responses-api` | both | off | Send prompts to the Responses API. |
| `--lang ` | `index` | `rust` | Comma-separated tree-sitter languages, such as `rust,python`. |
| `--concurrency ` | `index` | `16` | Prompts sent at once while indexing. |
| `--path ` | `ask` | `.` | Root of the codebase, as given to `index`. |
| `--top-k ` | `ask` | `20` | Number of chunks to retrieve. |
By default the tool logs only errors and turns off all logging from `swiftide_integrations`, which hides errors from LanceDB, FastEmbed, Redb, tree-sitter and OpenAI. Each retried attempt is logged at WARN, so it stays hidden too. Set `RUST_LOG=info` or `RUST_LOG=debug` to see what the pipelines do.
## Output
The captured run indexed the `swiftide-agents` crate with a debug build. It went through an OpenAI-compatible gateway that forwards to Anthropic models on AWS Bedrock. `claude-haiku-5-5` wrote the per-chunk questions, `claude-sonnet-5-5` wrote the answers, and FastEmbed ran `jina-embeddings-v2-base-code` on the CPU.
The first run indexes everything. The second finds nothing changed, so the node cache skips every file and no prompt is sent:
```text
$ ./target/debug/ask-your-codebase index swiftide-agents --model claude-haiku-5-5
Indexing Rust files in swiftide-agents ...
Indexed 34 chunks from 14 new or changed files (14 files found, 0 unchanged) in 91.9s
$ ./target/debug/ask-your-codebase index swiftide-agents --model claude-haiku-5-5
Indexing Rust files in swiftide-agents ...
Indexed 0 chunks from 0 new or changed files (14 files found, 14 unchanged) in 0.6s
```
The start of one answer, and its sources:
```text
$ ./target/debug/ask-your-codebase ask --model claude-sonnet-5-5 --path swiftide-agents "How does an agent decide to stop?"
# How an agent decides to stop
An agent stops when something calls `Agent::stop` (or `stop_with_error`), which moves the state to `State::Stopped(StopReason)`. The main loop in `run_agent` checks that state after each step. `Agent::stop` is a no-op if the agent is already stopped (`src/agent.rs`).
[...]
## Triggers, by `StopReason` variant (`src/state.rs`)
| Variant | When it applies |
|---|---|
| `RequestedByTool(ToolCall, Option)` | A tool returned `ToolOutput::Stop`. |
| `FeedbackRequired { tool_call, payload }` | A tool returned `ToolOutput::FeedbackRequired`. |
| `AgentFailed(Option)` | A tool returned `ToolOutput::AgentFailed`. |
| `ToolCallsOverLimit(ToolCall)` | The same tool call failed too many times. |
| `Error` | `step` returned an `Err`. |
| `NoNewMessages` | The completion loop ended without another reason. |
| `Other(String)` | Anything convertible from `AsRef`, e.g. `agent.stop("done")`. |
[...]
Sources:
- src/agent.rs
- src/tools/control.rs
- src/lib.rs
- src/state.rs
- src/errors.rs
- src/task_agent.rs
- src/hooks.rs
- src/tasks.rs
- src/default_context.rs
```
The answers in [`OUTPUT.md`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/ask-your-codebase/OUTPUT.md) were checked against the source of `swiftide-agents`. Where a needed chunk was not among the 20 retrieved, the answer marked its claim as an inference.
## Limitations
* When a file changes, its new chunks are added, but the chunks of the old version stay in LanceDB. Delete `.ask-your-codebase/` to start over.
* Files under a `tests` directory, or named `tests`, `test_utils` or `test_helpers`, are dropped in every language. Unit tests inside a file are only removed from `.rs` files.
* Retrieval is a single vector search. Raise `--top-k` for broad questions.
* A file that produces no chunks is never marked in the node cache, so every run loads it again. It costs no LLM calls.
## Concept pages
* Loading and caching: [Loading](/indexing/loading/), [Caching](/indexing/caching/)
* Chunking: [Code-aware indexing](/indexing/code-aware-indexing/), [Chunking](/indexing/chunking/), [Tree-sitter](/integrations/tree-sitter/)
* Metadata and embeddings: [Transformers and metadata](/indexing/transformers-and-metadata/), [Embedding](/indexing/embedding/), [FastEmbed](/integrations/fastembed/)
* Storage: [Storage](/indexing/storage/), [LanceDB](/integrations/lancedb/)
* Querying: [Transforming queries](/querying/transforming-queries/), [Retrieval and search strategies](/querying/retrieval-and-search-strategies/), [Answering](/querying/answering/)
* Models: [OpenAI-compatible endpoints](/integrations/openai-compatible/), [Errors, backoff and usage](/models/errors-backoff-and-usage/)
## Reference
* [`indexing::Pipeline`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.Pipeline.html)
* [`FileLoader`](https://docs.rs/swiftide/0.34/swiftide/indexing/loaders/struct.FileLoader.html)
* [`ChunkCode`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.ChunkCode.html)
* [`ChunkerTransformer`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ChunkerTransformer.html)
* [`SupportedLanguages`](https://docs.rs/swiftide/0.34/swiftide/integrations/treesitter/enum.SupportedLanguages.html)
* [`MetadataQACode`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.MetadataQACode.html)
* [`Embed`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.Embed.html)
* [`EmbeddedField`](https://docs.rs/swiftide/0.34/swiftide/indexing/enum.EmbeddedField.html)
* [`Redb`](https://docs.rs/swiftide/0.34/swiftide/integrations/redb/struct.Redb.html)
* [`LanceDB`](https://docs.rs/swiftide/0.34/swiftide/integrations/lancedb/struct.LanceDB.html)
* [`FastEmbed`](https://docs.rs/swiftide/0.34/swiftide/integrations/fastembed/struct.FastEmbed.html)
* [`query::Pipeline`](https://docs.rs/swiftide/0.34/swiftide/query/struct.Pipeline.html)
* [`SimilaritySingleEmbedding`](https://docs.rs/swiftide/0.34/swiftide/query/search_strategies/struct.SimilaritySingleEmbedding.html)
* [`GenerateSubquestions`](https://docs.rs/swiftide/0.34/swiftide/query/query_transformers/struct.GenerateSubquestions.html)
* [`query_transformers::Embed`](https://docs.rs/swiftide/0.34/swiftide/query/query_transformers/struct.Embed.html)
* [`answers::Simple`](https://docs.rs/swiftide/0.34/swiftide/query/answers/struct.Simple.html)
* [`query::Query`](https://docs.rs/swiftide/0.34/swiftide/query/struct.Query.html)
* [`OpenAI`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/type.OpenAI.html)
* [`LanguageModelWithBackOff`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.LanguageModelWithBackOff.html)
* [`BackoffConfiguration`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.BackoffConfiguration.html)
## Next
[Release notes agent](/showcases/release-notes-agent/)
# Docs Q&A with evaluation
> A retrieval-augmented generation app over markdown docs with a custom loader, dense and sparse embeddings in Qdrant, hybrid search, local reranking, a Ragas dataset export, and claim-based scores from a model judge.
This tour builds `docs-qa`, a retrieval-augmented generation (RAG) app with three commands: `index`, `ask` and `eval`. It reads markdown files or an `llms-full.txt`, and every answer lists the pages and sections it came from. The `eval` command prints a score table that you can rerun after each retrieval change.
The crate lives in the swiftide repository under `examples/docs-qa`. Run it with `cargo run -p docs-qa` at the repository root.
## Prerequisites
* Rust 1.97.1, which the swiftide workspace pins in `rust-toolchain.toml`.
* Qdrant in Docker. Swiftide talks to Qdrant over gRPC, so expose port 6334 as well as 6333. `QDRANT_URL` (default `http://localhost:6334`) and `QDRANT_API_KEY` point the client elsewhere.
* `OPENAI_API_KEY` for any OpenAI-compatible endpoint. `OPENAI_BASE_URL` defaults to `https://api.openai.com/v1` and `OPENAI_MODEL` to `gpt-5.4-mini`. Embeddings and reranking run locally; only model calls go to this endpoint.
* About 2.3GB of disk for the FastEmbed models, downloaded from Hugging Face into `.fastembed_cache` on first use. Set `FASTEMBED_CACHE_DIR` to share one cache.
* Optional: `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` and `LANGFUSE_URL` for `--langfuse`.
The crate turns off default features and enables the feature flags `openai`, `fastembed`, `qdrant` and `langfuse`. The excerpts derive `schemars::JsonSchema`, and the loader fetches an `llms-full.txt` URL with `reqwest`. To build the same thing in your own crate:
```sh
cargo add swiftide@0.34 --no-default-features --features openai,fastembed,qdrant,langfuse
cargo add schemars@1 --features derive
cargo add reqwest@0.13 --no-default-features --features rustls
```
TLS needs no setup, because swiftide’s HTTP clients bring their own rustls crypto provider ([TLS](/integrations/overview/#tls)).
## Architecture
`index` writes chunks into one Qdrant collection. `ask` and `eval` build the same query pipeline over that collection; `eval` only adds an evaluator.

## Custom loader
examples/docs-qa/src/loader.rs
```rust
/// Yields one [`TextNode`] per documentation page, with the page title as metadata.
#[derive(Debug, Clone)]
pub struct DocsLoader {
sources: Vec,
}
impl DocsLoader {
pub fn new(sources: Vec) -> Self {
Self { sources }
}
}
impl Loader for DocsLoader {
type Output = String;
fn into_stream(self) -> IndexingStream {
stream::iter(self.sources)
.then(DocsSource::pages)
.map_ok(|pages| stream::iter(pages).map(|page| Ok(page_node(page))))
.try_flatten()
.boxed()
.into()
}
// Only needed when the loader is used as a `Box`.
fn into_stream_boxed(self: Box) -> IndexingStream {
self.into_stream()
}
}
fn page_node(page: Page) -> TextNode {
let mut node = TextNode::new(page.markdown);
node.path = page.origin.into();
node.metadata.insert(PAGE_TITLE, page.title.as_str());
node
}
```
A loader is the start of an indexing pipeline. `Loader` has one associated type, `Output`, and `into_stream` returns an `IndexingStream` of nodes. Here every page becomes one `TextNode`, with its origin as `node.path` and its title under the `Page` metadata key. A futures stream becomes an `IndexingStream` with `.boxed().into()`.
`into_stream_boxed` matters only when you use the loader as a `Box`. The default implementation panics, so implement it on the concrete type.
`DocsSource` decides how a page is read. `MarkdownDir` walks a directory with `FileLoader` and respects `.gitignore`. `MarkdownFile` reads one file. `LlmsFullFile` and `LlmsFullUrl` split an `llms-full.txt` into one page per top-level heading.
## Hybrid indexing
examples/docs-qa/src/index.rs
```rust
indexing::Pipeline::from_loader(loader)
.then_chunk(ChunkMarkdown::from_chunk_range(
MIN_CHUNK_CHARS..MAX_CHUNK_CHARS,
))
// A short title and keywords per chunk; both end up in the embedded text and help the
// sparse (keyword) side of hybrid search in particular.
.then(
MetadataTitle::from_client(llm.clone())
.prompt_template(TITLE_PROMPT)
.build()?,
)
.then(
MetadataKeywords::from_client(llm.clone())
.prompt_template(KEYWORDS_PROMPT)
.build()?,
)
.then_in_batch(Embed::new(embedders.dense.clone()).with_batch_size(EMBED_BATCH_SIZE))
.then_in_batch(SparseEmbed::new(embedders.sparse.clone()).with_batch_size(EMBED_BATCH_SIZE))
.then_store_with(qdrant.clone())
.run()
.await
```
`ChunkMarkdown::from_chunk_range(120..1500)` splits each page into chunks of at most 1,499 characters, because the range end is exclusive. It drops chunks whose trimmed text is under 120 bytes, which are mostly badges and stray headings. `MetadataTitle` stores a title under `Title` and `MetadataKeywords` stores keywords under `Keywords`, so indexing makes two large language model (LLM) calls per chunk. Two batch transformers follow: `Embed` writes the dense vector and `SparseEmbed` the sparse one, both from local FastEmbed models. `index` returns the `PipelineStats` that `run` produces. The CLI prints the chunk count of the whole collection instead, which also covers earlier runs.
The metadata transformers use their own Tera templates:
examples/docs-qa/src/index.rs
```rust
// Swiftide's default metadata prompts show the expected format inside a code fence, and models
// tend to copy the fence into the metadata. These ask for plain text and use the page title.
// A code-only chunk is titled by the API it shows, because the reranker scores the title too.
const TITLE_PROMPT: &str = "Write a short, descriptive title (at most 10 words) for this \
excerpt from the \"{{ node.metadata.Page }}\" documentation. If the excerpt is mostly code, \
name the API it demonstrates (a macro, trait or builder method), not what the example does. \
Reply with the title only: no quotes, no markdown, no code fences.
{{ node.chunk }}";
const KEYWORDS_PROMPT: &str = "List up to 8 keywords for this excerpt from the \
\"{{ node.metadata.Page }}\" documentation: names of types, crates, features, tools, commands \
and concepts that literally appear in it. Reply with one comma-separated line only: no \
numbering, no markdown, no code fences.
{{ node.chunk }}";
```
The default prompts show the expected format inside a code fence, and models tend to copy the fence. These templates ask for plain text and read the page title from `node.metadata.Page`. A code-only chunk is titled after the API it shows, because the reranker scores the title too.
### Store
examples/docs-qa/src/store.rs
```rust
let qdrant = Qdrant::builder()
.batch_size(BATCH_SIZE)
.vector_size(DENSE_VECTOR_SIZE)
// One dense and one sparse vector, both over the chunk *and* its metadata
.with_vector(EmbeddedField::Combined)
.with_sparse_vector(EmbeddedField::Combined)
.collection_name(&collection.0)
.build()?;
```
The collection holds one dense and one sparse vector per chunk. Both use `EmbeddedField::Combined`, which embeds the chunk together with its metadata, so the title and keywords reach the keyword side of the search. The vector size is 384 because the dense model is BGE small, the default of `FastEmbed::try_default`. The sparse model comes from `FastEmbed::try_default_sparse`, which loads SPLADE.
## Hybrid search and reranking
examples/docs-qa/src/query.rs
```rust
query::Pipeline::from_search_strategy(
HybridSearch::default()
.with_top_n(CANDIDATES)
.with_top_k(CANDIDATES),
)
```
`HybridSearch` defaults to 10 for both `top_n` and `top_k`. `with_top_n` and `with_top_k` take the strategy by value and return it, so they chain onto `default()` and the result goes straight into the pipeline.
examples/docs-qa/src/query.rs
```rust
pipeline
// Hybrid search needs both a dense and a sparse embedding of the question
.then_transform_query(query_transformers::Embed::from_client(
self.embedders.dense.clone(),
))
.then_transform_query(query_transformers::SparseEmbed::from_client(
self.embedders.sparse.clone(),
))
.then_retrieve(self.qdrant.clone())
// A local cross-encoder reorders the fused candidates; only the best few remain
.then_transform_response(self.reranker.clone())
.then_answer(self.answerer.clone())
```
Qdrant’s hybrid retriever needs both a dense and a sparse embedding of the question and fails without either. It runs a dense and a sparse search, each limited to `top_n`, fuses them with reciprocal rank fusion (RRF), and returns `top_k` documents: 20 candidates here.
The reranker is a response transformer. It runs between retrieval and the answer generator:
examples/docs-qa/src/query.rs
```rust
let reranker = Rerank::builder()
.top_k(CONTEXT_CHUNKS)
.document_template(Some(RERANK_TEMPLATE.to_string()))
.build()?;
let answerer = answers::Simple::builder()
.client(llm)
.prompt_template(ANSWER_PROMPT.into())
.document_template(DOCUMENT_TEMPLATE)
.build()?;
```
`Rerank` scores each candidate against the original question with a local cross-encoder and keeps `top_k` of them (default 10). With a `document_template`, each document is rendered with Tera first, with `metadata` and `content` available. `answers::Simple` renders every kept document with its own `document_template` and joins them with `---` lines into `{{ documents }}`. Its `prompt_template` takes a `Prompt`, hence the `.into()`.
examples/docs-qa/src/query.rs
```rust
// The answer prompt receives the question and the rendered documents; each document is rendered
// with its page title and the chunk title written during indexing.
const ANSWER_PROMPT: &str = "You answer questions about Swiftide, a Rust library for building \
LLM applications, using excerpts from its documentation.
Question: {{ question }}
## Instructions
* Use only the documentation excerpts below. If they do not answer the question, say so plainly.
* Start with the direct answer in one or two sentences, then add supporting details.
* Only when the question asks how to do something, include the feature flags, commands or a \
short Rust snippet from the excerpts. Never invent APIs.
* Format the answer as markdown and keep it under 250 words. Do not refer to \"the excerpts\".
## Documentation excerpts
{{ documents }}
";
const DOCUMENT_TEMPLATE: &str = "### {{ metadata.Page | default(value=\"\") }}: \
{{ metadata.Title | default(value=\"\") }}
{{ content }}";
// The reranker scores the same title-first rendering. A chunk that is mostly code says little
// about what it shows, so its page and the title written during indexing go first.
const RERANK_TEMPLATE: &str = "{{ metadata.Page | default(value=\"\") }}: \
{{ metadata.Title | default(value=\"\") }}
{{ content }}";
```
### Qdrant content workaround
Qdrant retrieval turns the stored `content` payload into a string with `to_string` on Qdrant’s own `Value` type, which formats strings with `Debug`. Every chunk then comes back wrapped in quotes, with escaped newlines. The example wraps the store in `CleanQdrant` (`src/retrieval.rs`), which implements `Retrieve` for `HybridSearch`, calls Qdrant, and restores the original text. The reranker, the LLM and the evaluator then see clean markdown.
## Evaluation
examples/docs-qa/src/eval.rs
```rust
let ragas = Ragas::from_prepared_questions(&questions);
let answered = qa
.evaluated_pipeline(ragas.clone())
.query_all(ragas.questions().await)
.await?;
let ragas_json = ragas.to_json().await;
```
Retrieval-Augmented Generation Assessment (Ragas) is a Python toolkit. Swiftide’s `Ragas` evaluator only exports data for it and computes no scores.
`Ragas::from_prepared_questions` takes anything that converts into an `EvaluationDataSet`. The example implements that conversion for `&QuestionSet`, which is why the snippet passes `&questions`. `qa.evaluated_pipeline` builds the same query pipeline and attaches the evaluator with `evaluate_with`. `query_all` runs every question, and `to_json` exports the dataset with `question`, `answer`, `contexts` and `ground_truth`.
The evaluator records contexts at retrieval, before any response transformer. `ragas.json` therefore holds all 20 candidates per question, not the 10 the reranker kept.
### LLM judge
To print scores without leaving Rust, a judge splits the ground truth and the answer into claims and checks each one:
examples/docs-qa/src/judge.rs
```rust
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct Judgement {
reference_claims: Vec,
answer_claims: Vec,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ReferenceClaim {
// Never read; asking for the claim first makes the model commit to it before judging it.
#[expect(dead_code)]
claim: String,
in_answer: bool,
in_context: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AnswerClaim {
#[expect(dead_code)]
claim: String,
supported_by_context: bool,
}
```
examples/docs-qa/src/judge.rs
```rust
let contexts = answer.contexts().collect::>().join("\n---\n");
let prompt = Prompt::from(JUDGE_PROMPT)
.with_context_value("question", answer.question())
.with_context_value("ground_truth", ground_truth)
.with_context_value("answer", answer.text())
.with_context_value("contexts", contexts);
// The JSON schema of `Judgement` is sent along; the reply is parsed into it
let judgement: Judgement =
retry_transient(|| self.llm.structured_prompt(prompt.clone())).await?;
```
`structured_prompt` sends the JSON schema of `Judgement` with the prompt and parses the reply into it. The scores are plain ratios over the claims: faithful (answer claims the reranked context supports), answer recall and context recall. `LanguageModelWithBackOff` does not wrap `StructuredPrompt`, so the judge calls the plain `OpenAI` client and retries transient errors itself, up to five attempts.
## Model client
examples/docs-qa/src/models.rs
```rust
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
/// A chat model that retries rate limits and other transient errors with exponential backoff.
/// Indexing makes two calls per chunk, concurrently, so it will hit rate limits.
pub type RetryingModel = LanguageModelWithBackOff;
/// An OpenAI-compatible API: `OpenAI` itself, or any gateway or local server that speaks it.
#[derive(Clone)]
pub struct LlmEndpoint {
base_url: String,
api_key: String,
use_responses_api: bool,
}
impl LlmEndpoint {
/// Reads `OPENAI_BASE_URL` (optional, defaults to `OpenAI`) and `OPENAI_API_KEY` (required).
pub fn from_env(use_responses_api: bool) -> Result {
let api_key = non_empty_env("OPENAI_API_KEY")
.context("Set OPENAI_API_KEY to the API key of your OpenAI-compatible endpoint")?;
let base_url =
non_empty_env("OPENAI_BASE_URL").unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
Ok(Self {
base_url,
api_key,
use_responses_api,
})
}
/// A client for `model` on this endpoint.
pub fn model(&self, model: &str) -> Result {
Ok(OpenAI::builder()
.api_base(&self.base_url)
.api_key(&self.api_key)
.default_prompt_model(model)
.use_responses_api(self.use_responses_api)
.build()?)
}
/// The same client, wrapped in Swiftide's backoff decorator. An unreachable endpoint gives
/// up after two minutes.
pub fn retrying_model(&self, model: &str) -> Result {
let backoff = BackoffConfiguration {
initial_interval_sec: 5,
max_elapsed_time_sec: 120,
..BackoffConfiguration::default()
};
Ok(LanguageModelWithBackOff::new(self.model(model)?, backoff))
}
}
```
Answer and metadata calls go through `LanguageModelWithBackOff`, as in [Ask your codebase](/showcases/ask-your-codebase/#model-client). Here it retries with a longer backoff than the defaults (1 s start, 60 s cap).
## Tracing
examples/docs-qa/src/telemetry.rs
```rust
let terminal = tracing_subscriber::fmt::layer()
.compact()
.with_writer(std::io::stderr)
.with_filter(log_filter());
// Reads LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_URL
let langfuse = send_to_langfuse.then(LangfuseLayer::default);
tracing_subscriber::registry()
.with(terminal)
// Langfuse data is emitted at `debug`, so its layer gets its own, wider filter
.with(
langfuse
.clone()
.map(|layer| layer.with_filter(LevelFilter::DEBUG)),
)
.try_init()?;
```
The Langfuse layer is set up the same way as in the [release notes agent](/showcases/release-notes-agent/#tracing). Langfuse data is emitted at debug level, so its layer gets its own debug-level filter.
## Commands
```sh
git clone https://github.com/bosun-ai/swiftide && cd swiftide
docker run -d --name docs-qa-qdrant -p 6333:6333 -p 6334:6334 qdrant/qdrant
export OPENAI_API_KEY=sk-...
cargo run -p docs-qa -- index --recreate \
README.md CONTRIBUTING.md AGENTS.md swiftide-agents-contrib/README.md
cargo run -p docs-qa -- ask "How do I give an agent tools to read and edit files?"
cargo run -p docs-qa -- eval --out examples/docs-qa/ragas.json
```
| Flag | Where | Default | What it does |
| ------------------ | ------- | ------------------------------- | ------------------------------------------------ |
| `--model` | global | `gpt-5.4-mini` (`OPENAI_MODEL`) | Answers questions; default for the other models |
| `--responses-api` | global | off | Responses API instead of chat completions |
| `--collection` | global | `docs-qa` | Qdrant collection |
| `--langfuse` | global | off | Send traces to Langfuse (`LANGFUSE_URL`) |
| `--recreate` | `index` | off | Drop the collection first |
| `--metadata-model` | `index` | `--model` | Writes titles and keywords; pick a cheaper model |
| `--questions` | `eval` | bundled `questions.yaml` | Questions with ground truths |
| `--out` | `eval` | `ragas.json` | Where the dataset goes |
| `--judge-model` | `eval` | `--model` | Grades the answers |
## Output
The captured run used an OpenAI-compatible gateway in front of AWS Bedrock. `claude-haiku-5-5` wrote titles and keywords, and `claude-sonnet-5-5` wrote the answers and acted as judge. FastEmbed ran BGE small en v1.5, SPLADE++ and BGE reranker base locally.
```text
Indexed 43 chunks into `docs-qa` in 71.7s
```
The answer below describes the agents-contrib file tools, which shipped in 0.34. The CLI prints the answer followed by its sources.
```text
Enable the `agents-contrib-file-tools` feature on `swiftide`, then register `ReadFile`, `ExactReplace`, and `ApplyPatch` on your agent with `add_tool`.
...
**Requirement:** the executor must provide a POSIX-like shell with `cat`, `mkdir`, and `rm` commands.
Sources:
[1] Swiftide › Agent Tools and Hooks: Enabling Contrib File, Planning, and Context Features (README.md)
[2] swiftide-agents-contrib › read_file tool: arguments, defaults, and offset/limit behavior (swiftide-agents-contrib/README.md)
[3] swiftide-agents-contrib › File Tools: Line-Numbered Reads and Exact-Match Edits (swiftide-agents-contrib/README.md)
...
[10] Swiftide › Swiftide capabilities for agents, typed task graphs, pipelines, and tracing (README.md)
```
```text
# question ctx faithful answer context
recall recall
1 Which crate features and environment variable do I … 10 1.00 1.00 1.00
2 What does the agents-contrib-context feature provid… 10 1.00 1.00 1.00
3 What are the default limits of the read_file tool? 10 1.00 1.00 1.00
4 Which storage and retrieval backends does Swiftide … 10 1.00 1.00 1.00
5 What should I run before opening a pull request? 10 1.00 1.00 1.00
6 What is a task in Swiftide? 10 1.00 1.00 1.00
7 When does ContextCompression summarize the conversa… 10 1.00 1.00 1.00
8 Which commit message convention does the project fo… 10 1.00 1.00 1.00
9 How do I add a custom tool to an agent with the too… 10 1.00 1.00 1.00
mean 1.00 1.00 1.00
RAGAS dataset written to examples/docs-qa/ragas.json
```
`ctx` is the number of chunks the LLM saw after reranking.
## Gotchas
* A chunk that is mostly code ranks poorly when the reranker scores only its text. Render page and chunk title into the reranker’s `document_template`, and title code-only chunks after the API they show.
* The scores come from one judge over nine questions. Treat them as a regression signal for this corpus, not as a benchmark.
* `--responses-api` failed against the gateway used for the captured run, which returns a field the `async-openai` client rejects.
* Ragas exports rows in hash-map order, so `eval` sorts them back into question order before writing.
## Concept pages
* Indexing: [Loading](/indexing/loading/), [Chunking](/indexing/chunking/), [Transformers and metadata](/indexing/transformers-and-metadata/), [Embedding](/indexing/embedding/)
* Storage: [Storage](/indexing/storage/), [Qdrant](/integrations/qdrant/), [FastEmbed](/integrations/fastembed/)
* Querying: [Retrieval and search strategies](/querying/retrieval-and-search-strategies/), [Reranking and response transformers](/querying/reranking-and-response-transformers/), [Answering](/querying/answering/), [Evaluation](/querying/evaluation/)
* Models and tracing: [Structured output](/models/structured-output/), [Langfuse](/observability/langfuse/)
* Recipe: [Hybrid search and reranking](/cookbook/hybrid-search-and-reranking/)
## Reference
* [`Loader`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.Loader.html)
* [`FileLoader`](https://docs.rs/swiftide/0.34/swiftide/indexing/loaders/struct.FileLoader.html)
* [`TextNode`](https://docs.rs/swiftide/0.34/swiftide/indexing/type.TextNode.html)
* [`EmbeddedField`](https://docs.rs/swiftide/0.34/swiftide/indexing/enum.EmbeddedField.html)
* [`IndexingStream`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.IndexingStream.html)
* [`ChunkMarkdown`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.ChunkMarkdown.html)
* [`MetadataTitle`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.MetadataTitle.html)
* [`MetadataKeywords`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.MetadataKeywords.html)
* [`Embed`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.Embed.html) and [`SparseEmbed`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.SparseEmbed.html) (indexing)
* [`Qdrant`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/struct.Qdrant.html)
* [`FastEmbed`](https://docs.rs/swiftide/0.34/swiftide/integrations/fastembed/struct.FastEmbed.html)
* [`Rerank`](https://docs.rs/swiftide/0.34/swiftide/integrations/fastembed/struct.Rerank.html)
* [`HybridSearch`](https://docs.rs/swiftide/0.34/swiftide/query/search_strategies/struct.HybridSearch.html)
* [`Embed`](https://docs.rs/swiftide/0.34/swiftide/query/query_transformers/struct.Embed.html) and [`SparseEmbed`](https://docs.rs/swiftide/0.34/swiftide/query/query_transformers/struct.SparseEmbed.html) (query)
* [`answers::Simple`](https://docs.rs/swiftide/0.34/swiftide/query/answers/struct.Simple.html)
* [`Ragas`](https://docs.rs/swiftide/0.34/swiftide/query/evaluators/ragas/struct.Ragas.html) and [`EvaluationDataSet`](https://docs.rs/swiftide/0.34/swiftide/query/evaluators/ragas/struct.EvaluationDataSet.html)
* [`Prompt`](https://docs.rs/swiftide/0.34/swiftide/prompt/struct.Prompt.html)
* [`Retrieve`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.Retrieve.html)
* [`StructuredPrompt`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.StructuredPrompt.html)
* [`LanguageModelWithBackOff`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.LanguageModelWithBackOff.html)
* [`BackoffConfiguration`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.BackoffConfiguration.html)
* [`OpenAI`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/type.OpenAI.html)
* [`LangfuseLayer`](https://docs.rs/swiftide/0.34/swiftide/langfuse/struct.LangfuseLayer.html)
## Next
[Agent control recipes](/cookbook/agent-control/)
# Release notes agent
> An agent that reads git history through scoped tools, streams a release-notes draft, waits for your approval, and ends with structured notes.
You end up with a command-line agent that drafts release notes for the commits since your last tag. It streams the draft to your terminal and writes `RELEASE_NOTES.md` only after you answer `y`. The code lives in `examples/release-notes-agent` of the swiftide repository.
## Prerequisites
* Rust 1.97.1 and a git repository with at least one tag.
* `OPENAI_API_KEY`. Set `OPENAI_BASE_URL` to use any OpenAI-compatible endpoint instead of `https://api.openai.com/v1`.
* For `--mcp`: Node.js (`npx`) and `GITHUB_PERSONAL_ACCESS_TOKEN` or `GITHUB_TOKEN`.
* For `--trace`: `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY`, and optionally `LANGFUSE_URL`.
The CLI also reads variables from a `.env` file, found in the current directory or the nearest parent, or from the file you pass with `--env-file`. TLS needs no setup, because swiftide’s HTTP clients bring their own rustls crypto provider ([TLS](/integrations/overview/#tls)).
The crate turns off default features and enables the feature flags `macros`, `swiftide-agents`, `openai`, `mcp` and `langfuse`. The excerpts below also need `async-trait`, because `#[derive(Tool)]` expands to `#[async_trait]`, plus `schemars` and `rmcp`. The model client sets the endpoint with `api_base` and `api_key` on the `OpenAI` builder, which arrived in 0.34. To build the same thing in your own crate:
```sh
cargo add swiftide@0.34 --no-default-features --features macros,swiftide-agents,openai,mcp,langfuse
cargo add async-trait schemars --features schemars/derive
cargo add rmcp --features client,transport-child-process
```
The example itself builds against the swiftide workspace through a path dependency.
## Architecture

The app has one agent and one tool executor. The agent reads git history with three tools, drafts the notes, and calls `write_changelog`. That tool waits for approval, which the session loop collects outside the agent. A custom `stop` tool ends the run with the notes as structured data.
## Tool executor
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()?,
);
```
`LocalExecutor` runs every command with the repository root as its working directory. A command without its own timeout is stopped after one minute. It does not sandbox anything: absolute paths and `..` still reach outside the repository. The tools therefore parse revisions and paths from the model into value types, `Revision` and `RepoPath`, before they build a command. The output path comes from the CLI, not from the model.
## Tools
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.",
)))
}
```
`#[swiftide::tool]` turns each async function into a tool. It also generates a constructor with the same name that returns a boxed tool, which is why the agent calls `git_log_since()`. The tool description comes from the doc comment, and the parameter descriptions come from its `# Arguments` list. Every tool sends its `Command` through `executor()` on the context, so all commands share the executor above. The git queries run as shell commands. `Command::read_file` and `Command::write_file` go straight to the filesystem.
### Stateful tool
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 `Tool` derive macro builds a tool from a struct. Here the struct holds the output file, which the CLI fixes, so the model cannot choose where to write. The macro calls the method named after the tool, `write_changelog`, and generates the argument type `WriteChangelogArgs`. The session deserializes the pending call into that type to report how many lines the draft has and where it goes.
## System prompt
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 is a `Prompt`, a Tera template read from `src/system_prompt.md`. Since 0.33, `Prompt` replaces `SystemPrompt`; see [Upgrading](/reference/upgrading/). `with_context_value` fills a template variable.
## Agent
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()?)
}
```
`DefaultContext::from_executor` gives the agent’s context the executor, so tools get it through `executor()`. Call `without_default_stop_tool` before `tools`, because `tools` adds the default `stop` tool at the moment you call it. The `write_changelog` tool sits inside two wrappers. `ApprovalRequired` holds the call until you decide. `OneEntryPerChange`, defined in the example, sends a draft that lists one pull request twice back to the model before you see it. `limit(STEP_LIMIT)` caps one run at 40 completions.
## Hooks and streaming
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(()) })
});
}
```
Since 0.33, `before_tool` and `after_tool` receive `&mut Agent`. `after_tool` also gets the tool result as `&mut`, so a hook could rewrite it.
examples/release-notes-agent/src/progress.rs
```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(()) })
});
}
```
Registering `on_stream` turns on streaming completions for the agent. The notes arrive as the JSON argument of `write_changelog`, so the hook reads `tool_calls_chunk` and decodes the argument as it grows. The client in `src/llm.rs` sets `stream_full(false)` on the OpenAI builder. Each chunk then leaves out the accumulated message, tool calls and usage. `message_chunk` holds only the new text, but `tool_calls_chunk` still holds the argument accumulated so far.
## Structured stop
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` creates a tool named `stop` whose arguments must match the schema of `ReleaseNotes`. With a custom schema the whole argument payload is forwarded, without an `output` wrapper. It arrives in `StopReason::RequestedByTool`, where the session deserializes it into `ReleaseNotes`. `render` in `src/notes.rs` prints that value as the summary at the end of a run.
## Approval
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),
}
}
}
```
`ApprovalRequired` returns `ToolOutput::FeedbackRequired` until the context holds feedback for that tool call. The agent then stops with `StopReason::FeedbackRequired`, which carries the pending tool call. To approve, record `ToolFeedback::approved()` with `feedback_received` and call `run()`. The pending call then runs the inner tool. To ask for changes, record `ToolFeedback::refused()` and send your feedback with `query()`. The model sees “This tool call was refused” as the tool output, followed by your message.
The session reports success only when the approved write returned text. A write that fails goes back to the model, and the loop continues.
### Turn limit and nudges
The step counter resets on every `query()` and `run()`. When the agent reaches the limit, it stops with `StopReason::NoNewMessages`, the same stop reason as a turn without tool calls. The session tells the two apart from the message history. After a turn without a tool call it asks the model once to continue. At the limit, or after a second empty turn, it gives up.
## MCP tools
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)
}
```
`--mcp` starts the GitHub Model Context Protocol (MCP) server as a child process. `McpToolbox::try_from_transport` connects to it, and `with_whitelist` keeps two read-only tools, `get_pull_request` and `get_issue`. Swiftide registers MCP tools as `:`, and OpenAI rejects `:` in tool names. The example wraps the toolbox to replace those characters with `_`, so the model sees the two tools with the server name and `_` in front. Without `npx`, a token or a GitHub `origin`, the CLI continues without these tools.
## Tracing
examples/release-notes-agent/src/telemetry.rs
```rust
/// Logs warnings to stderr (override with `RUST_LOG`) and, with `trace`, sends traces to
/// Langfuse.
pub fn init(trace: bool) -> Result {
let logs = tracing_subscriber::fmt::layer()
.compact()
.with_writer(io::stderr)
.with_ansi(io::stderr().is_terminal())
.with_filter(
EnvFilter::try_from_default_env()
// The agent logs stops and approvals as warnings, and errors it also returns;
// the CLI reports both itself.
.unwrap_or_else(|_| EnvFilter::new("warn,swiftide_agents=off")),
);
let langfuse = if trace {
for key in ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"] {
ensure!(env::var_os(key).is_some(), "--trace needs {key}");
}
// Reads the keys and `LANGFUSE_URL` (default http://localhost:3000) from the env.
Some(LangfuseLayer::default())
} else {
None
};
tracing_subscriber::registry()
.with(logs)
.with(
langfuse
.clone()
// Langfuse data is logged at debug level; only send Swiftide's spans, not
// those of every dependency.
.map(|layer| {
layer
.with_filter(Targets::new().with_target("swiftide", LevelFilter::DEBUG))
}),
)
.try_init()?;
Ok(Self { langfuse })
}
```
`LangfuseLayer::default()` reads the keys and `LANGFUSE_URL` (default `http://localhost:3000`) from the environment. It panics when a key is missing or empty. `init` checks that the keys are set first, but an empty value still passes that check. The `Targets` filter matches by prefix, so the layer receives spans and events from every target that starts with `swiftide`, at debug level and above. `main` calls `flush` before the process exits, so buffered events reach Langfuse.
## Commands
Clone the repository:
```sh
git clone https://github.com/bosun-ai/swiftide
cd swiftide
```
From the checkout root, this drafts notes for swiftide itself and writes `RELEASE_NOTES.md` there:
```sh
cargo run -p release-notes-agent -- --version 0.34.0
```
Point it at another OpenAI-compatible endpoint, or at another repository:
```sh
OPENAI_BASE_URL=http://127.0.0.1:8080/v1 OPENAI_MODEL=claude-sonnet-5-5 \
cargo run -p release-notes-agent -- --version 0.34.0
cargo run -p release-notes-agent -- --repo ../my-crate --since v1.2.0 --mcp --trace
```
Answer `y` or `yes` to write the notes. Any other answer, including an empty one, asks what should change. An empty answer to that second question stops without writing.
`cargo test -p release-notes-agent` runs the revise round and the nudge against a scripted model ([Testing](/agents/testing/)), and checks the step-limit detection on a hand-built message history.
| Flag | Default | |
| ------------------- | --------------------- | ----------------------------------------------------------------- |
| `--repo ` | `.` | The git repository. |
| `--since ` | latest tag | The previous release. |
| `--version ` | proposed by the agent | The version being released. |
| `--output ` | `RELEASE_NOTES.md` | File the notes are prepended to, relative to the repository root. |
| `--mcp` | off | Read pull requests through the GitHub MCP server. |
| `--trace` | off | Send traces to Langfuse. |
| `--model ` | `gpt-5.4` | Model name, also read from `OPENAI_MODEL`. |
| `--responses-api` | off | Use the OpenAI Responses API instead of chat completions. |
| `--env-file ` | nearest `.env` | Load environment variables from this file. |
## Output
This run used `claude-sonnet-5-5` through an OpenAI-compatible gateway in front of AWS Bedrock, with chat completions. It covers the 7 commits since v0.33.0, released as 0.34.0, with `y` piped into stdin. The tool calls in a group run in parallel, but the agent awaits them in the order they were issued, so their results print in call order.
```text
Drafting release notes for the 7 commits since v0.33.0 with claude-sonnet-5-5 at http://127.0.0.1:8080/v1 (chat completions)
→ git_log_since v0.33.0
git_log_since ✓ 346 lines
→ git_diff swiftide-integrations/Cargo.toml 3b1ad22e^..3b1ad22e
→ git_diff …/src/chat_completion/chat_completion_response.rs 90bcadc7^..90bcadc7
...
git_diff ✓ 15 lines
git_diff ✓ 72 lines
...
## 0.34.0
This release adds the opt-in `swiftide-agents-contrib` crate and Perl support for tree-sitter. It also fixes duplicated text in OpenAI streaming and resolves security advisories, which removes AWS Console login from the Bedrock client.
...
→ write_changelog <32 lines>
write_changelog ⏸ waiting for approval
── proposed: 32 lines into a new file, RELEASE_NOTES.md
Write these release notes? [y/N] y
→ write_changelog <32 lines>
write_changelog ✓ wrote RELEASE_NOTES.md
→ stop (version 0.34.0: highlights 2, breaking 1, fixes 1)
stop ■ done
✔ RELEASE_NOTES.md
0.34.0 — This release adds the opt-in `swiftide-agents-contrib` crate and Perl support for tree-sitter. It also fixes duplicated text in OpenAI streaming and resolves security advisories, which removes AWS Console login from the Bedrock client.
highlights (2)
• Agent tools, hooks and executors in `swiftide-agents-contrib` [#1207]
• Perl support for tree-sitter [#1198]
breaking changes (1)
• AWS Console login removed from the Bedrock client [#1215]
fixes (1)
• Repeated text in OpenAI streaming [#1216]
```
Everything these notes describe, `swiftide-agents-contrib`, Perl tree-sitter support, the Bedrock change (#1215) and the streaming fix (#1216), shipped in 0.34.
## Gotchas
* The notes are a draft. Check each entry against `git show` before you approve.
* `--responses-api` failed against the gateway used for this run, because the gateway streams Responses events that `async-openai` cannot parse. Use it with OpenAI itself.
* `--trace` is untested against a live Langfuse; check that traces arrive before relying on them.
## Concept pages
* [Tools](/agents/tools/) and [Executors](/agents/executors/)
* [System prompts](/agents/system-prompts/)
* [Hooks](/agents/hooks/) and [Streaming](/agents/streaming/)
* [Stopping and structured results](/agents/stopping-and-structured-results/) and [Structured output](/models/structured-output/)
* [Human in the loop](/agents/human-in-the-loop/)
* [MCP](/agents/mcp/)
* [Langfuse](/observability/langfuse/)
## Reference
* [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html)
* [`AgentBuilder`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html)
* [`DefaultContext`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html)
* [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html)
* [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html)
* [`ApprovalRequired`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.ApprovalRequired.html)
* [`StopWithArgs`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.StopWithArgs.html)
* [`McpToolbox`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/mcp/struct.McpToolbox.html)
* [`ToolFeedback`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.ToolFeedback.html)
* [`ToolExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolExecutor.html)
* [`AgentContext`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html)
* [`ToolBox`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolBox.html)
* [`Command`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.Command.html)
* [`CommandOutput`](https://docs.rs/swiftide/0.34/swiftide/traits/struct.CommandOutput.html)
* [`ToolOutput`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ToolOutput.html)
* [`Prompt`](https://docs.rs/swiftide/0.34/swiftide/prompt/struct.Prompt.html)
* [`tool`](https://docs.rs/swiftide/0.34/swiftide/attr.tool.html) and [`Tool`](https://docs.rs/swiftide/0.34/swiftide/derive.Tool.html) macros
* [`OpenAI`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/type.OpenAI.html)
* [`LangfuseLayer`](https://docs.rs/swiftide/0.34/swiftide/langfuse/struct.LangfuseLayer.html)
## Next
[Workspace architect](/showcases/workspace-architect/)
# Workspace architect
> A typed task graph that runs one agent per crate of a Cargo workspace in parallel, joins their reports, drafts an ARCHITECTURE.md and writes it after your approval.
This showcase gives you a command-line interface (CLI) that drafts an `ARCHITECTURE.md` for any Cargo workspace. A task graph discovers the crates, runs one agent per crate in parallel, joins their reports, has a model write the document, and pauses until you approve it. Only then does it write the file.
The crate lives in the swiftide repository under `examples/workspace-architect`. Clone the repository and run the commands below from its root:
```sh
git clone https://github.com/bosun-ai/swiftide
cd swiftide
```
## Prerequisites
* Rust 1.97.1, which the swiftide workspace pins in `rust-toolchain.toml`.
* `OPENAI_API_KEY`. The CLI checks for it before any agent starts. `OPENAI_BASE_URL` points it at any OpenAI-compatible endpoint, and `OPENAI_MODEL` picks the model (default `gpt-5.4`). The CLI also reads `.env` in the current directory, but not in parent directories.
* `cargo`, `sh`, `find`, `sort` and `grep` on your `PATH`. The discover step runs `cargo metadata`. The agent tools run `find` piped into `sort` through a shell, and `grep`.
* The feature flags `swiftide-agents`, `swiftide-tasks` and `openai`, plus the default `macros`. `swiftide-agents` already turns on `swiftide-tasks`.
* A direct `schemars` dependency with the `derive` feature, for the `JsonSchema` derive on `CrateReport`.
* Swiftide 0.34 or newer: the model client sets the endpoint with `api_base` and `api_key` on the `OpenAI` builder, which arrived in 0.34.
TLS needs no setup, because swiftide’s HTTP clients bring their own rustls crypto provider ([TLS](/integrations/overview/#tls)). No Docker or database is needed.
## Task graph
The module comment in `graph.rs` sketches the graph:
```text
discover ──fan-out──▶ analyse × N (parallel) ──▶ join (builds prompt) ──▶ render ──▶ review ──▶ write
│ ▲
pause ◀──┘ └── resume
```
The task is a `Task`: a workspace root goes in, the written file comes out. It has six task nodes, registered first:
examples/workspace-architect/src/graph.rs
```rust
let discover = task.register_node_fn({
let progress = progress.clone();
move |root: &WorkspaceRoot| {
let members = workspace::discover(root, settings.selection)?;
progress.step("discover", format_args!("{members} in {root}"));
Ok::<_, workspace::DiscoveryError>(members)
}
});
let analyse = task.register_node(CrateAnalyst::new(llm.clone(), progress.clone()));
let join = task.register_node_fn({
let progress = progress.clone();
move |summaries: &JoinInput| {
progress.joined(summaries.len());
architecture_prompt(summaries)
}
});
// `Arc` is a task node out of the box: `Prompt` in, `String` out.
let render = task.register_node(Arc::new(llm.clone()) as Arc);
let review = task.register_node(ApprovalGate::new(settings.pre_approved, progress.clone()));
let write = task.register_node_async_fn({
let output = settings.output;
let progress = progress.clone();
move |draft: &ArchitectureDraft| {
let output = output.clone();
let progress = progress.clone();
Box::pin(async move {
let written = draft.write_to(&output).await?;
progress.step(
"write",
format_args!("{} ({} bytes)", written.path.display(), written.bytes),
);
Ok::<_, std::io::Error>(written)
})
}
});
```
* `discover` and `join` are sync closures registered with `register_node_fn`. A closure takes `&I` and returns `Result`.
* `analyse` is a struct that implements `TaskNode`, registered with `register_node`.
* `render` is the model client itself, registered as `Arc`.
* `review` is another custom `TaskNode`, the approval gate.
* `write` is an async closure registered with `register_node_async_fn`.
`task.starts_with(discover)` sets the entry node. The excerpts below show most transitions. Three short ones in `graph.rs` sit between the excerpts: `starts_with`, the `render` to `review` transition covered under Join and render, and `task.register_transition(write, task.transitions_to_finish())`, which ends the run.
## Per-crate agents
Every fan-out branch evaluates the same `analyse` node with a different crate. Each evaluation builds a fresh agent:
examples/workspace-architect/src/analyst.rs
```rust
fn agent_for(
&self,
member: &WorkspaceCrate,
analysis: Analysis,
) -> Result {
// Every command the tools run executes inside the crate directory.
let executor: Arc = Arc::new(LocalExecutor::new(&member.dir));
let tools: [Box; 4] = [
list_files(),
read_file(),
grep(),
summary_stop_tool().into(),
];
Agent::builder()
.name(member.name.as_str())
.llm(&self.llm)
.context(DefaultContext::from_executor(executor))
.system_prompt(SYSTEM_PROMPT)
// Replace the default `stop` tool with one that requires a `CrateReport`.
.without_default_stop_tool()
.tools(tools)
.before_tool(move |_agent, tool_call| {
analysis.tool_called(tool_call.name(), tool_call.args().unwrap_or_default());
Box::pin(async { Ok(()) })
})
.limit(MAX_TURNS)
.build()
}
```
`LocalExecutor::new(&member.dir)` makes the crate directory the working directory for every command the tools run. The context wraps that tool executor with `DefaultContext::from_executor`. Branches share nothing, so they can run at the same time.
`without_default_stop_tool()` comes before `tools(..)`, because `tools` merges in the default `stop` tool when you call it. The `before_tool` hook receives `&mut Agent` and the tool call. It counts the call, and with `--verbose` it also prints a `tool` line. `.limit(MAX_TURNS)` caps one query at 30 completions.
The three tools are read-only and run their commands through the agent’s executor:
examples/workspace-architect/src/tools.rs
```rust
#[swiftide::tool(
description = "List the files of the crate (relative paths, build output excluded).",
param(
name = "dir",
description = "Directory relative to the crate root, e.g. `.` or `src/tools`"
)
)]
pub async fn list_files(context: &dyn AgentContext, dir: &str) -> Result {
let Some(dir) = CratePath::parse(dir) else {
return Ok(CratePath::rejected(dir));
};
let files = run(
context,
format!(
"find {} -type f -not -path '*/target/*' -not -path '*/.git/*' | sort",
dir.quoted()
),
)
.await?;
Ok(first_lines(&files, MAX_LISTED_FILES, "files").into())
}
#[swiftide::tool(
description = "Read a file of the crate, 250 lines at a time.",
param(name = "path", description = "File path relative to the crate root"),
param(
name = "start_line",
description = "1-based line to start from; null to start at the top"
)
)]
pub async fn read_file(
context: &dyn AgentContext,
path: &str,
start_line: &Option,
) -> Result {
let Some(path) = CratePath::parse(path) else {
return Ok(CratePath::rejected(path));
};
let content = context
.executor()
.exec_cmd(&Command::read_file(path.as_path()))
.await?
.into_string_lossy();
Ok(line_window(&content, start_line.unwrap_or(1)).into())
}
#[swiftide::tool(
description = "Search the crate's files with an extended regular expression (grep -E).",
param(name = "pattern", description = "Regex, e.g. `pub (trait|struct) `"),
param(
name = "dir",
description = "Directory relative to the crate root to search in, e.g. `.` or `src`"
)
)]
pub async fn grep(
context: &dyn AgentContext,
pattern: &str,
dir: &str,
) -> Result {
let Some(dir) = CratePath::parse(dir) else {
return Ok(CratePath::rejected(dir));
};
let matches = run(
context,
format!(
"grep -rnIE --exclude-dir=target --exclude-dir=.git -e {} -- {}",
shell_quote(pattern),
dir.quoted()
),
)
.await?;
if matches.trim().is_empty() {
return Ok("No matches.".into());
}
Ok(first_lines(&matches, MAX_GREP_MATCHES, "matches").into())
}
```
`#[swiftide::tool]` turns each async function into a constructor that returns `Box`. The `description` and `param` attributes set what the model sees. `CratePath::parse` rejects absolute paths and `..` before any command runs.
### Structured report
The agent finishes by calling a custom `stop` tool whose arguments must match `CrateReport`:
examples/workspace-architect/src/summary.rs
```rust
/// What an analyst agent reports about one crate when it calls `stop`.
///
/// The doc comments become field descriptions in the JSON schema the model sees, so they double
/// as instructions.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CrateReport {
/// Two to four sentences: the problem this crate solves and the role it plays in the
/// workspace. Describe responsibilities, not files.
pub purpose: String,
/// Four to eight of the most important public items a newcomer must know (traits, structs,
/// enums, macros or functions), each formatted as "`Name`: what it is for". Use names exactly
/// as they appear in the source.
pub key_types: Vec,
/// One entry per workspace crate listed as a dependency in the brief. Empty if the brief
/// lists none.
pub depends_on: Vec,
/// One to three sentences on feature flags, extension points (traits meant to be
/// implemented) and non-obvious design decisions.
pub notes: String,
}
/// How the documented crate uses one of its workspace dependencies.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DependencyUsage {
/// The dependency's crate name, exactly as listed in the brief.
pub crate_name: String,
/// What the documented crate uses it for.
pub usage: String,
}
/// A `stop` tool whose arguments must match [`CrateReport`].
pub fn summary_stop_tool() -> StopWithArgs {
StopWithArgs::with_parameters_schema(schema_for!(CrateReport))
}
```
`StopWithArgs::with_parameters_schema` works as in the [release notes agent](/showcases/release-notes-agent/#structured-stop), here with the schema of `CrateReport`. The doc comments become field descriptions in that schema, so they double as instructions. When the model calls `stop`, the agent stops with `StopReason::RequestedByTool(_, Some(arguments))`, and the arguments are the report.
### Task node
`CrateAnalyst` implements `TaskNode` by running the agent and reading the report from its stop reason:
examples/workspace-architect/src/analyst.rs
```rust
/// How often an agent is told to fix a missing or malformed report before the crate fails.
const MAX_CORRECTIONS: usize = 2;
#[async_trait]
impl TaskNode for CrateAnalyst {
type Input = WorkspaceCrate;
type Output = CrateSummary;
type Error = AnalystError;
async fn evaluate(
&self,
_node_id: &DynNodeId,
member: &WorkspaceCrate,
) -> Result {
self.analyse(member).await.map_err(|cause| AnalystError {
crate_name: member.name.clone(),
cause,
})
}
}
impl CrateAnalyst {
async fn analyse(&self, member: &WorkspaceCrate) -> Result {
let analysis = self.progress.analysis(&member.name);
let mut agent = self.agent_for(member, analysis.clone())?;
agent.query(brief(member)).await?;
// Models occasionally answer in prose or nest the `stop` arguments; say what went wrong.
let mut corrections = 0;
let report = loop {
match submitted_report(&agent) {
Ok(report) => break report,
Err(problem) if corrections < MAX_CORRECTIONS => {
corrections += 1;
analysis.corrected(&problem);
agent.query(problem.correction()).await?;
}
Err(problem) => return Err(problem.into()),
}
};
analysis.finished(&report);
Ok(report.reconciled_with(member))
}
}
/// The report the agent passed to `stop`, checked against the `CrateReport` schema.
fn submitted_report(agent: &Agent) -> Result {
match agent.stop_reason() {
Some(StopReason::RequestedByTool(_, Some(arguments))) => {
Ok(CrateReport::deserialize(arguments)?)
}
other => Err(ReportProblem::Missing {
stop_reason: other.cloned(),
}),
}
}
```
`#[serde(deny_unknown_fields)]` matters here. Some models nest the report under an `arguments` key, and serde rejects that with ``unknown field `arguments` ``. The node sends the error back with `agent.query(..)` and reads the stop reason again, at most `MAX_CORRECTIONS` (2) times. `reconciled_with` then replaces the model’s dependency list with the one from `cargo metadata`, keeping the model’s usage notes.
## Parallel fan-out
The `discover` transition turns the crate list into one branch per crate:
examples/workspace-architect/src/graph.rs
```rust
// One branch per crate, all evaluating the same analyst node with a different input.
task.register_transition(discover, move |members: WorkspaceMembers| {
let mut crates = members.crates.into_iter();
let Some(first) = crates.next() else {
return Transition::error("the workspace has no crates to document");
};
crates
.fold(Transition::fan_out(&analyse, first), |fan_out, member| {
fan_out.and(&analyse, member)
})
.concurrency_model(ConcurrencyModel::Parallel)
.join_with(join.join())
})?;
```
`Transition::fan_out(&analyse, first)` creates the first branch, and `.and(&analyse, member)` adds one per remaining crate. `fan_out` needs a first target, so an empty crate list fails the run with `Transition::error` and “the workspace has no crates to document”. By default only publishable crates are selected. A workspace whose members all set `publish = false` fails the same way unless you pass `--all-members`. The default concurrency model is `ConcurrencyModel::Sequential`, so `.concurrency_model(ConcurrencyModel::Parallel)` is what runs the branches at the same time. `.join_with(join.join())` turns the `FanOutTransition` into a `Transition` and names the join node that waits for the group.
The join line in the captured run, with `claude-sonnet-5-5` as the model, shows the effect: `230s of agent work in 41s wall-clock (5.7x)`.
## Join and render
The graph registers one transition for the `analyse` node. Every fan-out branch evaluates that node, so all branches use the same transition to the join. The join then hands a prompt to `render`:
examples/workspace-architect/src/graph.rs
```rust
// Each branch hands its `CrateSummary` to the join; the join runs once all have arrived.
task.register_transition(analyse, join.join())?;
task.register_transition(join, {
let progress = progress.clone();
move |prompt| {
progress.step("render", "drafting ARCHITECTURE.md");
render.transitions_with(prompt)
}
})?;
```
Fan-out branches cannot finish the task themselves. They have to reach the join. The join runs once every branch has delivered its `CrateSummary`. `render.transitions_with(prompt)` sends the result on to the render node. The join closure calls `architecture_prompt`:
examples/workspace-architect/src/render.rs
```rust
/// Tera template for the document. The crate summaries are the model's only source of truth.
const ARCHITECTURE_TEMPLATE: &str = r#"You are writing ARCHITECTURE.md for a Rust Cargo workspace. The readers are Rust developers who are new to the codebase and want a mental map before they read code.
Below are per-crate summaries written by agents that read each crate's source. They are your only source of truth: do not invent crates, items, flows or relationships that are not supported by them. Crates are listed foundations first.
{% for crate in crates %}
### {{ crate.name }}
Purpose: {{ crate.report.purpose }}
Key items:
{% for item in crate.report.key_types %}- {{ item }}
{% endfor %}Depends on:
{% for dep in crate.dependencies %}- {{ dep.name }}{% if dep.optional %} (optional){% endif %}: {{ dep.usage }}
{% else %}- no workspace crates
{% endfor %}Notes: {{ crate.report.notes }}
{% endfor %}
Write the document in GitHub-flavoured Markdown with exactly these sections:
# Architecture
One short paragraph: what the workspace is for and how it is split into crates.
## Bird's-eye view
Two or three paragraphs describing the layers (for example foundation, building blocks, integrations, facade) and which crate a user of the project depends on.
## Crate map
A table with the columns `Crate`, `Responsibility` and `Start reading at`. List every crate above, in the same order. `Start reading at` names one to three key items in backticks.
## Dependency graph
Copy this diagram verbatim (it was generated from `cargo metadata`; dotted edges are optional dependencies):
{{ dependency_graph }}
After the diagram, three to five bullets on what the layering means for contributors (which crate to change for what, and what a change ripples into). Stick to what the graph and the summaries show; no generic advice.
## Data flow
The two to four most important things a user of the workspace does with it, each under its own `###` heading. For each, numbered steps from input to output that name the crates and items involved, in backticks. Skip internal plumbing.
## Extension points
One bullet per kind of extension, phrased as "To add , implement `Trait` (`crate`)" or "use `macro` (`crate`)". Only traits the summaries describe as meant to be implemented.
## Cross-cutting concerns
Short, specific bullets on feature flags, observability, error handling and test utilities. Leave out anything you cannot name concretely.
Rules: be concrete, precise and terse; prefer short sentences. Put crate and item names in backticks. Refer to an item by its name and, where useful, its crate in parentheses, e.g. `Agent` (`my-crate`); do not make up module paths. Never mention the summaries, the agents or these instructions in the document. Output only the Markdown document, without wrapping it in a code fence."#;
/// Builds the prompt for the render node from every branch that reached the join.
pub fn architecture_prompt(
summaries: &JoinInput,
) -> Result {
let crates = serde_json::to_value(summaries.iter().collect::>())?;
Ok(Prompt::from(ARCHITECTURE_TEMPLATE)
.with_context_value("crates", crates)
.with_context_value("dependency_graph", dependency_graph(summaries)))
}
```
`JoinInput` gives you `iter()`, `len()` and `is_empty()`. The prompt is a Tera template with two context values: `crates`, the serialized summaries, and `dependency_graph`. The Mermaid dependency graph is generated from `cargo metadata`, not by the model, and the template tells the model to copy it verbatim.
The `render` to `review` transition wraps the model’s `String` in an `ArchitectureDraft`. `ArchitectureDraft::from_model_output` strips a code fence around the whole document, in case the model adds one.
## Pause and resume
The review transition either continues to `write` or pauses the task:
examples/workspace-architect/src/graph.rs
```rust
task.register_transition(review, move |decision| match decision {
Review::Approved(draft) => write.transitions_with(draft).into(),
Review::AwaitingApproval => Transition::pause(),
})?;
```
`Transition::pause()` pauses the current branch. A paused branch keeps its node, and on `resume()` that node evaluates again with the same input. The approval gate is built around that:
examples/workspace-architect/src/approval.rs
```rust
/// Lets a draft through once it is approved, and otherwise keeps it for review.
///
/// The graph pauses the task when the gate answers [`Review::AwaitingApproval`]. A paused branch
/// re-runs its node on `resume()`, so after [`ApprovalGate::approve`] the same draft passes
/// through. The gate is cheap to evaluate, which is why the pause sits here and not on the
/// expensive render node.
#[derive(Debug, Clone)]
pub struct ApprovalGate {
state: Arc>,
progress: Progress,
}
#[derive(Debug, Default)]
struct GateState {
approved: bool,
pending: Option,
}
impl ApprovalGate {
pub fn new(pre_approved: bool, progress: Progress) -> Self {
let state = GateState {
approved: pre_approved,
pending: None,
};
Self {
state: Arc::new(Mutex::new(state)),
progress,
}
}
/// The draft waiting for a decision, if any.
pub fn pending_draft(&self) -> Option {
self.state().pending.clone()
}
pub fn approve(&self) {
self.state().approved = true;
}
fn state(&self) -> MutexGuard<'_, GateState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
}
#[async_trait]
impl TaskNode for ApprovalGate {
type Input = ArchitectureDraft;
type Output = Review;
type Error = Infallible;
async fn evaluate(
&self,
_node_id: &DynNodeId,
draft: &ArchitectureDraft,
) -> Result {
let mut state = self.state();
if state.approved {
state.pending = None;
self.progress.step("review", "draft approved");
return Ok(Review::Approved(draft.clone()));
}
state.pending = Some(draft.clone());
self.progress.step(
"review",
format_args!(
"draft ready ({} bytes), waiting for approval",
draft.as_str().len()
),
);
Ok(Review::AwaitingApproval)
}
}
```
The first evaluation stores the draft and answers `Review::AwaitingApproval`. After `approve()`, the second evaluation lets the same draft through. Putting the pause on `render` would call the model a second time.
The CLI drives the loop:
examples/workspace-architect/src/main.rs
```rust
let mut outcome = task.run(root).await?;
loop {
match outcome {
TaskRunOutcome::Completed(written) => return Ok(Some(written)),
TaskRunOutcome::Paused => {
if !approve_paused_draft(&task)? {
return Ok(None);
}
outcome = task.resume().await?;
}
}
}
```
`run` returns `TaskRunOutcome::Paused` instead of an output. `current_nodes::()` returns the paused gate as `CurrentNodes::One`, so the CLI can show its draft and call `approve()` before `task.resume()`.
## Commands
From the swiftide repository root:
```sh
export OPENAI_API_KEY=sk-...
cargo run -p workspace-architect
```
To document another workspace without the approval pause:
```sh
cargo run -p workspace-architect -- --workspace ../other-repo --yes
```
| Flag | Default | Description |
| -------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
| `--workspace ` | `.` | Any directory inside the workspace to document |
| `--output ` | `ARCHITECTURE.md` | Output file, relative to the workspace root |
| `--yes` | off | Write the draft without pausing for approval |
| `--all-members` | off | Also document unpublished members such as examples |
| `--model ` | `gpt-5.4` | Model for the analysts and the writer (env `OPENAI_MODEL`) |
| `--responses-api` | off | Use the OpenAI Responses application programming interface (API) instead of Chat Completions |
| `--verbose` | off | Print every tool call and each agent’s raw report |
Swiftide’s tracing is off unless `WORKSPACE_ARCHITECT_LOG` is set, for example to `swiftide=debug`. The CLI ignores `RUST_LOG`, so a global cargo config that sets it does not bury the draft. The client sets `max_completion_tokens` to 16,384 through `Options`, because some gateways cap an unset limit at 4,096 tokens and the document would be cut off.
The tests run the whole graph offline with a scripted model, including the pause, approval and `resume()`:
```sh
cargo test -p workspace-architect
```
## Output
This run on the swiftide repository used `claude-sonnet-5-5` for all ten analysts and the writer, through an OpenAI-compatible gateway (Bifrost in front of AWS Bedrock) with Chat Completions. The run answered `y` at the prompt and passed `--output target/ARCHITECTURE.md`. Trimmed from `OUTPUT.md`:
```text
[ 0.0s] model claude-sonnet-5-5 via Chat Completions at http://127.0.0.1:8080/v1
[ 0.0s] discover 10 crates (7 other members skipped) in /Users/timonv/projects/swiftide-wt-docs-showcases
[ 0.0s] analyse started swiftide-core
[ 0.0s] analyse started swiftide-langfuse
...
[ 14.0s] analyse finished swiftide-langfuse in 14.0s (6 tool calls)
[ 14.3s] analyse finished swiftide-macros in 14.3s (6 tool calls)
...
[ 33.9s] analyse retry swiftide-core: the `stop` arguments do not match the schema: unknown field `arguments`, expected one of `purpose`, `key_types`, `depends_on`, `notes`
[ 40.5s] analyse finished swiftide-core in 40.5s (15 tool calls)
[ 40.5s] join 10 summaries; 230s of agent work in 41s wall-clock (5.7x)
[ 40.5s] render drafting ARCHITECTURE.md
[ 68.8s] review draft ready (11210 bytes), waiting for approval
...
[ 68.8s] review draft approved
[ 68.8s] write /Users/timonv/projects/swiftide-wt-docs-showcases/target/ARCHITECTURE.md (11210 bytes)
Wrote /Users/timonv/projects/swiftide-wt-docs-showcases/target/ARCHITECTURE.md (11210 bytes) in 68.8s.
```
A second run with `--verbose` took 217s of agent work in 33s (6.5x). Writing the document took about 28 seconds in both runs. A few lines of the generated crate map:
```text
| Crate | Responsibility | Start reading at |
|---|---|---|
| `swiftide-core` | Shared traits, data types and errors for indexing, querying, chat completion, tools and agent context. No integrations. | `Node`, `Query`, `ChatCompletion` |
...
| `swiftide-tasks` | In-process typed task graph engine with transitions, fan-out/join, pause and resume, and adapters for closures and core traits. | `Task`, `TaskNode`, `Transition` |
| `swiftide-agents` | LLM agent loop over chat completions and tool calls, with hooks, a default context, a local executor and optional MCP toolboxes. | `Agent`, `AgentBuilder`, `DefaultContext` |
```
The run documents a swiftide checkout that includes `swiftide-agents-contrib`, which shipped in 0.34.
## Gotchas
* Retries cost time. In the approved run a retry made `swiftide-core` the slowest branch, and the join waits for it.
* Each correction is a new `query`, so it gets a fresh turn limit. One crate can use up to 90 completions.
* See [Turn limit and nudges](/showcases/release-notes-agent/#turn-limit-and-nudges) in the release notes agent for how a turn limit and a missing tool call share a stop reason. This node treats both as a missing report.
* `LocalExecutor` sets a working directory. It does not sandbox. The tools reject absolute paths and `..`, but symlinks inside a crate are followed. Treat the checks as a guard rail.
## Concept pages
* [Tasks overview](/tasks/overview/)
* [Nodes and transitions](/tasks/nodes-and-transitions/)
* [Fan-out and join](/tasks/fan-out-and-join/)
* [Pause and resume](/tasks/pause-and-resume/)
* [Agents as nodes](/tasks/agents-as-nodes/)
* [Built-in nodes](/tasks/built-in-nodes/)
* [Executors](/agents/executors/)
* [Stopping and structured results](/agents/stopping-and-structured-results/)
* [Tools](/agents/tools/)
## Reference
* [`Task`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html)
* [`TaskNode`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.TaskNode.html)
* [`Transition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Transition.html)
* [`FanOutTransition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.FanOutTransition.html)
* [`ConcurrencyModel`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.ConcurrencyModel.html)
* [`JoinInput`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.JoinInput.html)
* [`JoinTarget`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.JoinTarget.html)
* [`TaskRunOutcome`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskRunOutcome.html)
* [`CurrentNodes`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.CurrentNodes.html)
* [`TaskError`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskError.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)
* [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html)
* [`StopWithArgs`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.StopWithArgs.html)
* [`SimplePrompt`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.SimplePrompt.html)
* [`Options`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/struct.Options.html)
* [`#[swiftide::tool]`](https://docs.rs/swiftide/0.34/swiftide/attr.tool.html)
## Next
[Docs Q\&A with evaluation](/showcases/docs-qa/)