Synwire
Synwire is an async-first Rust framework for building LLM-powered applications, designed around trait-based composition and zero unsafe code.
Key features
- Trait-based architecture -- swap providers, vector stores, and tools via trait objects
- Async-first -- all I/O uses
async/awaitwith Tokio - Graph orchestration -- build stateful agent workflows with
StateGraph - Type-safe macros --
#[tool]and#[derive(State)]for ergonomic definitions - Comprehensive testing --
FakeChatModelandFakeEmbeddingsfor offline testing - Zero unsafe code --
#![forbid(unsafe_code)]in core crates
Quick example
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_core::messages::Message;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let model = FakeChatModel::new(vec!["Hello from Synwire!".into()]);
let messages = vec![Message::human("Hi")];
let result = model.invoke(&messages, None).await?;
println!("{}", result.message.content().as_text());
Ok(())
}
Crate overview
| Crate | Description |
|---|---|
synwire-core | Core traits: BaseChatModel, Embeddings, VectorStore, Tool, RunnableCore |
synwire-orchestrator | Graph-based orchestration: StateGraph, CompiledGraph, channels |
synwire-checkpoint | Checkpoint traits and in-memory implementation |
synwire-checkpoint-sqlite | SQLite checkpoint backend |
synwire-llm-openai | OpenAI provider (ChatOpenAI, OpenAIEmbeddings) |
synwire-llm-ollama | Ollama provider (ChatOllama, OllamaEmbeddings) |
synwire-derive | Procedural macros (#[tool], #[derive(State)]) |
synwire-test-utils | Fake models, proptest strategies, fixture builders |
synwire | Convenience re-exports, caches, text splitters, few-shot prompts |
Navigation
- Getting Started -- step-by-step tutorials from first chat to graph agents
- How-To Guides -- task-focused recipes for common operations
- Explanation -- design rationale and architecture decisions
- Reference -- glossary, error guide, feature flags
- Contributing -- setup, style guide
First Chat
This tutorial walks through installing Synwire and making your first chat model invocation.
Add dependencies
Add the following to your Cargo.toml:
[dependencies]
synwire-core = "0.1"
tokio = { version = "1", features = ["full"] }
For testing without API keys, synwire-core includes FakeChatModel. To use a real provider, add synwire-llm-openai or synwire-llm-ollama.
Using FakeChatModel (no API key)
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_core::messages::Message;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// FakeChatModel returns pre-configured responses in order
let model = FakeChatModel::new(vec![
"I'm doing well, thanks for asking!".into(),
]);
let messages = vec![Message::human("How are you?")];
let result = model.invoke(&messages, None).await?;
println!("{}", result.message.content().as_text());
// Output: I'm doing well, thanks for asking!
Ok(())
}
Using ChatOpenAI
Add synwire-llm-openai to your dependencies:
[dependencies]
synwire-llm-openai = "0.1"
use synwire_core::language_models::BaseChatModel;
use synwire_core::messages::Message;
use synwire_llm_openai::ChatOpenAI;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let model = ChatOpenAI::builder()
.model("gpt-4o-mini")
.api_key_env("OPENAI_API_KEY")
.build()?;
let messages = vec![Message::human("What is Rust?")];
let result = model.invoke(&messages, None).await?;
println!("{}", result.message.content().as_text());
Ok(())
}
Multi-turn conversations
All chat models accept a slice of Message values. Build a conversation by including system, human, and AI messages:
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_core::messages::Message;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let model = FakeChatModel::new(vec![
"Paris is the capital of France.".into(),
]);
let messages = vec![
Message::system("You are a helpful geography assistant."),
Message::human("What is the capital of France?"),
];
let result = model.invoke(&messages, None).await?;
println!("{}", result.message.content().as_text());
Ok(())
}
Batch invocation
Call batch to invoke on multiple inputs:
let inputs = vec![
vec![Message::human("Question 1")],
vec![Message::human("Question 2")],
];
let results = model.batch(&inputs, None).await?;
for r in &results {
println!("{}", r.message.content().as_text());
}
Key types
| Type | Purpose |
|---|---|
BaseChatModel | Trait for all chat models |
Message | Conversation message (human, AI, system, tool) |
ChatResult | Model response including the AI message |
FakeChatModel | Deterministic model for testing |
RunnableConfig | Optional configuration (callbacks, tags, metadata) |
Next steps
- Prompt Chains -- compose templates with models
- Streaming -- stream responses token by token
See also
- Local Inference with Ollama — run the same example with a local model, no API key required
- LLM Providers Explanation — choosing and swapping providers
Background: Zero-shot Prompting — how LLMs respond to instructions without examples.
Local Inference with Ollama
Ollama lets you run large language models on your own machine — no API key, no data leaving the network boundary. synwire-llm-ollama implements the same BaseChatModel and Embeddings traits as the OpenAI provider, so switching is a one-line change.
When to use Ollama:
- Privacy-sensitive workloads (data must not leave the machine)
- Air-gapped environments
- Development and testing without API costs
- Experimenting with open-weight models (Llama 3, Mistral, Gemma, Phi)
📖 Rust note: A trait is Rust's equivalent of an interface.
BaseChatModelis a trait — because bothChatOllamaandChatOpenAIimplement it, you can store either behind aBox<dyn BaseChatModel>and swap them without changing any other code.
Prerequisites
- Install Ollama from https://ollama.com
- Pull a model:
ollama pull llama3.2
- Confirm it is running:
ollama run llama3.2 "hello"
Ollama listens on http://localhost:11434 by default.
Add the dependency
[dependencies]
synwire-llm-ollama = "0.1"
tokio = { version = "1", features = ["full"] }
Basic invoke
use synwire_llm_ollama::ChatOllama; use synwire_core::language_models::chat::BaseChatModel; #[tokio::main] async fn main() -> anyhow::Result<()> { let model = ChatOllama::builder() .model("llama3.2") .build()?; let result = model.invoke("What is the Rust borrow checker?").await?; println!("{}", result.content); Ok(()) }
Streaming
📖 Rust note:
async fnand.awaitlet this code run concurrently without blocking a thread.StreamExt::next().awaityields each chunk as the model generates it — you see output appear progressively rather than waiting for the full response.
use synwire_llm_ollama::ChatOllama; use synwire_core::language_models::chat::BaseChatModel; use futures_util::StreamExt; #[tokio::main] async fn main() -> anyhow::Result<()> { let model = ChatOllama::builder() .model("llama3.2") .build()?; let mut stream = model.stream("Explain ownership in Rust step by step.").await?; while let Some(chunk) = stream.next().await { print!("{}", chunk?.content); } println!(); Ok(()) }
Local RAG with OllamaEmbeddings
Use a local embedding model so that retrieval-augmented generation never sends data to an external API:
ollama pull nomic-embed-text
use synwire_llm_ollama::{ChatOllama, OllamaEmbeddings}; use synwire_core::embeddings::Embeddings; use synwire_core::language_models::chat::BaseChatModel; #[tokio::main] async fn main() -> anyhow::Result<()> { let embeddings = OllamaEmbeddings::builder() .model("nomic-embed-text") .build()?; // Embed your documents let docs = vec![ "Rust ownership means each value has exactly one owner.".to_string(), "The borrow checker enforces ownership rules at compile time.".to_string(), ]; let vectors = embeddings.embed_documents(docs).await?; println!("Embedded {} documents, dimension {}", vectors.len(), vectors[0].len()); // Embed a query let query_vec = embeddings.embed_query("what is ownership?").await?; println!("Query vector dimension: {}", query_vec.len()); // Use vectors with your vector store, then answer with the chat model: let model = ChatOllama::builder().model("llama3.2").build()?; let answer = model.invoke("Given context about Rust ownership, explain it simply.").await?; println!("{}", answer.content); Ok(()) }
See Getting Started: RAG for a complete retrieval-augmented generation example.
Swapping from OpenAI to Ollama
Store the model as Box<dyn BaseChatModel> — swap by changing the constructor:
#![allow(unused)] fn main() { use synwire_core::language_models::chat::BaseChatModel; fn build_model() -> anyhow::Result<Box<dyn BaseChatModel>> { if std::env::var("USE_LOCAL").is_ok() { // Local: no API key required Ok(Box::new( synwire_llm_ollama::ChatOllama::builder().model("llama3.2").build()? )) } else { // Cloud: reads OPENAI_API_KEY from environment Ok(Box::new( synwire_llm_openai::ChatOpenAI::builder() .model("gpt-4o") .api_key_env("OPENAI_API_KEY") .build()? )) } } }
All downstream code that calls model.invoke(...) or model.stream(...) is unchanged.
Builder options
| Method | Default | Description |
|---|---|---|
.model(name) | — | Required. Any model pulled via ollama pull |
.base_url(url) | http://localhost:11434 | Ollama server address |
.temperature(f32) | model default | Sampling temperature |
.top_k(u32) | model default | Top-k sampling |
.top_p(f32) | model default | Top-p (nucleus) sampling |
.num_predict(i32) | model default | Max tokens to generate (-1 for unlimited) |
.timeout(Duration) | 5 minutes | Request timeout |
See also
- Getting Started: First Chat — the same example using OpenAI
- Getting Started: RAG — full retrieval pipeline (add
OllamaEmbeddingsfor local RAG) - LLM Providers Explanation — choosing and swapping providers
- How-To: Switch Provider
Prompt Chains
This tutorial shows how to use PromptTemplate and compose chains of runnables.
Prompt templates
PromptTemplate formats strings with named variables:
use std::collections::HashMap;
use synwire_core::prompts::PromptTemplate;
let template = PromptTemplate::new(
"Tell me a joke about {topic}",
vec!["topic".into()],
);
let mut vars = HashMap::new();
vars.insert("topic".into(), "Rust programming".into());
let prompt = template.format(&vars)?;
// "Tell me a joke about Rust programming"
Template + Model + Parser
Compose a prompt template with a model and output parser:
use std::collections::HashMap;
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_core::messages::Message;
use synwire_core::output_parsers::{OutputParser, StrOutputParser};
use synwire_core::prompts::PromptTemplate;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Create a template
let template = PromptTemplate::new(
"Explain {concept} in one sentence.",
vec!["concept".into()],
);
// 2. Format the prompt
let mut vars = HashMap::new();
vars.insert("concept".into(), "ownership in Rust".into());
let prompt = template.format(&vars)?;
// 3. Invoke the model
let model = FakeChatModel::new(vec![
"Ownership is Rust's system for managing memory without a garbage collector.".into(),
]);
let result = model.invoke(&[Message::human(&prompt)], None).await?;
// 4. Parse the output
let parser = StrOutputParser;
let output = parser.parse(&result.message.content().as_text())?;
println!("{output}");
Ok(())
}
Chat prompt templates
For multi-message prompts, use ChatPromptTemplate:
use synwire_core::prompts::{ChatPromptTemplate, MessageTemplate};
let chat_template = ChatPromptTemplate::new(vec![
MessageTemplate::system("You are a {role}."),
MessageTemplate::human("{question}"),
]);
RunnableCore composition
All components implement RunnableCore with serde_json::Value as the universal I/O type. This enables heterogeneous chaining:
use synwire_core::runnables::{RunnableCore, RunnableLambda, pipe};
// Create a lambda runnable
let add_prefix = RunnableLambda::new(|input: serde_json::Value| {
let text = input.as_str().unwrap_or_default();
Ok(serde_json::json!(format!("Processed: {text}")))
});
Output parsers
| Parser | Output type | Use case |
|---|---|---|
StrOutputParser | String | Plain text extraction |
JsonOutputParser | serde_json::Value | JSON responses |
StructuredOutputParser | Typed T: DeserializeOwned | Structured data |
ToolsOutputParser | Vec<ToolCall> | Tool call extraction |
Next steps
Background: Prompt Chaining — the technique of decomposing a task into sequential LLM calls, each building on the previous output.
Streaming
This tutorial shows how to stream responses from chat models token by token.
Basic streaming
Every BaseChatModel provides a stream method that returns a BoxStream of ChatChunk values:
use futures_util::StreamExt;
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_core::messages::Message;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let model = FakeChatModel::new(vec!["Hello, world!".into()])
.with_chunk_size(3); // Split into 3-character chunks
let messages = vec![Message::human("Greet me")];
let mut stream = model.stream(&messages, None).await?;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if let Some(content) = &chunk.delta_content {
print!("{content}");
}
}
println!();
// Output: Hel lo, wo rld !
Ok(())
}
Streaming with OpenAI
use futures_util::StreamExt;
use synwire_core::language_models::BaseChatModel;
use synwire_core::messages::Message;
use synwire_llm_openai::ChatOpenAI;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let model = ChatOpenAI::builder()
.model("gpt-4o-mini")
.api_key_env("OPENAI_API_KEY")
.build()?;
let messages = vec![Message::human("Write a haiku about Rust")];
let mut stream = model.stream(&messages, None).await?;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if let Some(content) = &chunk.delta_content {
print!("{content}");
}
}
println!();
Ok(())
}
ChatChunk fields
| Field | Type | Description |
|---|---|---|
delta_content | Option<String> | Incremental text content |
delta_tool_calls | Vec<ToolCallChunk> | Incremental tool call data |
finish_reason | Option<String> | "stop" on the final chunk |
usage | Option<UsageMetadata> | Token usage (final chunk only) |
Collecting streamed output
To accumulate the full response:
let mut full_text = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if let Some(content) = &chunk.delta_content {
full_text.push_str(content);
}
}
Error handling during streaming
Errors can occur mid-stream. Handle them per-chunk:
while let Some(result) = stream.next().await {
match result {
Ok(chunk) => { /* process chunk */ }
Err(e) => {
eprintln!("Stream error: {e}");
break;
}
}
}
Testing streams
FakeChatModel supports configurable chunking and error injection for stream testing:
// Split into 5-char chunks, inject error after 2 chunks
let model = FakeChatModel::new(vec!["abcdefghij".into()])
.with_chunk_size(5)
.with_stream_error_after(2);
Next steps
- RAG -- retrieval-augmented generation
- Tools and Agents -- tool-using agents
RAG (Retrieval-Augmented Generation)
This tutorial builds a RAG pipeline using vector stores, embeddings, and a retriever.
Overview
A RAG pipeline:
- Splits documents into chunks
- Embeds chunks into a vector store
- Retrieves relevant chunks for a query
- Passes retrieved context to the model
Dependencies
[dependencies]
synwire-core = "0.1"
synwire = "0.1"
tokio = { version = "1", features = ["full"] }
Full example with fake models
use synwire::text_splitters::RecursiveCharacterTextSplitter;
use synwire_core::documents::Document;
use synwire_core::embeddings::FakeEmbeddings;
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_core::messages::Message;
use synwire_core::vectorstores::{InMemoryVectorStore, VectorStore};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Split documents
let splitter = RecursiveCharacterTextSplitter::new(100, 20);
let text = "Rust is a systems programming language. \
It emphasises safety and performance. \
The ownership system prevents data races.";
let chunks = splitter.split_text(text);
// 2. Create documents from chunks
let docs: Vec<Document> = chunks
.into_iter()
.map(|chunk| Document::new(chunk))
.collect();
// 3. Add to vector store
let embeddings = FakeEmbeddings::new(32);
let store = InMemoryVectorStore::new();
let _ids = store.add_documents(&docs, &embeddings).await?;
// 4. Retrieve relevant documents
let results = store
.similarity_search("What is Rust?", 2, &embeddings)
.await?;
// 5. Build context and query the model
let context: String = results
.iter()
.map(|doc| doc.page_content.as_str())
.collect::<Vec<_>>()
.join("\n");
let model = FakeChatModel::new(vec![
"Rust is a systems programming language focused on safety.".into(),
]);
let prompt = format!(
"Answer based on this context:\n{context}\n\nQuestion: What is Rust?"
);
let result = model.invoke(&[Message::human(&prompt)], None).await?;
println!("{}", result.message.content().as_text());
Ok(())
}
Cached embeddings
Use CacheBackedEmbeddings to avoid recomputing embeddings:
use std::sync::Arc;
use synwire::cache::CacheBackedEmbeddings;
use synwire_core::embeddings::FakeEmbeddings;
let embeddings = Arc::new(FakeEmbeddings::new(32));
let cached = CacheBackedEmbeddings::new(embeddings, 1000); // cache up to 1000 entries
Similarity search with scores
let scored_results = store
.similarity_search_with_score("query", 5, &embeddings)
.await?;
for (doc, score) in &scored_results {
println!("Score: {score:.4} -- {}", doc.page_content);
}
VectorStoreRetriever
For integration with the Retriever trait:
use synwire_core::retrievers::{VectorStoreRetriever, SearchType};
let retriever = VectorStoreRetriever::new(store, embeddings, SearchType::Similarity, 4);
Next steps
- Tools and Agents -- add tool-using agents
- Graph Agents -- build stateful agent graphs
See also
- Local Inference with Ollama — use
OllamaEmbeddingsfor local, private RAG with no API costs - LLM Providers Explanation — swapping embedding providers
Background: Retrieval Augmented Generation — the pattern behind grounding LLM responses in external knowledge.
Tools and Agents
This tutorial shows how to define tools and create a ReAct agent that uses them.
Defining a tool
Implement the Tool trait:
use synwire_core::tools::{Tool, ToolOutput, ToolSchema};
use synwire_core::error::SynwireError;
use synwire_core::BoxFuture;
struct Calculator;
impl Tool for Calculator {
fn name(&self) -> &str { "calculator" }
fn description(&self) -> &str { "Evaluates simple maths expressions" }
fn schema(&self) -> &ToolSchema {
// In practice, store this in a field
Box::leak(Box::new(ToolSchema {
name: "calculator".into(),
description: "Evaluates simple maths expressions".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"expression": {"type": "string"}
},
"required": ["expression"]
}),
}))
}
fn invoke(
&self,
input: serde_json::Value,
) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
Box::pin(async move {
let expr = input["expression"].as_str().unwrap_or("0");
Ok(ToolOutput {
content: format!("Result: {expr}"),
artifact: None,
})
})
}
}
Using StructuredTool
For simpler tool definitions without implementing the trait manually:
use synwire_core::tools::StructuredTool;
let tool = StructuredTool::builder()
.name("search")
.description("Searches the web")
.parameters(serde_json::json!({
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"]
}))
.func(|input| Box::pin(async move {
let query = input["query"].as_str().unwrap_or("");
Ok(synwire_core::tools::ToolOutput {
content: format!("Results for: {query}"),
artifact: None,
})
}))
.build()?;
Creating a ReAct agent
The create_react_agent function builds a graph that loops between the model and tools:
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_core::tools::Tool;
use synwire_orchestrator::prebuilt::create_react_agent;
let model: Box<dyn BaseChatModel> = Box::new(
FakeChatModel::new(vec!["The answer is 42.".into()])
);
let tools: Vec<Box<dyn Tool>> = vec![/* your tools here */];
let graph = create_react_agent(model, tools)?;
let state = serde_json::json!({
"messages": [
{"type": "human", "content": "What is 6 * 7?"}
]
});
let result = graph.invoke(state).await?;
How ReAct works
The agent graph follows this pattern:
graph TD
__start__([__start__]) --> agent
agent -->|tool_calls present| tools
agent -->|no tool_calls| __end__([__end__])
tools --> agent
- agent node: invokes the model with current messages
- tools_condition: checks if the AI response contains tool calls
- tools node: executes tool calls and appends results
- Loop continues until the model responds without tool calls
Tool name validation
Tool names must match [a-zA-Z0-9_-]{1,64}:
use synwire_core::tools::validate_tool_name;
assert!(validate_tool_name("my-tool").is_ok());
assert!(validate_tool_name("my tool").is_err()); // spaces not allowed
Next steps
- Graph Agents -- build custom graph-based agents
- Derive Macros -- use
#[tool]for ergonomic tool definitions
Background: Function Calling — how LLMs invoke structured tools. Background: ReAct — the Reason + Act pattern that most tool-using agents follow.
Graph Agents
This tutorial builds a custom graph-based agent using StateGraph from synwire-orchestrator.
Overview
A StateGraph is a directed graph where:
- Nodes are async functions that transform state
- Edges are transitions between nodes (static or conditional)
- State is a
serde_json::Valuepassed through the graph
Basic graph
use synwire_orchestrator::graph::StateGraph;
use synwire_orchestrator::constants::END;
use synwire_orchestrator::error::GraphError;
#[tokio::main]
async fn main() -> Result<(), GraphError> {
let mut graph = StateGraph::new();
// Add a node that appends a greeting
graph.add_node("greet", Box::new(|mut state| {
Box::pin(async move {
state["greeting"] = serde_json::json!("Hello!");
Ok(state)
})
}))?;
// Add a node that appends a farewell
graph.add_node("farewell", Box::new(|mut state| {
Box::pin(async move {
state["farewell"] = serde_json::json!("Goodbye!");
Ok(state)
})
}))?;
// Wire the graph: start -> greet -> farewell -> end
graph.set_entry_point("greet");
graph.add_edge("greet", "farewell");
graph.set_finish_point("farewell");
// Compile and run
let compiled = graph.compile()?;
let result = compiled.invoke(serde_json::json!({})).await?;
assert_eq!(result["greeting"], "Hello!");
assert_eq!(result["farewell"], "Goodbye!");
Ok(())
}
Conditional edges
Route execution based on state:
use std::collections::HashMap;
use synwire_orchestrator::constants::END;
// Condition function inspects state and returns a branch key
fn route(state: &serde_json::Value) -> String {
if state["score"].as_i64().unwrap_or(0) > 80 {
"pass".into()
} else {
"retry".into()
}
}
let mut mapping = HashMap::new();
mapping.insert("pass".into(), END.into());
mapping.insert("retry".into(), "evaluate".into());
graph.add_conditional_edges("evaluate", Box::new(route), mapping);
Recursion limit
Guard against infinite loops:
let compiled = graph.compile()?
.with_recursion_limit(10);
If execution exceeds the limit, GraphError::RecursionLimit is returned.
Visualisation
Generate a Mermaid diagram of your graph:
let mermaid = compiled.to_mermaid();
println!("{mermaid}");
Output:
graph TD
__start__([__start__]) --> greet
greet --> farewell
farewell --> __end__([__end__])
Using with a chat model
Combine graph orchestration with model invocations:
use std::sync::Arc;
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_core::messages::Message;
let model = Arc::new(FakeChatModel::new(vec!["Processed.".into()]));
let model_ref = Arc::clone(&model);
graph.add_node("llm_node", Box::new(move |state| {
let model = Arc::clone(&model_ref);
Box::pin(async move {
let query = state["query"].as_str().unwrap_or("");
let result = model
.invoke(&[Message::human(query)], None)
.await
.map_err(|e| GraphError::Core(e))?;
let mut new_state = state;
new_state["response"] = serde_json::json!(
result.message.content().as_text()
);
Ok(new_state)
})
}))?;
Next steps
- Derive Macros --
#[derive(State)]for typed graph state - Add Checkpointing -- persist graph state
- Graph Interrupts -- pause and resume graphs
See also
- StateGraph vs FsmStrategy — when to use a graph pipeline vs a single-agent FSM
- Pregel Execution Model — how supersteps work under the hood
- synwire-orchestrator Explanation — channels and conditional routing
Background: AI Workflows vs AI Agents — the spectrum from deterministic pipelines to autonomous agents. Background: Introduction to Agents — agent architecture fundamentals.
Derive Macros
Synwire provides two procedural macros for ergonomic definitions: #[tool] and #[derive(State)].
#[tool] attribute macro
Transforms an annotated async function into a StructuredTool factory. The original function is preserved, and a companion {name}_tool() function is generated.
Usage
use synwire_derive::tool;
use synwire_core::error::SynwireError;
/// Searches the web for information.
#[tool]
async fn search(query: String) -> Result<String, SynwireError> {
Ok(format!("Results for: {query}"))
}
// Generated: search_tool() -> Result<StructuredTool, SynwireError>
Parameter type mapping
Function parameters are automatically mapped to JSON Schema types:
| Rust type | JSON Schema type |
|---|---|
String, &str | "string" |
i32, u64, etc. | "integer" |
f32, f64 | "number" |
bool | "boolean" |
Vec<T> | "array" |
Documentation
Doc comments on the function become the tool's description:
/// Calculates the sum of two numbers.
#[tool]
async fn add(a: i64, b: i64) -> Result<String, SynwireError> {
Ok(format!("{}", a + b))
}
// Tool description: "Calculates the sum of two numbers."
#[derive(State)] derive macro
Generates channel configuration from struct field annotations for use with StateGraph.
Usage
use synwire_derive::State;
#[derive(State)]
struct AgentState {
/// Messages accumulate via a Topic channel
#[reducer(topic)]
messages: Vec<String>,
/// Current step uses LastValue (default)
current_step: String,
}
// Generated: AgentState::channels() -> Vec<(String, Box<dyn BaseChannel>)>
Channel types
| Annotation | Channel type | Behaviour |
|---|---|---|
| (none) | LastValue | Overwrites with the latest value |
#[reducer(topic)] | Topic | Appends values (accumulator) |
Using with StateGraph
use synwire_orchestrator::graph::StateGraph;
let channels = AgentState::channels();
// Use channels when configuring the graph
Combining both macros
A typical agent combines #[tool] for tool definitions and #[derive(State)] for graph state:
use synwire_derive::{tool, State};
use synwire_core::error::SynwireError;
#[derive(State)]
struct MyAgentState {
#[reducer(topic)]
messages: Vec<String>,
result: String,
}
/// Looks up information in a database.
#[tool]
async fn lookup(key: String) -> Result<String, SynwireError> {
Ok(format!("Value for {key}"))
}
Next steps
- Custom Tool -- more tool patterns
- Graph Agents -- use State with graphs
Your First Agent
Time: ~20 minutes Prerequisites: Rust 1.85+, Cargo, a working internet connection for crate downloads
By the end of this tutorial you will have a Rust program that constructs a Synwire agent,
drives it with the Runner, and collects streaming events from the response. You will
understand what each component does and how errors surface.
What you are building
A minimal binary that:
- Constructs an
Agentusing the builder API. - Wraps it in a
Runner. - Sends a single input message and reads the event stream to completion.
- Prints any text delta and the termination reason.
Step 1: Create a new Cargo project
cargo new synwire-hello
cd synwire-hello
Step 2: Add Synwire to Cargo.toml
Open Cargo.toml and add the dependencies. If you are working inside the Synwire
workspace, use the workspace path. For a standalone project, add version numbers from
crates.io once the crate is published.
[dependencies]
# Core agent types (Agent builder, Runner, AgentError, AgentEvent)
synwire-core = { path = "../../crates/synwire-core" }
# Tokio async runtime
tokio = { version = "1", features = ["full"] }
# JSON value construction
serde_json = "1"
If you are working inside the Synwire repository workspace, use
synwire-core = { workspace = true }andtokio = { workspace = true }.
Step 3: Write the agent
Replace the contents of src/main.rs:
use synwire_core::agents::agent_node::Agent; use synwire_core::agents::runner::{Runner, RunnerConfig}; use synwire_core::agents::streaming::AgentEvent; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Build the agent. // // Agent::new(name, model) creates the builder directly. // Every method on Agent consumes and returns `self`, so they chain. let agent: Agent = Agent::new("my-agent", "stub-model") .description("A simple demonstration agent") .max_turns(10); // The Runner drives the agent turn loop. It holds the agent behind an Arc // so it can be shared and stopped from another task if needed. let runner = Runner::new(agent); // runner.run() returns a channel receiver. The runner spawns a background // task that sends events; we read them in this loop. let config = RunnerConfig::default(); let mut rx = runner .run(serde_json::json!("What is 2+2?"), config) .await?; // Drain the event stream until the channel closes. while let Some(event) = rx.recv().await { match event { AgentEvent::TextDelta { content } => { print!("{content}"); } AgentEvent::TurnComplete { reason } => { println!("\n[done: {reason:?}]"); } AgentEvent::Error { message } => { eprintln!("Agent error: {message}"); } _ => { // Other events (UsageUpdate, ToolCallStart, etc.) are ignored here. } } } Ok(()) }
Run it:
cargo run
You will see [done: Complete] printed — the stub model finishes immediately. In a
production setup you would provide a real LLM backend crate (such as synwire-llm-openai)
that replaces the stub model invocation.
Step 4: Understand the Agent builder
Agent<O> is a plain builder struct. The type parameter O is the optional structured
output type. Omitting it (or writing Agent without a type argument) defaults O to (),
which means the agent returns unstructured text.
The builder fields you will use most often:
| Method | Purpose |
|---|---|
Agent::new(name, model) | Set the agent name and primary model identifier |
.description(text) | Human-readable description used in logging |
.max_turns(n) | Abort after n conversation turns |
.max_budget(usd) | Abort when cumulative cost exceeds the USD limit |
.fallback_model(name) | Switch to this model on retryable errors |
.tool(t) | Register a tool the model can call |
.plugin(p) | Attach a plugin (covered in tutorial 04) |
The builder is consumed by Runner::new(agent). After that point, configuration is fixed.
Step 5: Understand the Runner
Runner<O> drives the agent execution loop in a background Tokio task. It is intentionally
stateless between calls to run().
#![allow(unused)] fn main() { // Create a runner from the agent. let runner = Runner::new(agent); // Override the model for one run without rebuilding the agent. runner.set_model("gpt-4o").await; // Start a run and receive the event channel. let mut rx = runner.run(input, RunnerConfig::default()).await?; }
RunnerConfig lets you pass per-run options:
#![allow(unused)] fn main() { use synwire_core::agents::runner::RunnerConfig; let config = RunnerConfig { // Resume an existing conversation by session ID. session_id: Some("session-abc".to_string()), // Override the model for this single run. model_override: Some("claude-3-5-sonnet".to_string()), // Retry transient model errors up to this many times. max_retries: 3, }; }
Step 6: Understand AgentError
Runner::run returns Result<mpsc::Receiver<AgentEvent>, AgentError>. The error fires
only if setup fails before the event stream starts (for example, an invalid configuration).
AgentError is #[non_exhaustive], meaning new variants may be added in future releases.
Always include a catch-all arm:
#![allow(unused)] fn main() { use synwire_core::agents::error::AgentError; async fn run_agent(runner: &Runner) -> Result<(), AgentError> { let config = RunnerConfig::default(); let mut rx = runner .run(serde_json::json!("Hello"), config) .await?; // <-- AgentError propagated here with `?` while let Some(event) = rx.recv().await { if let AgentEvent::Error { message } = event { // Errors during the run arrive as events, not as Err(AgentError). eprintln!("runtime error: {message}"); } } Ok(()) } }
The key AgentError variants you are likely to encounter:
| Variant | Meaning |
|---|---|
AgentError::Model(ModelError::RateLimit(_)) | Rate-limited; the runner retries automatically |
AgentError::Model(ModelError::Authentication(_)) | Bad API key; not retryable |
AgentError::BudgetExceeded(cost) | Cumulative spend exceeded max_budget |
AgentError::Tool(msg) | A registered tool returned an error |
Because AgentError is #[non_exhaustive], write:
#![allow(unused)] fn main() { match err { AgentError::Model(model_err) => { /* ... */ } AgentError::BudgetExceeded(cost) => { /* ... */ } _ => eprintln!("unexpected agent error: {err}"), } }
Step 7: Read the full event stream
AgentEvent carries all observable agent behaviour. The events you should always handle:
| Event | When emitted |
|---|---|
TextDelta { content } | Model produces a text chunk (streaming) |
UsageUpdate { usage } | After each turn; contains input_tokens, output_tokens |
TurnComplete { reason } | Final event; reason is one of Complete, MaxTurnsExceeded, BudgetExceeded, Stopped, Aborted, Error |
Error { message } | Non-fatal error during the run |
The channel closes after the TurnComplete (or Error) event, so rx.recv().await
returning None is the correct loop termination signal.
Stopping an agent from outside
You can stop a running agent by holding an Arc<Runner> and calling stop_graceful or
stop_force from another task:
#![allow(unused)] fn main() { use std::sync::Arc; let runner = Arc::new(Runner::new(agent)); let runner_handle = Arc::clone(&runner); // Spawn the run. let mut rx = runner.run(serde_json::json!("Long task"), RunnerConfig::default()).await?; // In another task, stop after 5 seconds. tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(5)).await; runner_handle.stop_graceful().await; }); // Drain events normally; you will receive TurnComplete { reason: Stopped }. while let Some(event) = rx.recv().await { // ... } }
Next steps
- Add tools: See
../how-to/tools.mdfor registering typed tool handlers. - Structured output: See
../how-to/structured_output.mdfor bindingAgent<MyType>. - Understanding the event model: See
../explanation/event_model.mdfor a deep dive into how events, turns, and retries interact. - Next tutorial: Continue with
02-pure-directive-testing.mdto learn how to test agent logic without executing any side effects.
Background: Agent Components — the memory, tools, and planning components of an agent; all three appear in this tutorial.
Testing Agent Logic Without Side Effects
Time: ~25 minutes
Prerequisites: Completed 01-first-agent.md, familiarity with Rust #[test]
Agent nodes often want to spawn child agents, emit events, or stop themselves. These are side effects that are expensive or inconvenient to trigger in unit tests. Synwire separates the description of effects from their execution through the directive/effect pattern. This tutorial teaches you to write pure node functions, assert on the directives they return, and verify nothing actually executed.
What you are building
A counter agent node that:
- Increments a counter in state.
- Emits a
SpawnAgentdirective when the counter reaches a threshold. - Emits a
Stopdirective when it reaches a hard limit.
You will test all three behaviours without running any LLM or spawning any real child agent.
Step 1: Understand DirectiveResult
DirectiveResult<S> is the return type of a pure agent node function:
#![allow(unused)] fn main() { pub struct DirectiveResult<S: State> { pub state: S, pub directives: Vec<Directive>, } }
stateis the new state value after the node ran. It is applied immediately.directivesis a list of effects to be executed by the runtime later.
The split matters: your node function can be a plain synchronous fn returning a
DirectiveResult. It does not need to be async, does not take a runtime reference, and
cannot accidentally trigger real side effects.
Step 2: Define a State type
Add this to src/lib.rs (or a test module):
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; use serde_json::Value; use synwire_core::State; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CounterState { pub count: u32, } impl State for CounterState { fn to_value(&self) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> { Ok(serde_json::to_value(self)?) } fn from_value(value: Value) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { Ok(serde_json::from_value(value)?) } } }
State requires Send + Sync + Clone + Serialize + DeserializeOwned. The body of both
methods is identical for almost every concrete state type — delegate to serde_json.
Step 3: Write the pure node function
#![allow(unused)] fn main() { use synwire_core::agents::directive::{Directive, DirectiveResult}; use serde_json::json; const SPAWN_THRESHOLD: u32 = 3; const STOP_LIMIT: u32 = 5; pub fn counter_node(state: CounterState) -> DirectiveResult<CounterState> { let new_count = state.count + 1; let new_state = CounterState { count: new_count }; if new_count >= STOP_LIMIT { // Request agent stop. The runtime will act on this; the node does not. return DirectiveResult::with_directive( new_state, Directive::Stop { reason: Some(format!("limit {STOP_LIMIT} reached")), }, ); } if new_count == SPAWN_THRESHOLD { // Request spawning a helper agent. Config is arbitrary JSON. return DirectiveResult::with_directive( new_state, Directive::SpawnAgent { name: "helper-agent".to_string(), config: json!({ "model": "gpt-4o-mini", "task": "summarise" }), }, ); } // No side effects — state only. DirectiveResult::state_only(new_state) } }
Key constructors on DirectiveResult:
| Constructor | Use when |
|---|---|
DirectiveResult::state_only(state) | No side effects needed |
DirectiveResult::with_directive(state, d) | Exactly one effect |
DirectiveResult::with_directives(state, vec![...]) | Multiple effects |
state.into() (via From<S>) | Shorthand for state_only |
Step 4: The Directive enum
Directive is #[non_exhaustive]. The variants you will use most often:
| Variant | Purpose |
|---|---|
Emit { event: AgentEvent } | Push an event to the event stream |
SpawnAgent { name, config } | Ask the runtime to start a child agent |
StopChild { name } | Ask the runtime to stop a named child agent |
Stop { reason } | Ask the runtime to stop this agent |
SpawnTask { description, input } | Run a background task |
StopTask { task_id } | Cancel a background task |
RunInstruction { instruction, input } | Delegate to the model and route result back |
Schedule { action, delay } | Fire an action after a delay |
Cron { expression, action } | Fire an action on a cron schedule |
Step 5: Write unit tests
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use synwire_core::agents::directive::Directive; #[test] fn increments_count() { let state = CounterState { count: 0 }; let result = counter_node(state); assert_eq!(result.state.count, 1); assert!(result.directives.is_empty(), "no side effects below threshold"); } #[test] fn spawns_agent_at_threshold() { let state = CounterState { count: SPAWN_THRESHOLD - 1 }; let result = counter_node(state); assert_eq!(result.state.count, SPAWN_THRESHOLD); assert_eq!(result.directives.len(), 1); assert!( matches!( &result.directives[0], Directive::SpawnAgent { name, .. } if name == "helper-agent" ), "expected SpawnAgent directive" ); } #[test] fn stops_at_limit() { let state = CounterState { count: STOP_LIMIT - 1 }; let result = counter_node(state); assert_eq!(result.state.count, STOP_LIMIT); assert_eq!(result.directives.len(), 1); assert!( matches!(&result.directives[0], Directive::Stop { .. }), "expected Stop directive" ); } #[test] fn stop_reason_is_set() { let state = CounterState { count: STOP_LIMIT - 1 }; let result = counter_node(state); if let Directive::Stop { reason } = &result.directives[0] { assert!(reason.is_some(), "reason should be set"); assert!(reason.as_ref().unwrap().contains("limit")); } else { panic!("expected Stop directive"); } } } }
Run:
cargo test
All four tests pass with zero network calls, zero spawned processes, and zero LLM tokens.
Step 6: Use NoOpExecutor to confirm no execution
In integration tests you may wire your node function into a broader harness that passes
directives to an executor. NoOpExecutor records nothing and executes nothing — it always
returns Ok(None):
#![allow(unused)] fn main() { #[cfg(test)] mod executor_tests { use synwire_core::agents::directive::{Directive, DirectiveResult}; use synwire_core::agents::directive_executor::{DirectiveExecutor, NoOpExecutor}; #[tokio::test] async fn noop_executor_does_not_execute() { let executor = NoOpExecutor; let directive = Directive::SpawnAgent { name: "child".to_string(), config: serde_json::json!({}), }; // execute_directive returns Ok(None) — no child was spawned. let result = executor .execute_directive(&directive) .await .expect("executor should not error"); assert!(result.is_none(), "NoOpExecutor never returns a value"); } } }
When you later integrate a real executor (for example one that makes HTTP calls to spawn
agents), you can substitute NoOpExecutor in tests while keeping the same node functions.
Step 7: Serde round-trip
Directive derives Serialize and Deserialize, with #[serde(tag = "type")]. This
lets you persist directives to a queue, send them over the wire, or log them for auditing.
#![allow(unused)] fn main() { #[cfg(test)] mod serde_tests { use synwire_core::agents::directive::Directive; #[test] fn stop_directive_round_trips() { let original = Directive::Stop { reason: Some("task complete".to_string()), }; let json = serde_json::to_string(&original).expect("serialize"); // The discriminant is the "type" field. assert!(json.contains(r#""type":"stop""#)); let deserialized: Directive = serde_json::from_str(&json).expect("deserialize"); assert!(matches!(deserialized, Directive::Stop { .. })); } #[test] fn spawn_agent_directive_round_trips() { let original = Directive::SpawnAgent { name: "worker".to_string(), config: serde_json::json!({ "model": "gpt-4o" }), }; let json = serde_json::to_string(&original).expect("serialize"); assert!(json.contains(r#""type":"spawn_agent""#)); let back: Directive = serde_json::from_str(&json).expect("deserialize"); assert!(matches!(back, Directive::SpawnAgent { name, .. } if name == "worker")); } #[test] fn run_instruction_directive_round_trips() { let original = Directive::RunInstruction { instruction: "summarise this text".to_string(), input: serde_json::json!({ "text": "hello world" }), }; let json = serde_json::to_string(&original).expect("serialize"); let back: Directive = serde_json::from_str(&json).expect("deserialize"); assert!(matches!(back, Directive::RunInstruction { .. })); } } }
The serialised form uses "type" as the tag field. For example, Directive::Stop becomes:
{"type":"stop","reason":"task complete"}
What you have learned
DirectiveResult<S>separates state mutation from side-effect description.- Pure node functions are plain synchronous
fns — noasync, no runtime references. - The
Directiveenum describes every possible side effect. NoOpExecutorlets you wire the executor interface into tests without executing anything.Directiveis fully serialisable for logging, queueing, or persistence.
Next steps
- Execution strategies: Continue with
03-execution-strategies.mdto learn how to constrain which actions an agent can take based on FSM state. - Custom directives: See
../explanation/directive_system.mdfor implementing a customDirectivePayloadviatypetag. - How-to guide: See
../how-to/testing.mdfor composing test fixtures withsynwire-test-utilsproptest strategies.
Controlling Agent Behaviour with Execution Strategies
Time: ~30 minutes
Prerequisites: Completed 02-pure-directive-testing.md, basic understanding of
finite-state machines
An execution strategy constrains how an agent orchestrates actions. By default agents use
DirectStrategy, which accepts any action immediately. When you need to enforce an ordered
workflow — for example, an agent that must authenticate before it can process data — you
use FsmStrategy to model the allowed transitions as a state machine.
This tutorial covers both strategies and shows how to add guard conditions that inspect input before allowing a transition.
What you are building
- A
DirectStrategyagent that accepts any action. - An
FsmStrategyfor a simple three-state workflow:idle → running → done. - A guard that rejects a transition based on input content.
- Snapshot serialisation to inspect FSM state.
Step 1: Add dependencies
[dependencies]
synwire-core = { path = "../../crates/synwire-core" }
synwire-agent = { path = "../../crates/synwire-agent" }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
Step 2: DirectStrategy — immediate execution
DirectStrategy is the simplest implementation of ExecutionStrategy. It accepts any
action and passes the input through unchanged as its output. There is no state to set up
and no builder step — just construct and call:
use synwire_agent::strategies::DirectStrategy; use synwire_core::agents::execution_strategy::ExecutionStrategy; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let strategy = DirectStrategy::new(); // Execute any action — DirectStrategy never rejects. let output = strategy .execute("generate_text", json!({ "prompt": "hello" })) .await?; println!("output: {output}"); // Snapshot tells you the strategy type. let snap = strategy.snapshot()?; let snap_value = snap.to_value()?; println!("snapshot: {snap_value}"); // {"type":"direct"} Ok(()) }
DirectStrategy is appropriate when:
- The agent orchestrates LLM calls without a defined state machine.
- You want no constraints on action ordering.
- You are prototyping and will add an FSM later.
Step 3: FsmStrategy — state-constrained execution
FsmStrategy models the allowed actions as a directed graph. Each node is a state and
each edge is an (action, target-state) pair. The FSM rejects any action not defined for
the current state.
Build the FSM with FsmStrategy::builder():
use synwire_agent::strategies::{FsmStrategy, FsmStrategyWithRoutes}; use synwire_core::agents::execution_strategy::{ExecutionStrategy, StrategyError}; use serde_json::json; fn build_workflow_fsm() -> Result<FsmStrategyWithRoutes, StrategyError> { FsmStrategy::builder() // Declare states for readability (optional — states are inferred from transitions). .state("idle") .state("running") .state("done") // Set the state the FSM starts in. .initial("idle") // Define allowed transitions: (from, action, to). .transition("idle", "start", "running") .transition("running", "finish", "done") .build() } #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let fsm = build_workflow_fsm()?; // Valid transition: idle --start--> running. fsm.execute("start", json!({})).await?; println!("state: {:?}", fsm.strategy.current_state()?); // running // Valid transition: running --finish--> done. fsm.execute("finish", json!({})).await?; println!("state: {:?}", fsm.strategy.current_state()?); // done Ok(()) }
FsmStrategy::builder().build() returns FsmStrategyWithRoutes (not FsmStrategy
directly). FsmStrategyWithRoutes implements ExecutionStrategy and bundles any signal
routes defined on the builder. The inner FsmStrategy is available as the public
.strategy field when you need to call current_state().
Step 4: Handle invalid transitions
When you call execute with an action that has no transition from the current state, you
receive StrategyError::InvalidTransition:
#![allow(unused)] fn main() { #[tokio::test] async fn test_invalid_transition() { let fsm = FsmStrategy::builder() .initial("idle") .state("idle") .state("running") .transition("idle", "start", "running") .build() .expect("valid FSM"); // "finish" is not a valid action from "idle". let err = fsm .execute("finish", serde_json::json!({})) .await .expect_err("should reject unknown action"); match err { StrategyError::InvalidTransition { current_state, attempted_action, valid_actions, } => { assert_eq!(current_state, "idle"); assert_eq!(attempted_action, "finish"); // valid_actions lists what IS allowed from the current state. assert!(valid_actions.contains(&"start".to_string())); } other => panic!("unexpected error: {other}"), } } }
The error message from Display is also human-readable:
Invalid transition from idle via finish. Valid actions: ["start"]
Note that StrategyError is #[non_exhaustive]; always include a catch-all arm in
match blocks.
Step 5: Add a ClosureGuard
Guards let you inspect the action input before committing to a transition. Use
transition_with_guard and provide a ClosureGuard:
#![allow(unused)] fn main() { use synwire_agent::strategies::FsmStrategy; use synwire_core::agents::execution_strategy::{ClosureGuard, ExecutionStrategy, StrategyError}; use serde_json::json; #[tokio::test] async fn test_guard_on_transition() { // Guard: only allow "start" when the input contains a non-empty "task" field. let has_task = ClosureGuard::new("requires-task", |input| { input .get("task") .and_then(|v| v.as_str()) .is_some_and(|s| !s.is_empty()) }); let fsm = FsmStrategy::builder() .initial("idle") .transition_with_guard("idle", "start", "running", has_task, 0) .build() .expect("valid FSM"); // Input without "task" — guard rejects. let err = fsm .execute("start", json!({})) .await .expect_err("guard should reject"); assert!(matches!(err, StrategyError::GuardRejected(_))); // Input with "task" — guard passes, transition succeeds. fsm.execute("start", json!({ "task": "summarise" })) .await .expect("guard should pass"); assert_eq!( fsm.strategy.current_state().expect("state").0, "running" ); } }
ClosureGuard::new(name, f) wraps any Fn(&Value) -> bool closure. The name string
appears in StrategyError::GuardRejected messages to help diagnose failures.
Step 6: Priority ordering with multiple guards
When multiple transitions share the same (from, action) key, the FSM evaluates them in
descending priority order and accepts the first one whose guard passes:
#![allow(unused)] fn main() { use synwire_agent::strategies::FsmStrategy; use synwire_core::agents::execution_strategy::{ClosureGuard, ExecutionStrategy}; use serde_json::json; #[tokio::test] async fn test_priority_ordering() { // Priority 10: premium path — only when input has "premium": true. let premium_guard = ClosureGuard::new("premium", |v| { v.get("premium").and_then(|p| p.as_bool()).unwrap_or(false) }); // Priority 5: standard path — always passes. let standard_guard = ClosureGuard::new("always", |_| true); let fsm = FsmStrategy::builder() .initial("idle") .transition_with_guard("idle", "start", "premium-queue", premium_guard, 10) .transition_with_guard("idle", "start", "standard-queue", standard_guard, 5) .build() .expect("valid FSM"); // Non-premium input: premium guard (priority 10) fails, standard guard (priority 5) passes. fsm.execute("start", json!({ "premium": false })) .await .expect("standard path"); assert_eq!( fsm.strategy.current_state().expect("state").0, "standard-queue" ); } }
The priority parameter is an i32. Higher values are evaluated first.
Step 7: Snapshot the FSM state
ExecutionStrategy::snapshot() captures the current state of the strategy as a
Box<dyn StrategySnapshot>. Call to_value() to serialise it:
#![allow(unused)] fn main() { use synwire_agent::strategies::FsmStrategy; use synwire_core::agents::execution_strategy::ExecutionStrategy; use serde_json::json; #[tokio::test] async fn test_fsm_snapshot() { let fsm = FsmStrategy::builder() .initial("idle") .transition("idle", "start", "running") .build() .expect("valid FSM"); // Snapshot before transition. let before = fsm.snapshot().expect("snapshot").to_value().expect("to_value"); assert_eq!(before["type"], "fsm"); assert_eq!(before["current_state"], "idle"); fsm.execute("start", json!({})).await.expect("transition"); // Snapshot after transition. let after = fsm.snapshot().expect("snapshot").to_value().expect("to_value"); assert_eq!(after["current_state"], "running"); } }
The snapshot for DirectStrategy is simpler:
#![allow(unused)] fn main() { use synwire_agent::strategies::DirectStrategy; use synwire_core::agents::execution_strategy::ExecutionStrategy; let snap = DirectStrategy::new() .snapshot() .expect("snapshot") .to_value() .expect("to_value"); assert_eq!(snap, serde_json::json!({"type": "direct"})); }
Snapshots are useful for persisting workflow state to a checkpoint store so a long-running agent can resume after a restart.
Step 8: Signal routes
FsmStrategyBuilder::route attaches SignalRoute values to the built strategy. Signal
routes tell the agent runtime how to map incoming external signals to FSM actions. They are
declared at build time and queried via ExecutionStrategy::signal_routes():
#![allow(unused)] fn main() { use synwire_agent::strategies::FsmStrategy; use synwire_core::agents::signal::SignalRoute; use synwire_core::agents::execution_strategy::ExecutionStrategy; let fsm = FsmStrategy::builder() .initial("idle") .transition("idle", "start", "running") .route(SignalRoute { signal: "user.message".to_string(), action: "start".to_string(), }) .build() .expect("valid FSM"); let routes = fsm.signal_routes(); assert_eq!(routes.len(), 1); assert_eq!(routes[0].signal, "user.message"); assert_eq!(routes[0].action, "start"); }
See ../explanation/signal_routing.md for how the runtime dispatches signals.
Summary
| Strategy | When to use |
|---|---|
DirectStrategy | No ordering constraints; any action is valid |
FsmStrategy | Ordered workflow; reject invalid action sequences |
ClosureGuard | Runtime inspection of action input before committing |
Next steps
- Plugin state: Continue with
04-plugin-state-isolation.mdto learn how plugins attach isolated state slices to an agent. - Persisting snapshots: See
../how-to/checkpointing.mdfor storing FSM snapshots in the SQLite checkpoint backend. - Deep dive: See
../explanation/execution_strategies.mdfor the full lifecycle of a strategy within the runner loop.
See also
- StateGraph vs FsmStrategy — when to use the state machine vs a graph pipeline
- FSM Strategy Design — guard semantics and transition priority
Background: AI Workflows vs AI Agents — when structured workflows outperform unconstrained agents.
Composing Plugins with Type-Safe State
Time: ~30 minutes
Prerequisites: Completed 01-first-agent.md, familiarity with Rust generics
Synwire agents support a plugin system that lets independent modules each hold their own private state alongside the agent, without being able to interfere with one another. The isolation is enforced at compile time through Rust's type system, not at runtime through naming conventions.
This tutorial shows you how to define plugin state keys, store state in a PluginStateMap,
access it through typed handles, implement the Plugin lifecycle trait, and verify that two
plugins cannot read each other's data.
What you are building
Two plugins:
CachePlugin— holds a simple in-memory cache with a hit counter.MetricsPlugin— holds a message count.
You will verify that mutating CachePlugin state does not affect MetricsPlugin state,
and that PluginStateMap enforces this isolation.
Step 1: Add dependencies
[dependencies]
synwire-core = { path = "../../crates/synwire-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Step 2: Understand PluginStateKey
PluginStateKey is a marker trait that pairs a zero-sized key type with its associated
state type and a unique string identifier:
#![allow(unused)] fn main() { pub trait PluginStateKey: Send + Sync + 'static { type State: Send + Sync + 'static; const KEY: &'static str; } }
type Stateis the concrete data type stored for this plugin.KEYis a stable string used in serialised output (e.g. debug dumps or checkpoints). It must be unique across all plugins in an agent. Duplicate registrations are detected at runtime and return an error.
The key type itself is never instantiated — it is purely a compile-time token.
Step 3: Define the CachePlugin key and state
#![allow(unused)] fn main() { use std::collections::HashMap; use serde::{Deserialize, Serialize}; use synwire_core::agents::plugin::PluginStateKey; /// State stored by CachePlugin. #[derive(Debug, Default, Serialize, Deserialize)] pub struct CacheState { pub hits: u32, pub entries: HashMap<String, String>, } /// Zero-sized key type. Never instantiated. pub struct CachePlugin; impl PluginStateKey for CachePlugin { type State = CacheState; const KEY: &'static str = "cache"; } }
Step 4: Define the MetricsPlugin key and state
#![allow(unused)] fn main() { /// State stored by MetricsPlugin. #[derive(Debug, Default, Serialize, Deserialize)] pub struct MetricsState { pub messages_processed: u64, } /// Zero-sized key type. pub struct MetricsPlugin; impl PluginStateKey for MetricsPlugin { type State = MetricsState; const KEY: &'static str = "metrics"; } }
Step 5: Register state in PluginStateMap
PluginStateMap is the container that holds all plugin state slices for one agent
instance. Register each plugin's initial state with register:
#![allow(unused)] fn main() { use synwire_core::agents::plugin::{PluginHandle, PluginStateMap}; fn create_plugin_map() -> (PluginStateMap, PluginHandle<CachePlugin>, PluginHandle<MetricsPlugin>) { let mut map = PluginStateMap::new(); // register() returns a PluginHandle — a zero-sized proof token. // If you try to register the same type twice, register() returns Err(KEY). let cache_handle = map .register::<CachePlugin>(CacheState::default()) .expect("cache not yet registered"); let metrics_handle = map .register::<MetricsPlugin>(MetricsState::default()) .expect("metrics not yet registered"); (map, cache_handle, metrics_handle) } }
PluginHandle<P> is a Copy + Clone zero-sized struct. Holding one proves that the
plugin is registered in the associated map. It has no runtime data — it is a compile-time
witness only.
Step 6: Read and write through typed access
PluginStateMap::get::<P>() and get_mut::<P>() are generic over the key type. They
return Option<&P::State> and Option<&mut P::State> respectively. The type checker
prevents you from using the wrong key:
#![allow(unused)] fn main() { #[test] fn read_and_write_cache_state() { let (mut map, _cache, _metrics) = create_plugin_map(); // Read initial state. let state = map.get::<CachePlugin>().expect("cache registered"); assert_eq!(state.hits, 0); assert!(state.entries.is_empty()); // Mutate via get_mut. { let state = map.get_mut::<CachePlugin>().expect("cache registered"); state.hits += 1; state.entries.insert("greeting".to_string(), "hello".to_string()); } // Verify mutation. let state = map.get::<CachePlugin>().expect("cache registered"); assert_eq!(state.hits, 1); assert_eq!(state.entries.get("greeting").map(String::as_str), Some("hello")); } }
You cannot pass MetricsPlugin to get::<CachePlugin>() — the types do not match and
the code will not compile.
Step 7: Verify cross-plugin isolation
The following test confirms that mutating one plugin's state does not affect another:
#![allow(unused)] fn main() { #[test] fn plugin_isolation_is_enforced() { let (mut map, _cache, _metrics) = create_plugin_map(); // Mutate cache state aggressively. { let cache = map.get_mut::<CachePlugin>().expect("registered"); cache.hits = 9999; cache.entries.insert("key".to_string(), "value".to_string()); } // MetricsPlugin state is untouched. let metrics = map.get::<MetricsPlugin>().expect("registered"); assert_eq!(metrics.messages_processed, 0); // Mutate metrics state. map.get_mut::<MetricsPlugin>().expect("registered").messages_processed = 42; // Cache state is untouched. let cache = map.get::<CachePlugin>().expect("registered"); assert_eq!(cache.hits, 9999); } }
The isolation holds because the map is keyed by TypeId::of::<P>() — the Rust type
identity, not a string. Even if two plugins happened to share the same KEY string,
the TypeId lookup would still route correctly. (Duplicate TypeId registrations are
caught and return Err.)
Step 8: Implement the Plugin lifecycle trait
To hook into agent events, implement Plugin on a concrete struct:
#![allow(unused)] fn main() { use synwire_core::agents::directive::Directive; use synwire_core::agents::plugin::{Plugin, PluginInput, PluginStateMap}; use synwire_core::BoxFuture; pub struct CachePluginImpl; impl Plugin for CachePluginImpl { fn name(&self) -> &str { "cache" } /// Called when the agent receives a user message. /// We count cache accesses and emit no directives. fn on_user_message<'a>( &'a self, input: &'a PluginInput, state: &'a PluginStateMap, ) -> BoxFuture<'a, Vec<Directive>> { Box::pin(async move { // Read-only access is fine — PluginStateMap is shared here. if let Some(cache) = state.get::<CachePlugin>() { tracing::debug!( turn = input.turn, hits = cache.hits, "CachePlugin: on_user_message" ); } Vec::new() // return empty slice — no directives }) } } }
All Plugin methods have default no-op implementations. Override only the lifecycle
hooks you care about:
| Method | Called when |
|---|---|
on_user_message | A user message arrives |
on_event | Any AgentEvent is emitted |
before_run | Before each turn loop iteration |
after_run | After each turn loop iteration |
signal_routes | At startup; contribute signal routing rules |
Step 9: Register the plugin with an Agent
Attach the plugin implementation to the Agent builder with .plugin():
#![allow(unused)] fn main() { use synwire_core::agents::agent_node::Agent; let agent: Agent = Agent::new("my-agent", "stub-model") .plugin(CachePluginImpl) .plugin(MetricsPluginImpl); }
Multiple .plugin() calls are chained. Each plugin is stored as Box<dyn Plugin> and
called in registration order during lifecycle events.
Step 10: Serialise all plugin state
PluginStateMap::serialize_all() produces a JSON object keyed by each plugin's KEY
string. This is useful for logging, debugging, or persisting agent state:
#![allow(unused)] fn main() { #[test] fn serialize_all_plugin_state() { let (mut map, _, _) = create_plugin_map(); map.get_mut::<CachePlugin>().expect("registered").hits = 7; map.get_mut::<MetricsPlugin>().expect("registered").messages_processed = 3; let snapshot = map.serialize_all(); assert_eq!(snapshot["cache"]["hits"], 7); assert_eq!(snapshot["metrics"]["messages_processed"], 3); } }
The keys in the output object are the KEY constants you defined — "cache" and
"metrics" in this example.
Step 11: Detect duplicate registration
Attempting to register the same plugin key type twice returns an error:
#![allow(unused)] fn main() { #[test] fn duplicate_registration_is_rejected() { let mut map = PluginStateMap::new(); let _ = map .register::<CachePlugin>(CacheState::default()) .expect("first registration succeeds"); // Second registration of the same type returns the KEY string as the error. let err = map .register::<CachePlugin>(CacheState::default()) .expect_err("duplicate registration should fail"); assert_eq!(err, CachePlugin::KEY); // "cache" } }
Why this design matters
Most plugin systems use HashMap<String, Box<dyn Any>> with string keys. This approach
trades compile-time safety for flexibility: if you mistype a key string you get a silent
None at runtime, not a compiler error.
PluginStateMap uses TypeId as the map key. TypeId is derived from the Rust type
system, so:
- Accessing the wrong plugin state is a compile error, not a runtime panic.
- There is no string lookup —
TypeIdlookups are O(1) hash operations. - The
KEYstring constant exists only for serialisation; it plays no role in access control.
Next steps
- Backend operations: Continue with
05-backend-operations.mdto learn how to read and write files through the backend protocol. - Plugin how-to: See
../how-to/plugins.mdfor a complete guide to plugin registration, ordering, and dependency injection. - Architecture: See
../explanation/plugin_system.mdfor a deeper explanation of howTypeId-keyed maps enforce isolation.
Working with Files and Shell
Time: ~35 minutes
Prerequisites: Completed 01-first-agent.md, basic familiarity with async Rust
Agents often need to read configuration files, write output artefacts, search source code,
or navigate a directory hierarchy. Synwire provides a uniform interface for all of these
through the Vfs trait. Two concrete implementations are built in:
MemoryProvider— ephemeral, in-memory storage. No files on disk. Ideal for tests and agent scratchpads.LocalProvider— real OS filesystem, scoped to a root directory. Suitable for agents that must persist output between runs.
Both backends enforce safety boundaries that prevent an agent from escaping its allowed working directory. This tutorial teaches you to use both, understand the error model, and search file content with ripgrep-style options.
What you are building
- Writing, reading, and navigating with
MemoryProvider. - Attempting a path traversal and observing the rejection.
- Using
LocalProviderscoped to a temporary directory. - Searching file content with
grepandGrepOptions. - Reading
GrepMatchfields.
Step 1: Add dependencies
[dependencies]
synwire-core = { path = "../../crates/synwire-core" }
synwire-agent = { path = "../../crates/synwire-agent" }
tokio = { version = "1", features = ["full"] }
Step 2: Understand Vfs
Vfs is the trait all backends implement. Every method returns a
BoxFuture<'_, Result<T, VfsError>>, so operations are always async:
#![allow(unused)] fn main() { pub trait Vfs: Send + Sync { fn read(&self, path: &str) -> BoxFuture<'_, Result<FileContent, VfsError>>; fn write(&self, path: &str, content: &[u8]) -> BoxFuture<'_, Result<WriteResult, VfsError>>; fn ls(&self, path: &str) -> BoxFuture<'_, Result<Vec<DirEntry>, VfsError>>; fn grep(&self, pattern: &str, opts: GrepOptions) -> BoxFuture<'_, Result<Vec<GrepMatch>, VfsError>>; fn pwd(&self) -> BoxFuture<'_, Result<String, VfsError>>; fn cd(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>>; // ... and rm, cp, mv_file, edit, glob, upload, download, capabilities } }
You call backend.read("file.txt").await? exactly the same way for both
MemoryProvider and LocalProvider.
Step 3: MemoryProvider — write and read
use synwire_core::vfs::state_backend::MemoryProvider; use synwire_core::vfs::protocol::Vfs; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let backend = MemoryProvider::new(); // Write a file. Content is raw bytes; use b"..." for UTF-8 string literals. backend.write("notes.txt", b"hello from synwire").await?; // Read it back. FileContent.content is Vec<u8>. let file = backend.read("notes.txt").await?; let text = String::from_utf8(file.content)?; assert_eq!(text, "hello from synwire"); println!("read back: {text}"); Ok(()) }
MemoryProvider::new() creates an empty backend with / as the working directory. There is
no persistence — when the MemoryProvider is dropped, all data is lost.
Step 4: Navigate the working directory
Paths in MemoryProvider are resolved relative to the current working directory, exactly
like a real shell. Absolute paths (starting with /) are always resolved from root:
#[tokio::test] async fn navigate_directories() { let backend = MemoryProvider::new(); // Write a file at an absolute path. backend.write("/project/src/main.rs", b"fn main() {}").await.expect("write"); // Check the initial working directory. let cwd = backend.pwd().await.expect("pwd"); assert_eq!(cwd, "/"); // Change into the project directory. backend.cd("/project").await.expect("cd"); assert_eq!(backend.pwd().await.expect("pwd"), "/project"); // Relative read now works from /project. let file = backend.read("src/main.rs").await.expect("read relative"); assert_eq!(file.content, b"fn main() {}"); // cd with a relative path. backend.cd("src").await.expect("cd src"); assert_eq!(backend.pwd().await.expect("pwd"), "/project/src"); }
cd to a path that does not exist returns VfsError::NotFound. The working directory
is not changed on error.
Step 5: Path traversal protection
Both backends block traversal attempts that would escape the root. In MemoryProvider the
root is always /; in LocalProvider it is the directory you passed to new:
#![allow(unused)] fn main() { #[tokio::test] async fn path_traversal_is_rejected() { use synwire_core::vfs::error::VfsError; let backend = MemoryProvider::new(); // Attempt to cd above root using "..". let err = backend.cd("/../etc").await.expect_err("should be blocked"); // The error is PathTraversal, not NotFound. assert!( matches!(err, VfsError::PathTraversal { .. }), "expected PathTraversal, got: {err}" ); // The working directory is unchanged. assert_eq!(backend.pwd().await.expect("pwd"), "/"); } }
VfsError::PathTraversal carries two fields:
#![allow(unused)] fn main() { VfsError::PathTraversal { attempted: String, // The normalised path that was attempted root: String, // The root boundary that was violated } }
Step 6: VfsError — the full error model
VfsError is #[non_exhaustive]. The variants you will encounter most often:
| Variant | Meaning |
|---|---|
NotFound(path) | File or directory does not exist |
PermissionDenied(path) | OS-level permission refused |
IsDirectory(path) | Expected file but found directory |
PathTraversal { attempted, root } | Path normalised outside the root boundary |
ScopeViolation { path, scope } | Operation outside the configured allowed scope |
Unsupported(msg) | Operation not implemented by this backend |
Io(io::Error) | Underlying OS I/O error (filesystem backend only) |
Timeout(msg) | Operation exceeded a time limit |
OperationDenied(msg) | User or policy denied the operation |
Always handle VfsError with a match and a catch-all arm:
#![allow(unused)] fn main() { use synwire_core::vfs::error::VfsError; match err { VfsError::NotFound(p) => eprintln!("missing: {p}"), VfsError::PathTraversal { attempted, root } => { eprintln!("traversal blocked: {attempted} outside {root}") } VfsError::PermissionDenied(p) => eprintln!("permission denied: {p}"), other => eprintln!("backend error: {other}"), } }
Step 7: LocalProvider — real files on disk
LocalProvider operates on the real filesystem but confines all operations to the
root directory you pass to new. Attempting to access anything outside that root is
treated as a PathTraversal error.
#![allow(unused)] fn main() { use synwire_agent::vfs::filesystem::LocalProvider; use synwire_core::vfs::protocol::Vfs; #[tokio::test] async fn filesystem_backend_write_and_read() { // Use a temporary directory so the test cleans up after itself. let dir = tempfile::tempdir().expect("tmpdir"); let root = dir.path(); // new() canonicalises root and verifies it exists. let backend = LocalProvider::new(root).expect("create backend"); // Write a file. Parent directories are created automatically. backend .write("output/result.txt", b"42") .await .expect("write"); // Read it back. let content = backend.read("output/result.txt").await.expect("read"); assert_eq!(content.content, b"42"); } }
To use tempfile in tests, add it to your [dev-dependencies]:
[dev-dependencies]
tempfile = "3"
Step 8: Path traversal with LocalProvider
LocalProvider normalises paths without requiring them to exist (it avoids calling
canonicalize on non-existent paths). The boundary check happens after normalisation:
#![allow(unused)] fn main() { #[tokio::test] async fn filesystem_backend_blocks_traversal() { use synwire_core::vfs::error::VfsError; let dir = tempfile::tempdir().expect("tmpdir"); let backend = LocalProvider::new(dir.path()).expect("create"); // Attempt to read a file above the workspace root. let err = backend .read("../../etc/passwd") .await .expect_err("traversal must be blocked"); assert!( matches!(err, VfsError::PathTraversal { .. }), "expected PathTraversal, got: {err}" ); } }
Step 9: Grep — searching file content
Vfs::grep accepts a regex pattern and a GrepOptions struct. The options
mirror ripgrep's command-line flags:
use synwire_core::vfs::state_backend::MemoryProvider; use synwire_core::vfs::protocol::Vfs; use synwire_core::vfs::grep_options::{GrepOptions, GrepOutputMode}; #[tokio::test] async fn grep_with_context() { let backend = MemoryProvider::new(); // Seed the backend with some files. backend .write("/src/lib.rs", b"// lib\npub fn add(a: u32, b: u32) -> u32 {\n a + b\n}\n") .await .expect("write"); backend .write("/src/main.rs", b"// main\nfn main() {\n println!(\"hello\");\n}\n") .await .expect("write"); let opts = GrepOptions { case_insensitive: false, line_numbers: true, // Show one line of context before and after each match. after_context: 1, before_context: 1, // Restrict to .rs files. glob: Some("*.rs".to_string()), ..GrepOptions::default() }; let matches = backend.grep("pub fn", opts).await.expect("grep"); // "pub fn" appears only in lib.rs. assert_eq!(matches.len(), 1); let m = &matches[0]; assert!(m.file.ends_with("lib.rs")); assert_eq!(m.line_number, 2); // 1-indexed assert!(m.line_content.contains("pub fn add")); assert!(!m.before.is_empty()); // "// lib" is the before context assert!(!m.after.is_empty()); // " a + b" is the after context }
Step 10: GrepOptions reference
| Field | Type | Default | Description |
|---|---|---|---|
path | Option<String> | None (= cwd) | Restrict search to this path |
after_context | u32 | 0 | Lines to show after each match |
before_context | u32 | 0 | Lines to show before each match |
context | Option<u32> | None | Symmetric context (overrides before/after) |
case_insensitive | bool | false | Case-insensitive match |
glob | Option<String> | None | File name glob filter (e.g. "*.rs") |
file_type | Option<String> | None | Ripgrep-style type filter ("rust", "python", ...) |
max_matches | Option<usize> | None | Stop after this many matches |
output_mode | GrepOutputMode | Content | One of Content, FilesWithMatches, Count |
line_numbers | bool | false | Include line numbers in output |
invert | bool | false | Show non-matching lines |
fixed_string | bool | false | Treat pattern as literal string, not regex |
multiline | bool | false | Allow pattern to span lines |
Step 11: GrepOutputMode variants
GrepOutputMode controls the shape of the GrepMatch values returned:
#![allow(unused)] fn main() { use synwire_core::vfs::grep_options::{GrepOptions, GrepOutputMode}; // Content mode (default): full line content with context. let content_opts = GrepOptions { output_mode: GrepOutputMode::Content, line_numbers: true, ..GrepOptions::default() }; // FilesWithMatches: one entry per file that has at least one match. // GrepMatch.line_content and context fields are empty. let files_opts = GrepOptions { output_mode: GrepOutputMode::FilesWithMatches, ..GrepOptions::default() }; // Count: one entry per file; GrepMatch.line_number holds the match count. let count_opts = GrepOptions { output_mode: GrepOutputMode::Count, ..GrepOptions::default() }; }
Count mode example:
#![allow(unused)] fn main() { #[tokio::test] async fn grep_count_mode() { let backend = MemoryProvider::new(); backend.write("/f.txt", b"foo\nfoo\nbar\nfoo").await.expect("write"); let matches = backend .grep( "foo", GrepOptions { output_mode: GrepOutputMode::Count, ..GrepOptions::default() }, ) .await .expect("grep"); // One GrepMatch per file; line_number holds the count. assert_eq!(matches.len(), 1); assert_eq!(matches[0].line_number, 3); } }
Step 12: GrepMatch fields
GrepMatch carries all information about a single match:
#![allow(unused)] fn main() { pub struct GrepMatch { /// File path where the match was found. pub file: String, /// Line number (1-indexed). 0 when line_numbers: false or in FilesWithMatches mode. pub line_number: usize, /// Column of the match start (0-indexed). 0 in invert or FilesWithMatches mode. pub column: usize, /// Full content of the matched line. pub line_content: String, /// Lines before the match (up to before_context). pub before: Vec<String>, /// Lines after the match (up to after_context). pub after: Vec<String>, } }
In Count mode, line_number is repurposed to hold the match count and line_content
holds the count as a string. All other fields are empty.
Step 13: Case-insensitive and invert search
#![allow(unused)] fn main() { #[tokio::test] async fn case_insensitive_and_invert() { let backend = MemoryProvider::new(); backend .write("/log.txt", b"INFO: start\nERROR: fail\nINFO: end") .await .expect("write"); // Case-insensitive: "error" matches "ERROR". let errors = backend .grep( "error", GrepOptions { case_insensitive: true, line_numbers: true, ..GrepOptions::default() }, ) .await .expect("grep"); assert_eq!(errors.len(), 1); assert!(errors[0].line_content.contains("ERROR")); // Invert: show lines that do NOT match "ERROR". let non_errors = backend .grep( "ERROR", GrepOptions { invert: true, ..GrepOptions::default() }, ) .await .expect("grep"); assert_eq!(non_errors.len(), 2); assert!(non_errors.iter().all(|m| !m.line_content.contains("ERROR"))); } }
What you have learned
Vfsis the uniform interface for file operations across backends.MemoryProvideris fully in-memory — perfect for tests and agent scratchpads.LocalProvideris scoped to a root directory and enforces path traversal protection using normalised path comparison.- Both backends reject
../../etc/passwd-style traversal attempts withVfsError::PathTraversal. grepsupports case insensitivity, context lines, file type/glob filters, output modes, invert matching, and match limits throughGrepOptions.GrepMatchcarries the file path, line number, column, matched content, and context lines.
Next steps
- Composite backends: See
../how-to/vfs.mdfor composingMemoryProvider,LocalProvider, and custom backends through theCompositeProviderpipeline. - Shell execution: See
../how-to/shell.mdfor running commands withShelland readingExecuteResponse. - Architecture: See
../explanation/backend_protocol.mdfor a deeper explanation of how backends integrate with the agent runner, middleware, and approval gates. - Previous tutorial:
04-plugin-state-isolation.md— composing plugins with type-safe state.
Tutorial 6: Checkpointing — Resumable Workflows
Prerequisites: Rust 1.85+, completion of Tutorial 5, familiarity with StateGraph from Getting Started: Graph Agents.
In this tutorial you will:
- Understand what checkpointing does and when you need it
- Wire
InMemoryCheckpointSaverinto aStateGraph - Resume a run from a checkpoint using
thread_id - Switch to
SqliteSaverfor persistence across process restarts - Fork a run from a past checkpoint
1. Why checkpoint?
Without checkpointing, every graph.invoke(...) starts from scratch. Checkpointing enables:
- Resume — a long-running workflow interrupted mid-way (network error, process restart) picks up from the last completed superstep
- Fork — run alternative continuations from the same past state without re-executing earlier steps
- Replay / debug — rewind to an intermediate state and inspect what changed
- Human-in-the-loop — pause execution at a decision point, wait for human input, then resume
📖 Rust note:
Arc<T>(Atomically Reference Counted) enables shared ownership of a value across threads. We useArc<dyn BaseCheckpointSaver>because the compiled graph and the caller both need access to the saver.
2. In-memory checkpointing
Add dependencies:
[dependencies]
synwire-orchestrator = "0.1"
synwire-checkpoint = "0.1"
synwire-derive = "0.1"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
Define state and a simple two-step graph:
use synwire_derive::State; use synwire_orchestrator::graph::StateGraph; use synwire_orchestrator::constants::END; use synwire_orchestrator::func::sync_node; use synwire_checkpoint::{InMemoryCheckpointSaver, CheckpointConfig}; use std::sync::Arc; use serde::{Serialize, Deserialize}; #[derive(State, Clone, Debug, Default, Serialize, Deserialize)] struct WorkflowState { #[reducer(topic)] steps_completed: Vec<String>, #[reducer(last_value)] result: String, } #[tokio::main] async fn main() -> anyhow::Result<()> { let mut graph = StateGraph::<WorkflowState>::new(); graph.add_node("step_one", sync_node(|mut s: WorkflowState| { s.steps_completed.push("step_one".to_string()); Ok(s) }))?; graph.add_node("step_two", sync_node(|mut s: WorkflowState| { s.steps_completed.push("step_two".to_string()); s.result = format!("Completed: {:?}", s.steps_completed); Ok(s) }))?; graph.set_entry_point("step_one") .add_edge("step_one", "step_two") .add_edge("step_two", END); let compiled = graph.compile()?; // Attach the saver let saver = Arc::new(InMemoryCheckpointSaver::new()); let checkpointed = compiled.with_checkpoint_saver(saver.clone()); // Run with thread_id "session-1" let config = CheckpointConfig::new("session-1"); let state = checkpointed.invoke(WorkflowState::default(), Some(config)).await?; println!("Result: {}", state.result); Ok(()) }
After invoke completes, saver holds a checkpoint for "session-1". The checkpoint contains the full state after each superstep.
3. Resuming from a checkpoint
Pass the same thread_id on a subsequent call. The graph loads the latest checkpoint and continues from there:
#![allow(unused)] fn main() { // ... (same graph and saver from above) // First run let config = CheckpointConfig::new("my-thread"); checkpointed.invoke(WorkflowState::default(), Some(config.clone())).await?; // Simulate an interruption here. On resume: let resumed = checkpointed.invoke(WorkflowState::default(), Some(config)).await?; println!("Resumed result: {}", resumed.result); // The graph finds the existing checkpoint and skips already-completed supersteps. }
Note:
InMemoryCheckpointSaverloses all state when the process exits. For true resumability across restarts, useSqliteSaver(next section).
4. SQLite checkpointing for durable persistence
Add synwire-checkpoint-sqlite:
[dependencies]
synwire-checkpoint-sqlite = "0.1"
# ... rest unchanged
Replace InMemoryCheckpointSaver with SqliteSaver:
#![allow(unused)] fn main() { use synwire_checkpoint_sqlite::SqliteSaver; use std::path::Path; use std::sync::Arc; // Opens or creates "checkpoints.db" in the current directory. // File permissions are set to 0600 automatically. let saver = Arc::new(SqliteSaver::new(Path::new("checkpoints.db"))?); let checkpointed = compiled.with_checkpoint_saver(saver); let config = CheckpointConfig::new("persistent-session"); checkpointed.invoke(WorkflowState::default(), Some(config.clone())).await?; // Kill the process here. On restart: // The same code opens "checkpoints.db" and resumes from the last superstep. let resumed = checkpointed.invoke(WorkflowState::default(), Some(config)).await?; }
No system SQLite library is required — synwire-checkpoint-sqlite bundles SQLite via the rusqlite bundled feature.
5. Forking from a past checkpoint
To fork at a specific checkpoint, provide its checkpoint_id:
#![allow(unused)] fn main() { use synwire_checkpoint::{CheckpointConfig, BaseCheckpointSaver}; // List all checkpoints for a thread let checkpoints = saver.list(&CheckpointConfig::new("my-thread"), None).await?; // Fork from the first checkpoint (earliest in the run) if let Some(first) = checkpoints.first() { let fork_config = CheckpointConfig::new("my-thread") .with_checkpoint_id(first.checkpoint.id.clone()); let forked = checkpointed.invoke(WorkflowState::default(), Some(fork_config)).await?; println!("Fork result: {}", forked.result); } }
The forked run creates a new branch in the checkpoint tree, identified by a new checkpoint_id. The original thread remains unchanged.
Next steps
- How-To: Add Checkpointing — configuration options and advanced patterns
- Checkpointing Explanation —
BaseStoreand the serde protocol - Pregel Execution Model — how supersteps relate to checkpoints
Tutorial 7: Building a Coding Agent
Time: ~90 minutes Prerequisites: Rust 1.85+, completion of Tutorial 5: File and Shell Operations, basic familiarity with async Rust
Background: Function Calling — how LLMs invoke tools; ReAct — the reason-then-act loop that coding agents use.
In this tutorial you will build a terminal-based coding assistant — similar in spirit to tools like OpenCode or Aider — using the Synwire agent runtime. By the end you will have a working binary that:
- Reads and writes files inside a project directory
- Searches source code for patterns
- Runs shell commands (
cargo build,cargo test, linters) - Streams output to the terminal as the model generates it
- Resumes conversations across sessions
- Asks for approval before running shell commands
📖 Rust note: A trait is Rust's equivalent of an interface. Synwire's
VfsandToolare traits — they define a contract that multiple implementations satisfy, letting you swap backends without changing the agent.
What makes a coding agent different
A plain chatbot responds with text. A coding agent calls tools on every turn:
- Model receives the user's request plus the conversation history.
- Model emits a tool call (e.g.,
read_file("src/main.rs")). - Runtime executes the tool and appends the result to the conversation.
- Model receives the tool result and decides whether to call another tool or respond.
- Repeat until the model emits a text response with no tool calls.
This loop — called ReAct (Reason + Act) — continues until the model decides the task is done. Synwire's Runner drives this loop automatically. Your job is to define the tools and the system prompt.
sequenceDiagram
participant User
participant Runner
participant Model
participant Tools
User->>Runner: "Fix the failing test in lib.rs"
loop ReAct
Runner->>Model: conversation + tool schemas
Model->>Runner: ToolCall(read_file, "src/lib.rs")
Runner->>Tools: read_file("src/lib.rs")
Tools->>Runner: file content
Runner->>Model: conversation + tool result
Model->>Runner: ToolCall(write_file, "src/lib.rs", fixed_content)
Runner->>Tools: write_file(...)
Tools->>Runner: OK
Runner->>Model: conversation + tool result
Model->>Runner: ToolCall(run_command, "cargo test")
Runner->>Tools: cargo test
Tools->>Runner: "test result: ok. 3 passed"
Runner->>Model: conversation + tool result
Model->>Runner: TextDelta("All tests are passing now.")
end
Runner->>User: stream events
Step 1: Project setup
Create a new binary crate:
cargo new synwire-coder
cd synwire-coder
Add dependencies to Cargo.toml:
[package]
name = "synwire-coder"
version = "0.1.0"
edition = "2024"
[dependencies]
# Agent builder and Runner
synwire-core = "0.1"
synwire-agent = "0.1"
# OpenAI provider (swap for synwire-llm-ollama for local inference)
synwire-llm-openai = "0.1"
# Async runtime
tokio = { version = "1", features = ["full"] }
# JSON tool schemas and input parsing
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = { version = "0.8", features = ["derive"] }
# Error handling
anyhow = "1"
# Command-line argument parsing
clap = { version = "4", features = ["derive"] }
[dev-dependencies]
synwire-test-utils = "0.1"
📖 Rust note:
schemarsderives JSON Schema from your Rust types. The#[tool]attribute macro insynwire-derivecalls it automatically to generate the schema that the model uses to format tool call arguments.
Step 2: The five core tools
A coding agent needs exactly five tools to handle most tasks. Create src/tools.rs:
📖 Rust note:
serde::Deserializeandschemars::JsonSchemaon the same struct serve different purposes:Deserializeparses JSON arguments at runtime;JsonSchemagenerates the schema at startup that tells the model what arguments to supply.
#![allow(unused)] fn main() { // src/tools.rs use std::sync::Arc; use anyhow::Context; use schemars::JsonSchema; use serde::Deserialize; use serde_json::Value; use synwire_core::error::SynwireError; use synwire_core::tools::{StructuredTool, ToolOutput, ToolSchema}; use crate::backend::ProjectBackend; // ───────────────────────────────────────────────────────────────────────────── // 1. read_file // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct ReadFileInput { /// Path to the file, relative to the project root. pub path: String, /// Optional: only return lines `start_line` through `end_line` (1-indexed). pub start_line: Option<usize>, pub end_line: Option<usize>, } pub fn read_file_tool(backend: Arc<ProjectBackend>) -> Result<StructuredTool, SynwireError> { StructuredTool::builder() .name("read_file") .description( "Read the contents of a file in the project. \ Use start_line and end_line to retrieve a window when the file is large.", ) .schema(ToolSchema { name: "read_file".into(), description: "Read a project file".into(), parameters: schemars::schema_for!(ReadFileInput) .schema .into_object() .into(), }) .func(move |args: Value| { let backend = Arc::clone(&backend); Box::pin(async move { let input: ReadFileInput = serde_json::from_value(args) .context("invalid read_file arguments") .map_err(|e| SynwireError::Tool(e.to_string()))?; let content = backend .read(&input.path) .await .map_err(|e| SynwireError::Tool(e.to_string()))?; let text = String::from_utf8_lossy(&content.content).into_owned(); // Apply optional line window. let output = match (input.start_line, input.end_line) { (Some(start), Some(end)) => text .lines() .enumerate() .filter(|(i, _)| *i + 1 >= start && *i + 1 <= end) .map(|(_, line)| line) .collect::<Vec<_>>() .join("\n"), _ => text, }; Ok(ToolOutput { content: output, ..Default::default() }) }) }) .build() } // ───────────────────────────────────────────────────────────────────────────── // 2. write_file // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct WriteFileInput { /// Path to create or overwrite, relative to the project root. pub path: String, /// Complete new content for the file. pub content: String, } pub fn write_file_tool(backend: Arc<ProjectBackend>) -> Result<StructuredTool, SynwireError> { StructuredTool::builder() .name("write_file") .description( "Write content to a file, creating it if it does not exist. \ Overwrites the entire file. Always write the complete file content — \ do not write partial content.", ) .schema(ToolSchema { name: "write_file".into(), description: "Write a project file".into(), parameters: schemars::schema_for!(WriteFileInput) .schema .into_object() .into(), }) .func(move |args: Value| { let backend = Arc::clone(&backend); Box::pin(async move { let input: WriteFileInput = serde_json::from_value(args) .context("invalid write_file arguments") .map_err(|e| SynwireError::Tool(e.to_string()))?; backend .write(&input.path, input.content.as_bytes()) .await .map_err(|e| SynwireError::Tool(e.to_string()))?; Ok(ToolOutput { content: format!("Wrote {} bytes to {}", input.content.len(), input.path), ..Default::default() }) }) }) .build() } // ───────────────────────────────────────────────────────────────────────────── // 3. list_dir // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct ListDirInput { /// Directory to list, relative to the project root. Defaults to "." (root). pub path: Option<String>, } pub fn list_dir_tool(backend: Arc<ProjectBackend>) -> Result<StructuredTool, SynwireError> { StructuredTool::builder() .name("list_dir") .description( "List the contents of a directory. \ Returns file names and whether each entry is a file or directory.", ) .schema(ToolSchema { name: "list_dir".into(), description: "List a directory".into(), parameters: schemars::schema_for!(ListDirInput) .schema .into_object() .into(), }) .func(move |args: Value| { let backend = Arc::clone(&backend); Box::pin(async move { let input: ListDirInput = serde_json::from_value(args) .context("invalid list_dir arguments") .map_err(|e| SynwireError::Tool(e.to_string()))?; let path = input.path.as_deref().unwrap_or("."); let entries = backend .ls(path) .await .map_err(|e| SynwireError::Tool(e.to_string()))?; let lines: Vec<String> = entries .iter() .map(|e| { if e.is_dir { format!("{}/", e.name) } else { e.name.clone() } }) .collect(); Ok(ToolOutput { content: lines.join("\n"), ..Default::default() }) }) }) .build() } // ───────────────────────────────────────────────────────────────────────────── // 4. search_code // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct SearchCodeInput { /// Regular expression pattern to search for. pub pattern: String, /// Restrict search to this subdirectory (optional). pub path: Option<String>, /// File glob filter, e.g. "*.rs" (optional). pub glob: Option<String>, /// Number of context lines before and after each match. pub context_lines: Option<u32>, /// Case-insensitive search. pub case_insensitive: Option<bool>, } pub fn search_code_tool(backend: Arc<ProjectBackend>) -> Result<StructuredTool, SynwireError> { StructuredTool::builder() .name("search_code") .description( "Search for a regex pattern across the project files. \ Returns matching lines with optional context. \ Use glob to restrict to a file type (e.g. '*.rs', '*.toml').", ) .schema(ToolSchema { name: "search_code".into(), description: "Grep the project files".into(), parameters: schemars::schema_for!(SearchCodeInput) .schema .into_object() .into(), }) .func(move |args: Value| { use synwire_core::vfs::grep_options::GrepOptions; let backend = Arc::clone(&backend); Box::pin(async move { let input: SearchCodeInput = serde_json::from_value(args) .context("invalid search_code arguments") .map_err(|e| SynwireError::Tool(e.to_string()))?; let ctx = input.context_lines.unwrap_or(2); let opts = GrepOptions { path: input.path, glob: input.glob, before_context: ctx, after_context: ctx, case_insensitive: input.case_insensitive.unwrap_or(false), line_numbers: true, ..GrepOptions::default() }; let matches = backend .grep(&input.pattern, opts) .await .map_err(|e| SynwireError::Tool(e.to_string()))?; if matches.is_empty() { return Ok(ToolOutput { content: "No matches found.".into(), ..Default::default() }); } // Format like ripgrep terminal output. let mut out = String::new(); let mut last_file = String::new(); for m in &matches { if m.file != last_file { if !last_file.is_empty() { out.push('\n'); } out.push_str(&format!("{}:\n", m.file)); last_file = m.file.clone(); } for line in &m.before { out.push_str(&format!(" {}\n", line)); } out.push_str(&format!( " {:>4}: {}\n", m.line_number, m.line_content )); for line in &m.after { out.push_str(&format!(" {}\n", line)); } } Ok(ToolOutput { content: out, ..Default::default() }) }) }) .build() } // ───────────────────────────────────────────────────────────────────────────── // 5. run_command // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct RunCommandInput { /// Command to run, e.g. "cargo". pub command: String, /// Arguments, e.g. ["test", "--", "--nocapture"]. pub args: Vec<String>, /// Optional timeout in seconds. Defaults to 120. pub timeout_secs: Option<u64>, } pub fn run_command_tool(backend: Arc<ProjectBackend>) -> Result<StructuredTool, SynwireError> { StructuredTool::builder() .name("run_command") .description( "Run a shell command in the project root directory. \ Use for 'cargo build', 'cargo test', 'cargo clippy', 'cargo fmt', \ and similar development commands. \ Returns stdout, stderr, and the exit code.", ) .schema(ToolSchema { name: "run_command".into(), description: "Execute a shell command".into(), parameters: schemars::schema_for!(RunCommandInput) .schema .into_object() .into(), }) .func(move |args: Value| { use std::time::Duration; let backend = Arc::clone(&backend); Box::pin(async move { let input: RunCommandInput = serde_json::from_value(args) .context("invalid run_command arguments") .map_err(|e| SynwireError::Tool(e.to_string()))?; let timeout = input.timeout_secs.map(Duration::from_secs); let resp = backend .execute_cmd(&input.command, &input.args, timeout) .await .map_err(|e| SynwireError::Tool(e.to_string()))?; // Format output for model consumption. let mut out = String::new(); if !resp.stdout.is_empty() { out.push_str("stdout:\n"); out.push_str(&resp.stdout); } if !resp.stderr.is_empty() { if !out.is_empty() { out.push('\n'); } out.push_str("stderr:\n"); out.push_str(&resp.stderr); } out.push_str(&format!("\nexit code: {}", resp.exit_code)); Ok(ToolOutput { content: out, ..Default::default() }) }) }) .build() } }
📖 Rust note:
Box::pin(async move { ... })is required because async closures can't be used directly in trait objects. Themovekeyword transfers ownership of captured variables (backend,input) into the async block. See the Rust async book for a full explanation.
Step 3: The backend
The agent needs access to the project's filesystem and the ability to run commands. Shell wraps LocalProvider and adds command execution. Create src/backend.rs:
📖 Rust note:
Arc<T>(Atomically Reference Counted) lets multiple tools share ownership of the same backend without copying it. Calling.clone()on anArccreates another pointer to the same data — no allocation.
#![allow(unused)] fn main() { // src/backend.rs use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use synwire_agent::vfs::local_shell::Shell; use synwire_core::vfs::error::VfsError; use synwire_core::vfs::protocol::Vfs; /// Wraps Shell as a shared, Arc-compatible project backend. /// /// The Arc lets multiple tools borrow the same backend concurrently /// without copying it. pub type ProjectBackend = dyn Vfs; /// Create the shared backend for a project directory. pub fn create_backend( project_root: PathBuf, ) -> Result<Arc<Shell>, VfsError> { // Inherit PATH and common Rust toolchain variables from the environment. let env: HashMap<String, String> = std::env::vars() .filter(|(k, _)| { matches!( k.as_str(), "PATH" | "HOME" | "CARGO_HOME" | "RUSTUP_HOME" | "RUST_LOG" ) }) .collect(); let backend = Shell::new( project_root, env, 120, // default command timeout: 2 minutes )?; Ok(Arc::new(backend)) } }
Step 4: The system prompt
The system prompt is the most important part of any coding agent. It defines the agent's constraints, tells it which tools to reach for first, and prevents common failure modes. Create src/prompt.rs:
#![allow(unused)] fn main() { // src/prompt.rs /// System prompt for the coding agent. /// /// Design principles: /// - Tell the model to read before writing (prevents hallucinated edits). /// - Tell the model to run tests after editing (validates the change). /// - Constrain the working directory explicitly. /// - Describe each tool's purpose so the model reaches for the right one. pub fn system_prompt(project_root: &str) -> String { format!( r#"You are a coding assistant with direct access to the files and shell in a \ Rust project located at `{project_root}`. # Core workflow When asked to make a change: 1. Use `list_dir` to orient yourself if you haven't seen the project yet. 2. Use `read_file` to read the relevant files before editing them. 3. Use `search_code` to find definitions, usages, or error messages when you \ don't know where something is. 4. Use `write_file` to make the edit. Always write the complete file — never \ write partial content. 5. After editing, run `cargo check` with `run_command` to verify the code \ compiles. 6. Run `cargo test` to check for regressions. 7. If tests fail, read the error output carefully, fix the code, and re-test. # Tool guidance - **read_file**: Use `start_line`/`end_line` to read large files in sections. \ Check `list_dir` first if you're not sure the file exists. - **write_file**: Always write the complete file content. The tool overwrites \ the entire file. - **search_code**: Use for finding function definitions, error messages, or \ usages of a type. Prefer `*.rs` glob for Rust files. - **run_command**: Prefer `cargo check` over `cargo build` for speed. Run \ `cargo clippy` when asked to improve code quality. Run `cargo fmt` when \ asked to format. - **list_dir**: Start here when exploring an unfamiliar project. # Constraints - Only modify files inside the project root. - Do not install system packages or modify global configuration. - If a change is destructive or irreversible, explain what you are about to \ do before calling write_file or run_command. When the task is complete, summarise what you changed and why. "# ) } }
Step 5: Building the agent
Now assemble the tools and agent. Create src/agent.rs:
📖 Rust note:
Result<T, E>is Rust's error type. The?operator unwrapsOk(value)or immediately returns theErrto the caller — it replaces the boilerplatematch result { Ok(v) => v, Err(e) => return Err(e.into()) }.
#![allow(unused)] fn main() { // src/agent.rs use std::sync::Arc; use synwire_core::agents::agent_node::Agent; use synwire_core::agents::permission::{PermissionBehavior, PermissionMode, PermissionRule}; use synwire_core::error::SynwireError; use crate::backend::ProjectBackend; use crate::prompt::system_prompt; use crate::tools::{ list_dir_tool, read_file_tool, run_command_tool, search_code_tool, write_file_tool, }; /// Configuration for the coding agent. pub struct CoderAgentConfig { /// LLM model to use (e.g. "claude-opus-4-6"). pub model: String, /// Absolute path to the project root shown in the system prompt. pub project_root: String, /// Whether to require approval before running shell commands. pub require_command_approval: bool, } /// Build the coding agent with all five tools registered. pub fn build_coding_agent( backend: Arc<dyn ProjectBackend>, config: CoderAgentConfig, ) -> Result<Agent, SynwireError> { let system = system_prompt(&config.project_root); // Register tools — each tool captures an Arc<backend> clone. let read = read_file_tool(Arc::clone(&backend))?; let write = write_file_tool(Arc::clone(&backend))?; let ls = list_dir_tool(Arc::clone(&backend))?; let search = search_code_tool(Arc::clone(&backend))?; let run = run_command_tool(Arc::clone(&backend))?; // Permission rules: file tools auto-approve; shell commands require // confirmation unless require_command_approval is false. let permission_rules = if config.require_command_approval { vec![ PermissionRule { tool_pattern: "read_file".into(), behavior: PermissionBehavior::Allow, }, PermissionRule { tool_pattern: "list_dir".into(), behavior: PermissionBehavior::Allow, }, PermissionRule { tool_pattern: "search_code".into(), behavior: PermissionBehavior::Allow, }, PermissionRule { tool_pattern: "write_file".into(), behavior: PermissionBehavior::Ask, }, PermissionRule { tool_pattern: "run_command".into(), behavior: PermissionBehavior::Ask, }, ] } else { vec![PermissionRule { tool_pattern: "*".into(), behavior: PermissionBehavior::Allow, }] }; let agent = Agent::new("coder", &config.model) .description("A coding assistant with filesystem and shell access") .system_prompt(system) .tool(read) .tool(write) .tool(ls) .tool(search) .tool(run) .permission_mode(PermissionMode::DenyUnauthorized) .permission_rules(permission_rules) .max_turns(50); Ok(agent) } }
Step 6: The terminal REPL
The REPL reads user input, runs the agent, and streams events to the terminal. Create src/repl.rs:
📖 Rust note: Rust's
matchis exhaustive — the compiler forces you to handle every variant.#[non_exhaustive]onAgentEventmeans new variants may appear in future releases, which is why the_catch-all arm is required.
#![allow(unused)] fn main() { // src/repl.rs use std::io::{self, BufRead, Write}; use synwire_core::agents::runner::{Runner, RunnerConfig}; use synwire_core::agents::streaming::AgentEvent; use crate::approval::handle_approval; /// Run the interactive REPL until the user types "exit" or EOF. /// /// `session_id` is used to resume conversations across process restarts. pub async fn run_repl( runner: &Runner, session_id: &str, ) -> anyhow::Result<()> { let stdin = io::stdin(); let mut stdout = io::stdout(); println!("Coding agent ready. Type your request, or 'exit' to quit."); println!("Session: {session_id}"); println!(); loop { // Print the prompt and flush so it appears before blocking. print!("you> "); stdout.flush()?; // Read one line of input. let mut line = String::new(); let n = stdin.lock().read_line(&mut line)?; if n == 0 || line.trim() == "exit" { println!("Goodbye."); break; } let input = line.trim().to_string(); if input.is_empty() { continue; } // Run the agent with the current session_id so conversation // history is preserved across turns. let config = RunnerConfig { session_id: Some(session_id.to_string()), ..RunnerConfig::default() }; let mut stream = runner.run(serde_json::json!(input), config).await?; println!(); print!("agent> "); stdout.flush()?; // Drain the event stream. while let Some(event) = stream.recv().await { match event { AgentEvent::TextDelta { content } => { print!("{content}"); stdout.flush()?; } AgentEvent::ToolCallStart { name, .. } => { println!(); println!("\x1b[2m[calling {name}]\x1b[0m"); } AgentEvent::ToolResult { output, .. } => { // Show a preview of tool results so the user can // see what the agent is reading. let preview: String = output.content.chars().take(120).collect(); let ellipsis = if output.content.len() > 120 { "…" } else { "" }; println!("\x1b[2m → {preview}{ellipsis}\x1b[0m"); } AgentEvent::StatusUpdate { status, .. } => { println!("\x1b[2m[{status}]\x1b[0m"); } AgentEvent::UsageUpdate { usage } => { // Print token usage at end of turn. println!( "\n\x1b[2m[tokens: {}↑ {}↓]\x1b[0m", usage.input_tokens, usage.output_tokens ); } AgentEvent::TurnComplete { reason } => { println!("\n\x1b[2m[done: {reason:?}]\x1b[0m"); } AgentEvent::Error { message } => { eprintln!("\n\x1b[31merror: {message}\x1b[0m"); } _ => { // Ignore other events (ToolCallDelta, DirectiveEmitted, etc.) } } } println!(); } Ok(()) } }
Step 7: Approval gates
When PermissionBehavior::Ask is set for a tool, Synwire calls an approval callback before executing that tool call. The callback receives the proposed Directive and must return ApprovalDecision. Create src/approval.rs:
#![allow(unused)] fn main() { // src/approval.rs use std::io::{self, Write}; use synwire_core::agents::directive::Directive; use synwire_agent::vfs::threshold_gate::{ApprovalDecision, ApprovalRequest}; /// Interactive approval callback for write_file and run_command. /// /// Returns Allow, AllowAlways (never ask again), or Deny. pub async fn handle_approval(request: &ApprovalRequest) -> ApprovalDecision { let tool_name = match &request.directive { Directive::Emit { event } => { // Extract tool name from ToolCall events if present. format!("{event:?}") } _ => format!("{:?}", request.directive), }; // In practice, the approval request arrives with the tool name and // arguments already formatted. Display them to the user. eprintln!(); eprintln!("\x1b[33m⚠ Approval required\x1b[0m"); eprintln!(" Tool: {}", request.tool_name.as_deref().unwrap_or("unknown")); eprintln!(" Risk: {:?}", request.risk_level); if let Some(args) = &request.tool_args { eprintln!(" Args: {}", serde_json::to_string_pretty(args).unwrap_or_default()); } eprint!(" Allow? [y]es / [a]lways / [n]o: "); io::stderr().flush().ok(); let mut line = String::new(); io::stdin().read_line(&mut line).ok(); match line.trim() { "y" | "yes" => ApprovalDecision::Allow, "a" | "always" => ApprovalDecision::AllowAlways, _ => ApprovalDecision::Deny, } } }
Step 8: The main binary
Wire everything together in src/main.rs:
📖 Rust note:
#[tokio::main]transformsasync fn maininto a standard synchronous entry point by starting the Tokio runtime. Without it, you cannot.awaitat the top level.
// src/main.rs mod agent; mod approval; mod backend; mod prompt; mod repl; mod tools; use std::path::PathBuf; use clap::Parser; use synwire_agent::runner::Runner; use crate::agent::{CoderAgentConfig, build_coding_agent}; use crate::backend::create_backend; use crate::repl::run_repl; /// A Synwire-powered coding assistant. #[derive(Parser)] #[command(version, about)] struct Cli { /// Project root directory to operate on. #[arg(short, long, default_value = ".")] project: PathBuf, /// LLM model identifier. #[arg(short, long, default_value = "claude-opus-4-6")] model: String, /// Session ID for conversation continuity (auto-generated if omitted). #[arg(short, long)] session: Option<String>, /// Automatically approve write and shell operations without prompting. #[arg(long)] auto_approve: bool, } #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); // Resolve the project root to an absolute path. let project_root = cli.project.canonicalize() .unwrap_or_else(|_| cli.project.clone()); let project_root_str = project_root.to_string_lossy().into_owned(); // Create the shared backend scoped to the project root. let backend = create_backend(project_root)?; // Build the agent with the five tools. let agent_config = CoderAgentConfig { model: cli.model, project_root: project_root_str, require_command_approval: !cli.auto_approve, }; let agent = build_coding_agent(backend, agent_config)?; // Wrap in a Runner. let runner = Runner::new(agent); // Use the provided session ID or generate a new one. let session_id = cli.session.unwrap_or_else(|| { uuid::Uuid::new_v4().to_string() }); // Run the interactive REPL. run_repl(&runner, &session_id).await?; Ok(()) }
Add uuid to your dependencies for session ID generation:
uuid = { version = "1", features = ["v4"] }
Step 9: Testing without side effects
Use FakeChatModel to test agent behaviour without an API key or real files. Create tests/agent_tools.rs:
📖 Rust note:
#[tokio::test]is the async equivalent of#[test]. It starts the Tokio runtime for the duration of the test function, which is required because all Synwire operations are async.
// tests/agent_tools.rs use std::sync::Arc; use synwire_core::vfs::state_backend::MemoryProvider; use synwire_test_utils::fake_chat_model::FakeChatModel; use synwire_test_utils::recording_executor::RecordingExecutor; use synwire_coder::agent::{CoderAgentConfig, build_coding_agent}; use synwire_coder::tools::read_file_tool; #[tokio::test] async fn read_file_tool_returns_content() { // Use MemoryProvider — ephemeral, no real filesystem. let backend = Arc::new(MemoryProvider::new()); backend .write("/src/main.rs", b"fn main() { println!(\"hello\"); }") .await .unwrap(); let tool = read_file_tool(Arc::clone(&backend) as Arc<_>).unwrap(); let output = tool .invoke(serde_json::json!({ "path": "/src/main.rs" })) .await .unwrap(); assert!(output.content.contains("println!")); } #[tokio::test] async fn read_file_with_line_window() { let backend = Arc::new(MemoryProvider::new()); let content = "line1\nline2\nline3\nline4\nline5"; backend.write("/file.txt", content.as_bytes()).await.unwrap(); let tool = read_file_tool(Arc::clone(&backend) as Arc<_>).unwrap(); let output = tool .invoke(serde_json::json!({ "path": "/file.txt", "start_line": 2, "end_line": 3 })) .await .unwrap(); assert_eq!(output.content.trim(), "line2\nline3"); assert!(!output.content.contains("line1"), "line1 should be excluded"); assert!(!output.content.contains("line5"), "line5 should be excluded"); } /// Test the full agent turn loop with a fake model. /// The fake model emits a tool call then a text response. #[tokio::test] async fn agent_calls_read_file_then_responds() { use synwire_core::agents::runner::{Runner, RunnerConfig}; use synwire_core::agents::streaming::AgentEvent; let backend = Arc::new(MemoryProvider::new()); backend .write("/lib.rs", b"pub fn add(a: i32, b: i32) -> i32 { a + b }") .await .unwrap(); // FakeChatModel returns pre-programmed responses in order. // Response 1: a tool call to read_file. // Response 2: text after seeing the file content. let model = FakeChatModel::new(vec![ // The model calls read_file on the first turn. r#"{"tool_calls": [{"id": "tc1", "name": "read_file", "arguments": {"path": "/lib.rs"}}]}"#.into(), // After seeing the file, it responds with text. "The `add` function takes two i32 arguments and returns their sum.".into(), ]); let agent_config = CoderAgentConfig { model: model.model_name().to_string(), project_root: "/".to_string(), require_command_approval: false, }; let agent = build_coding_agent( Arc::clone(&backend) as Arc<_>, agent_config, ) .unwrap(); let runner = Runner::new(agent).with_model(model); let config = RunnerConfig::default(); let mut stream = runner .run(serde_json::json!("Explain the add function."), config) .await .unwrap(); let mut text = String::new(); let mut tool_called = false; while let Some(event) = stream.recv().await { match event { AgentEvent::ToolCallStart { name, .. } if name == "read_file" => { tool_called = true; } AgentEvent::TextDelta { content } => { text.push_str(&content); } _ => {} } } assert!(tool_called, "agent should have called read_file"); assert!( text.contains("add"), "agent should mention the function" ); }
Run the tests:
cargo test
Step 10: Running the agent
Build and run against your current Rust project:
# Set your API key (if using OpenAI/Anthropic)
export ANTHROPIC_API_KEY="..."
# Run against the current directory
cargo run -- --project . --model claude-opus-4-6
# Run with a fixed session to resume later
cargo run -- --project . --session my-session-001
# Disable approval prompts (auto-approve all tool calls)
cargo run -- --project . --auto-approve
Example session:
Coding agent ready. Type your request, or 'exit' to quit.
Session: my-session-001
you> Why is cargo test failing?
agent>
[calling list_dir]
→ src/ Cargo.toml Cargo.lock README.md tests/…
[calling run_command]
→ stdout:
running 3 tests
test tests::it_works ... FAILED
failures:
tests::it_works
…
[calling read_file]
→ use crate::processor::process;
#[test]
fn it_works() {
assert_eq!(process("hello"), "HELLO");
}…
[calling search_code]
→ src/processor.rs:
3: pub fn process(input: &str) -> String {
4: input.to_lowercase() ← match here
[calling write_file]
⚠ Approval required
Tool: write_file
Risk: Medium
Args: { "path": "src/processor.rs", "content": "pub fn process ..." }
Allow? [y]es / [a]lways / [n]o: y
[calling run_command]
→ stdout: test result: ok. 3 passed
[tokens: 2847↑ 412↓]
[done: Complete]
I found the issue: `process()` returns lowercase but the test expects uppercase.
I changed `to_lowercase()` to `to_uppercase()` in `src/processor.rs`.
All 3 tests now pass.
Step 11: Session continuity
The session_id in RunnerConfig tells the runner to load and save conversation history via InMemorySessionManager. To persist sessions across process restarts, swap the session manager:
#![allow(unused)] fn main() { // In agent.rs, when building the Runner: use synwire_agent::session::InMemorySessionManager; use std::sync::Arc; // In-memory session (lost on exit — good for development): let session_manager = Arc::new(InMemorySessionManager::new()); // For production, implement SessionManager backed by a database. // The runner accepts any Arc<dyn SessionManager>: let runner = Runner::new(agent) .with_session_manager(session_manager); }
With a fixed --session argument, the agent picks up the conversation exactly where it left off, including the full tool call history. This is how multi-session coding workflows — "continue the refactor you started yesterday" — become possible.
Step 12: Extension ideas
Now that you have the foundation, here are natural next steps:
Git-aware operations
Replace LocalProvider with GitBackend to give the agent version control awareness. The agent can then check git diff, create commits, and revert changes:
#![allow(unused)] fn main() { use synwire_agent::vfs::git::GitBackend; let backend = Arc::new(GitBackend::new(&project_root)?); // The agent can now call run_command("git", ["diff", "--stat"]) // and GitBackend enforces the repository boundary. }
Multi-file context via search first
Constrain the agent to always search before reading. Add this to the system prompt:
Before reading any file, always call
search_codewith the symbol name to find where it is defined. Only then callread_fileon the relevant file.
Streaming diffs in the terminal
Intercept write_file tool calls in the approval gate to show a diff before applying:
#![allow(unused)] fn main() { // In approval.rs, compute and show a diff before accepting. use similar::{ChangeTag, TextDiff}; let diff = TextDiff::from_lines(&old_content, &new_content); for change in diff.iter_all_changes() { match change.tag() { ChangeTag::Delete => eprint!("\x1b[31m- {change}\x1b[0m"), ChangeTag::Insert => eprint!("\x1b[32m+ {change}\x1b[0m"), ChangeTag::Equal => eprint!(" {change}"), } } }
Parallel sub-agents
For large refactors, spawn a sub-agent per file using Directive::SpawnAgent. The orchestrator collects results via the directive system without blocking the main conversation.
Local inference with Ollama
Swap the provider without changing any other code:
[dependencies]
synwire-llm-ollama = "0.1"
#![allow(unused)] fn main() { // src/main.rs use synwire_llm_ollama::ChatOllama; // Replace ChatOpenAI with ChatOllama — same BaseChatModel trait. let runner = Runner::new(agent) .with_model(ChatOllama::builder().model("codestral").build()?); }
See Local Inference with Ollama for setup instructions.
Language server intelligence (LSP)
Add the synwire-lsp crate to give the agent semantic code understanding — hover documentation, go-to-definition, find-references, and real-time diagnostics — without reading every file:
[dependencies]
synwire-lsp = "0.1"
use synwire_lsp::plugin::LspPlugin;
use synwire_lsp::registry::LanguageServerRegistry;
use synwire_lsp::config::LspPluginConfig;
let registry = LanguageServerRegistry::default_registry();
let lsp = LspPlugin::new(registry, LspPluginConfig::default());
let agent = Agent::new("coder", "coding assistant")
.plugin(Box::new(lsp))
// ... other configuration
.build()?;
The plugin auto-detects language servers on PATH based on file extension. For a Rust project, if rust-analyzer is installed, tools like lsp_goto_definition, lsp_hover, and lsp_diagnostics become available immediately. Add a line to the system prompt to take advantage:
Before reading an entire file, use
lsp_hoverorlsp_goto_definitionto navigate directly to the relevant symbol. Uselsp_diagnosticsafter edits to check for errors without running a full build.
See How-To: LSP Integration for full details.
Interactive debugging (DAP)
Add the synwire-dap crate so the agent can debug failing tests by setting breakpoints and inspecting runtime values:
[dependencies]
synwire-dap = "0.1"
use synwire_dap::plugin::DapPlugin;
use synwire_dap::registry::DebugAdapterRegistry;
use synwire_dap::config::DapPluginConfig;
let registry = DebugAdapterRegistry::default_registry();
let dap = DapPlugin::new(registry, DapPluginConfig::default());
let agent = Agent::new("coder", "coding assistant")
.plugin(Box::new(dap))
// ... other configuration
.build()?;
The agent gains tools like debug.launch, debug.set_breakpoints, debug.continue, debug.variables, and debug.evaluate. Add a system prompt hint:
When a test fails and the cause is not obvious from the source code, use
debug.launchto run it under the debugger. Set a breakpoint at the failing assertion, then usedebug.variablesto inspect local state.
Note that debug.evaluate is marked as destructive since it can execute arbitrary code in the debuggee — the approval gate will prompt the user before execution.
See How-To: DAP Integration for full details.
What you have built
You now have a working coding agent with:
| Capability | Implementation |
|---|---|
| File read (with line windows) | read_file_tool + LocalProvider |
| File write (full overwrite) | write_file_tool + path traversal protection |
| Directory listing | list_dir_tool |
| Code search (ripgrep-style) | search_code_tool + GrepOptions |
| Shell command execution | run_command_tool + Shell |
| Streaming terminal output | AgentEvent matching in REPL |
| Conversation continuity | RunnerConfig::session_id |
| Approval gates | PermissionBehavior::Ask + handle_approval |
| Safe sandboxing | LocalProvider path traversal protection |
| Unit-testable tools | MemoryProvider + FakeChatModel |
| Code intelligence (hover, goto-def, diagnostics) | LspPlugin + auto-started language server |
| Interactive debugging (breakpoints, variables) | DapPlugin + debug adapter |
See also
- Tutorial 5: File and Shell Operations — deep dive into
Vfs, path traversal protection, andGrepOptions - Tutorial 2: Testing Without Side Effects —
RecordingExecutorfor testing directives without executing them - How-To: Approval Gates —
ThresholdGateandRiskLevel-based approval - How-To: MCP Integration — give the agent access to external tools via the Model Context Protocol
- How-To: Backend Implementations —
CompositeProvider,GitBackend, and custom backends - Explanation: synwire-agent — full reference for the
Runner, middleware, and all backends - Local Inference with Ollama — run the agent locally without an API key
- Explanation: StateGraph vs FsmStrategy — when to use an agent runtime vs. a graph workflow
- How-To: LSP Integration — add code intelligence tools (hover, goto-definition, diagnostics) to your agent
- How-To: DAP Integration — add interactive debugging (breakpoints, stepping, variable inspection)
- Explanation: synwire-lsp — architecture and design of the LSP integration
- Explanation: synwire-dap — architecture and design of the DAP integration
Background: AI Workflows vs AI Agents — the distinction between an orchestrated pipeline and a ReAct agent loop; this tutorial builds the latter. Context Engineering for AI Agents — how the system prompt and tool descriptions shape agent behaviour.
Tutorial 8: Deep Research + Coding Agent
Time: ~2 hours Prerequisites: Rust 1.85+, completion of Tutorial 7: Building a Coding Agent, completion of Tutorial 3: Execution Strategies
Background: AI Workflows vs AI Agents — the distinction between a static multi-step workflow and an open-ended agent loop. This tutorial uses both: the coding agent is an open-ended agent that decides what to do; the deep research tool it can call is a fixed workflow.
This tutorial builds a coding agent that can invoke deep research as a tool. Two Synwire primitives compose to make this work:
| Primitive | Role |
|---|---|
FsmStrategy (synwire-agent) | Outer coding agent: FSM-constrained turn loop |
StateGraph (synwire-orchestrator) | Inner research pipeline: runs inside a tool the agent can call |
The key idea: the StateGraph pipeline is not the top-level entrypoint — it is wrapped as a StructuredTool and handed to an FSM-governed coding agent. The agent decides when to call deep_research, and the FSM ensures it does so before writing any code.
graph TD
subgraph "Coding Agent (FsmStrategy)"
A[Idle] -->|start| B[Researching]
B -->|"deep_research ✓"| C[Implementing]
C -->|"files written"| D[Testing]
D -->|pass| E[Done]
D -->|"fail (retries < 3)"| C
end
subgraph "deep_research tool (StateGraph)"
R1[explore_codebase] --> R2[synthesise_report]
R2 --> R3[END]
end
B -.->|"agent calls deep_research"| R1
R3 -.->|"returns report"| C
- The outer agent uses
FsmStrategyto enforce: research first, then implement, then test. - The inner tool uses
StateGraphto chain two nodes: codebase exploration → report synthesis. - The agent sees
deep_researchas just another tool — it doesn't know there's a graph inside.
📖 Rust note: A
StructuredToolwraps a closureFn(Value) -> BoxFuture<Result<ToolOutput, SynwireError>>. BecauseStateGraph::invokeis an async function that returnsResult<S, GraphError>, wrapping a graph as a tool is a one-liner: callinvokeinside the closure, serialise the result.
Architecture: why the agent holds the graph, not the other way round
In Tutorial 7 the coding agent was a standalone tool-calling loop. In this tutorial, the agent gains a research capability — but the research itself is a multi-step pipeline that's too complex for a single tool call.
You could build this two ways:
| Approach | Topology | Agent autonomy |
|---|---|---|
| Graph-first (Tutorial 8 v1) | StateGraph is the top level; the agent is a node | Low — stages are fixed at compile time |
| Agent-first (this tutorial) | Agent is the top level; the graph is a tool | High — agent decides when and whether to research |
The agent-first approach is more powerful: the agent can skip research for trivial tasks, call it multiple times for complex ones, or interleave research with implementation. The FSM still prevents it from writing code without researching first — you get autonomy within structural bounds.
Background: Agent Components — the deep research tool is the agent's memory retrieval component. The FSM is its planning component. Tools are its action component.
Step 1: Project setup
[package]
name = "synwire-research-coder"
version = "0.1.0"
edition = "2024"
[dependencies]
# Agent runtime + FSM strategy
synwire-core = "0.1"
synwire-agent = "0.1"
# Graph orchestration (for the research tool's internal pipeline)
synwire-orchestrator = "0.1"
synwire-derive = "0.1"
# LLM provider
synwire-llm-openai = "0.1"
# Async + serde
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = { version = "0.8", features = ["derive"] }
# Error handling + CLI
anyhow = "1"
clap = { version = "4", features = ["derive"] }
[dev-dependencies]
synwire-test-utils = "0.1"
tempfile = "3"
Step 2: The research pipeline state
The research graph has its own typed state, separate from the outer agent's working memory. This state flows through the two graph nodes and is discarded after the tool call completes — only the final report string is returned to the agent.
📖 Rust note:
#[derive(State)]generates theStatetrait implementation.#[reducer(last_value)]overwrites on each superstep;#[reducer(topic)]appends. See synwire-derive.
#![allow(unused)] fn main() { // src/research/state.rs use serde::{Deserialize, Serialize}; use synwire_derive::State; /// Internal state for the deep research pipeline. /// /// This state is NOT exposed to the outer coding agent. /// Only `report` is extracted and returned as the tool result. #[derive(State, Debug, Clone, Default, Serialize, Deserialize)] pub struct ResearchState { /// The question or task to research. #[reducer(last_value)] pub query: String, /// Absolute path to the project root. #[reducer(last_value)] pub project_root: String, /// Files discovered and read during exploration. #[reducer(topic)] pub files_explored: Vec<String>, /// Raw findings from the exploration node (file contents, search hits). #[reducer(topic)] pub raw_findings: Vec<String>, /// Final synthesised report returned to the caller. #[reducer(last_value)] pub report: String, } }
Step 3: The exploration node
The first graph node uses a DirectStrategy agent to explore the codebase. It reads files, searches for patterns, and records raw findings.
#![allow(unused)] fn main() { // src/research/explore.rs use std::sync::Arc; use synwire_core::agents::agent_node::Agent; use synwire_core::agents::runner::{Runner, RunnerConfig}; use synwire_core::agents::streaming::AgentEvent; use synwire_orchestrator::error::GraphError; use crate::backend::create_backend; use crate::research::state::ResearchState; use crate::tools::{list_dir_tool, read_file_tool, search_code_tool}; /// Exploration node: agent reads the codebase and populates `raw_findings`. pub async fn explore_node(mut state: ResearchState) -> Result<ResearchState, GraphError> { let backend = create_backend(state.project_root.clone().into()) .map_err(|e| GraphError::NodeError { message: e.to_string() })?; let backend = Arc::new(backend); let system = format!( r#"You are a code researcher exploring a Rust codebase. Your goal: gather all information relevant to this question: {query} Use list_dir to understand project structure, search_code to find relevant symbols and patterns, and read_file to read the actual source. Focus on: - Relevant types, traits, and functions - Test patterns and conventions - Module boundaries and public APIs - Any existing code that relates to the question Output your findings as structured notes — one paragraph per file or concept. Be thorough but focused: only include information that helps answer the question."#, query = state.query, ); let read = read_file_tool(Arc::clone(&backend) as Arc<_>) .map_err(|e| GraphError::NodeError { message: e.to_string() })?; let ls = list_dir_tool(Arc::clone(&backend) as Arc<_>) .map_err(|e| GraphError::NodeError { message: e.to_string() })?; let search = search_code_tool(Arc::clone(&backend) as Arc<_>) .map_err(|e| GraphError::NodeError { message: e.to_string() })?; let agent = Agent::new("explorer", "claude-opus-4-6") .system_prompt(system) .tool(read) .tool(ls) .tool(search) .max_turns(20); let runner = Runner::new(agent); let mut stream = runner .run(serde_json::json!(state.query.clone()), RunnerConfig::default()) .await .map_err(|e| GraphError::NodeError { message: e.to_string() })?; let mut findings = String::new(); while let Some(event) = stream.recv().await { match event { AgentEvent::TextDelta { content } => findings.push_str(&content), AgentEvent::Error { message } => { return Err(GraphError::NodeError { message }); } _ => {} } } state.raw_findings.push(findings); Ok(state) } }
Step 4: The synthesis node
The second graph node is a one-shot agent that reads the raw findings and produces a structured report. No tools needed — just reasoning.
#![allow(unused)] fn main() { // src/research/synthesise.rs use synwire_core::agents::agent_node::Agent; use synwire_core::agents::runner::{Runner, RunnerConfig}; use synwire_core::agents::streaming::AgentEvent; use synwire_orchestrator::error::GraphError; use crate::research::state::ResearchState; /// Synthesis node: distils raw findings into a structured report. pub async fn synthesise_node(mut state: ResearchState) -> Result<ResearchState, GraphError> { let system = r#"You receive raw findings from a codebase exploration and produce a structured research report. Format the report as markdown with these sections: 1. **Relevant Code** — files, types, and functions that relate to the task 2. **Data Types** — key structs, enums, and traits the implementation must use 3. **Test Patterns** — how existing tests are structured in this project 4. **Integration Points** — where new code should fit in 5. **Constraints** — things the implementation must not break Be concise. The implementation agent will use this report to guide its work."#; let prompt = format!( "Question: {}\n\nRaw findings:\n{}", state.query, state.raw_findings.join("\n\n---\n\n"), ); let agent = Agent::new("synthesiser", "claude-opus-4-6") .system_prompt(system) .max_turns(1); let runner = Runner::new(agent); let mut stream = runner .run(serde_json::json!(prompt), RunnerConfig::default()) .await .map_err(|e| GraphError::NodeError { message: e.to_string() })?; let mut report = String::new(); while let Some(event) = stream.recv().await { match event { AgentEvent::TextDelta { content } => report.push_str(&content), AgentEvent::Error { message } => { return Err(GraphError::NodeError { message }); } _ => {} } } state.report = report; Ok(state) } }
Step 5: Assembling the research graph
Two nodes, one edge, no conditionals:
#![allow(unused)] fn main() { // src/research/graph.rs use synwire_orchestrator::constants::END; use synwire_orchestrator::error::GraphError; use synwire_orchestrator::graph::{CompiledGraph, StateGraph}; use crate::research::explore::explore_node; use crate::research::state::ResearchState; use crate::research::synthesise::synthesise_node; /// Build and compile the two-stage research pipeline. /// /// ```text /// START → explore → synthesise → END /// ``` pub fn build_research_graph() -> Result<CompiledGraph<ResearchState>, GraphError> { let mut graph = StateGraph::<ResearchState>::new(); graph.add_node("explore", Box::new(|s| Box::pin(explore_node(s))))?; graph.add_node("synthesise", Box::new(|s| Box::pin(synthesise_node(s))))?; graph .set_entry_point("explore") .add_edge("explore", "synthesise") .add_edge("synthesise", END); graph.compile() } }
Step 6: Wrapping the graph as a tool
This is the composition point. The StateGraph pipeline becomes a StructuredTool — the agent sees it as a single function call that takes a query and returns a research report.
📖 Rust note:
Arc(Atomic Reference Count) allows multiple owners of a value across threads. The compiled graph isSend + Sync, so wrapping it inArclets the tool closure capture it safely. The closure itself must beFn(notFnOnce) because it may be called multiple times.
#![allow(unused)] fn main() { // src/research/tool.rs use std::sync::Arc; use synwire_core::BoxFuture; use synwire_core::error::SynwireError; use synwire_core::tools::{StructuredTool, ToolOutput, ToolSchema}; use crate::research::graph::build_research_graph; use crate::research::state::ResearchState; /// Creates the `deep_research` tool. /// /// When invoked, this tool: /// 1. Builds a `ResearchState` from the JSON input /// 2. Runs the two-stage `StateGraph` (explore → synthesise) /// 3. Returns the synthesised report as the tool output /// /// The calling agent never sees the internal graph topology — /// it receives a plain-text research report. pub fn deep_research_tool( project_root: String, ) -> Result<StructuredTool, SynwireError> { // Compile the graph once; share it across invocations via Arc. let graph = Arc::new( build_research_graph() .map_err(|e| SynwireError::Tool( synwire_core::error::ToolError::InvocationFailed { message: format!("failed to compile research graph: {e}"), }, ))?, ); let root = project_root.clone(); StructuredTool::builder() .name("deep_research") .description( "Performs deep codebase research. Takes a question about the codebase \ and returns a structured report covering relevant code, data types, \ test patterns, integration points, and constraints. Use this BEFORE \ writing any code to understand the existing codebase." ) .schema(ToolSchema { name: "deep_research".into(), description: "Deep codebase research tool".into(), parameters: serde_json::json!({ "type": "object", "properties": { "query": { "type": "string", "description": "The research question about the codebase" } }, "required": ["query"] }), }) .func(move |input: serde_json::Value| -> BoxFuture<'static, Result<ToolOutput, SynwireError>> { let graph = Arc::clone(&graph); let project_root = root.clone(); Box::pin(async move { let query = input["query"] .as_str() .unwrap_or("general codebase overview") .to_owned(); let initial = ResearchState { query, project_root, ..ResearchState::default() }; // Run the two-stage pipeline to completion. let result = graph.invoke(initial).await.map_err(|e| { SynwireError::Tool(synwire_core::error::ToolError::InvocationFailed { message: format!("research pipeline failed: {e}"), }) })?; Ok(ToolOutput { content: result.report, ..Default::default() }) }) }) .build() } }
What just happened
The entire StateGraph — two agents, file I/O tools, a synthesis step — is now hidden behind a single deep_research(query: String) -> String interface. From the outer coding agent's perspective, it's no different from read_file or search_code. But internally, it spawns two sub-agents, explores the codebase, and synthesises findings.
This is the composability the crate architecture enables: synwire-orchestrator and synwire-agent are independent crates that compose through synwire-core's Tool trait.
Step 7: The coding agent's FSM
The FSM constrains the coding agent's turn loop. The critical constraint: the agent must call deep_research before it can write any files.
#![allow(unused)] fn main() { // src/fsm.rs use serde_json::Value; use synwire_agent::strategies::fsm::{FsmStrategy, FsmStrategyWithRoutes}; use synwire_core::agents::execution_strategy::{ClosureGuard, StrategyError}; /// FSM states for the coding agent. pub mod states { pub const IDLE: &str = "idle"; pub const RESEARCHING: &str = "researching"; pub const IMPLEMENTING: &str = "implementing"; pub const TESTING: &str = "testing"; pub const DONE: &str = "done"; } /// Actions that drive FSM transitions. pub mod actions { /// Idle → Researching: agent starts working. pub const START: &str = "start"; /// Researching → Implementing: deep_research has been called. pub const RESEARCH_COMPLETE: &str = "research_complete"; /// Implementing → Testing: at least one file written. pub const RUN_TESTS: &str = "run_tests"; /// Testing → Done: tests passed. pub const TESTS_PASS: &str = "tests_pass"; /// Testing → Implementing: tests failed, retry. pub const TESTS_FAIL: &str = "tests_fail"; } /// Payload keys inspected by guards. pub mod payload { pub const RESEARCH_CALLS: &str = "research_calls"; pub const FILES_WRITTEN: &str = "files_written"; pub const RETRIES: &str = "retries"; } /// Build the FSM for the coding agent. /// /// ```text /// idle → [start] → researching /// researching → [research_complete | research_calls > 0] → implementing /// implementing → [run_tests | files_written > 0] → testing /// testing → [tests_pass] → done /// testing → [tests_fail | retries < 3] → implementing /// ``` /// /// Guards enforce three invariants: /// 1. Agent must call `deep_research` at least once before implementing. /// 2. Agent must write at least one file before running tests. /// 3. Test retries are capped at 3. pub fn build_coder_fsm() -> Result<FsmStrategyWithRoutes, StrategyError> { let guard_has_researched = ClosureGuard::new( "has_researched", |p: &Value| p[payload::RESEARCH_CALLS].as_u64().unwrap_or(0) > 0, ); let guard_has_written = ClosureGuard::new( "has_written", |p: &Value| p[payload::FILES_WRITTEN].as_u64().unwrap_or(0) > 0, ); let guard_retry_allowed = ClosureGuard::new( "retry_allowed", |p: &Value| p[payload::RETRIES].as_u64().unwrap_or(0) < 3, ); FsmStrategy::builder() .state(states::IDLE) .state(states::RESEARCHING) .state(states::IMPLEMENTING) .state(states::TESTING) .state(states::DONE) // idle → researching (unconditional) .transition(states::IDLE, actions::START, states::RESEARCHING) // researching → implementing (must have called deep_research) .transition_with_guard( states::RESEARCHING, actions::RESEARCH_COMPLETE, states::IMPLEMENTING, guard_has_researched, 10, ) // implementing → testing (must have written at least one file) .transition_with_guard( states::IMPLEMENTING, actions::RUN_TESTS, states::TESTING, guard_has_written, 10, ) // testing → done .transition(states::TESTING, actions::TESTS_PASS, states::DONE) // testing → implementing (retry if allowed) .transition_with_guard( states::TESTING, actions::TESTS_FAIL, states::IMPLEMENTING, guard_retry_allowed, 10, ) .initial(states::IDLE) .build() } }
Testing the FSM in isolation
The FSM is pure Rust — no model, no I/O:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use synwire_core::agents::execution_strategy::{ExecutionStrategy, StrategyError}; #[tokio::test] async fn cannot_implement_without_research() { let fsm = build_coder_fsm().unwrap(); // Start working. fsm.execute(actions::START, serde_json::json!({})).await.unwrap(); // Try to skip research. let err = fsm .execute( actions::RESEARCH_COMPLETE, serde_json::json!({ payload::RESEARCH_CALLS: 0 }), ) .await .expect_err("guard should reject"); assert!(matches!(err, StrategyError::GuardRejected(_))); } #[tokio::test] async fn research_then_implement_succeeds() { let fsm = build_coder_fsm().unwrap(); fsm.execute(actions::START, serde_json::json!({})).await.unwrap(); fsm.execute( actions::RESEARCH_COMPLETE, serde_json::json!({ payload::RESEARCH_CALLS: 1 }), ) .await .unwrap(); // Now in implementing state — can write files. assert_eq!( fsm.strategy.current_state().unwrap().0, states::IMPLEMENTING, ); } #[tokio::test] async fn cannot_test_without_writing() { let fsm = build_coder_fsm().unwrap(); fsm.execute(actions::START, serde_json::json!({})).await.unwrap(); fsm.execute( actions::RESEARCH_COMPLETE, serde_json::json!({ payload::RESEARCH_CALLS: 1 }), ) .await .unwrap(); let err = fsm .execute( actions::RUN_TESTS, serde_json::json!({ payload::FILES_WRITTEN: 0 }), ) .await .expect_err("guard should reject"); assert!(matches!(err, StrategyError::GuardRejected(_))); } #[tokio::test] async fn retry_capped_at_three() { let fsm = build_coder_fsm().unwrap(); // Advance to testing. fsm.execute(actions::START, serde_json::json!({})).await.unwrap(); fsm.execute( actions::RESEARCH_COMPLETE, serde_json::json!({ payload::RESEARCH_CALLS: 1 }), ) .await .unwrap(); fsm.execute( actions::RUN_TESTS, serde_json::json!({ payload::FILES_WRITTEN: 1 }), ) .await .unwrap(); // Three retries succeed. for i in 0..3 { fsm.execute( actions::TESTS_FAIL, serde_json::json!({ payload::RETRIES: i }), ) .await .unwrap(); fsm.execute( actions::RUN_TESTS, serde_json::json!({ payload::FILES_WRITTEN: 1 }), ) .await .unwrap(); } // Fourth retry rejected. let err = fsm .execute( actions::TESTS_FAIL, serde_json::json!({ payload::RETRIES: 3 }), ) .await .expect_err("retry limit"); assert!(matches!(err, StrategyError::GuardRejected(_))); } #[tokio::test] async fn happy_path_reaches_done() { let fsm = build_coder_fsm().unwrap(); fsm.execute(actions::START, serde_json::json!({})).await.unwrap(); fsm.execute( actions::RESEARCH_COMPLETE, serde_json::json!({ payload::RESEARCH_CALLS: 1 }), ) .await .unwrap(); fsm.execute( actions::RUN_TESTS, serde_json::json!({ payload::FILES_WRITTEN: 2 }), ) .await .unwrap(); fsm.execute(actions::TESTS_PASS, serde_json::json!({})) .await .unwrap(); assert_eq!(fsm.strategy.current_state().unwrap().0, states::DONE); } } }
Step 8: The coding agent
Now assemble the agent with all its tools, including deep_research:
📖 Rust note:
moveclosures take ownership of captured variables. The event-loop closure capturesArcclones of the counters and FSM, so each closure call can mutate the shared counters throughMutex::lock().
#![allow(unused)] fn main() { // src/agent.rs use std::sync::{Arc, Mutex}; use synwire_core::agents::agent_node::Agent; use synwire_core::agents::permission::{PermissionBehavior, PermissionMode, PermissionRule}; use synwire_core::agents::runner::{Runner, RunnerConfig}; use synwire_core::agents::streaming::AgentEvent; use crate::backend::create_backend; use crate::fsm::{actions, build_coder_fsm, payload, states}; use crate::research::tool::deep_research_tool; use crate::tools::{ list_dir_tool, read_file_tool, run_command_tool, search_code_tool, write_file_tool, }; /// Result of running the coding agent. pub struct CodingResult { pub files_written: Vec<String>, pub test_output: String, pub test_passed: bool, pub research_report: String, } /// Build and run the FSM-governed coding agent. /// /// The agent has six tools: /// /// | Tool | Purpose | FSM constraint | /// |---|---|---| /// | `deep_research` | Runs the StateGraph research pipeline | Must call before implementing | /// | `read_file` | Read source files | Allowed in all states | /// | `list_dir` | List directories | Allowed in all states | /// | `search_code` | Search for patterns | Allowed in all states | /// | `write_file` | Write source files | Only after research | /// | `run_command` | Run cargo test | Only after writing files | pub async fn run_coding_agent( task: &str, project_root: &str, ) -> anyhow::Result<CodingResult> { let backend = Arc::new(create_backend(project_root.into())?); // ── Tools ───────────────────────────────────────────────────────────── let deep_research = deep_research_tool(project_root.to_owned())?; let read = read_file_tool(Arc::clone(&backend) as Arc<_>)?; let write = write_file_tool(Arc::clone(&backend) as Arc<_>)?; let ls = list_dir_tool(Arc::clone(&backend) as Arc<_>)?; let search = search_code_tool(Arc::clone(&backend) as Arc<_>)?; let run = run_command_tool(Arc::clone(&backend) as Arc<_>)?; // ── FSM ─────────────────────────────────────────────────────────────── let fsm = Arc::new(build_coder_fsm()?); // Counters for FSM guard payloads. let research_calls: Arc<Mutex<u32>> = Arc::new(Mutex::new(0)); let files_written: Arc<Mutex<u32>> = Arc::new(Mutex::new(0)); let retries: Arc<Mutex<u32>> = Arc::new(Mutex::new(0)); // ── Agent ───────────────────────────────────────────────────────────── let system = format!( r#"You are a coding agent that implements changes to a Rust project. # Your task {task} # Workflow 1. FIRST, call deep_research to understand the existing codebase. Pass a focused question about what you need to understand. 2. Read the research report carefully before writing any code. 3. Implement the changes by writing files. 4. Run `cargo test` to verify your changes. 5. If tests fail, read the error output and fix the code. # Rules - You MUST call deep_research before writing any files. - You MUST write files before running tests. - You may call deep_research multiple times with different questions. - Read files directly with read_file for quick lookups; use deep_research for broader understanding. When all tests pass, state that you are done."#, ); let agent = Agent::new("coder", "claude-opus-4-6") .system_prompt(system) .tool(deep_research) .tool(read) .tool(write) .tool(ls) .tool(search) .tool(run) .permission_rules(vec![ PermissionRule { tool_pattern: "*".into(), behavior: PermissionBehavior::Allow, }, ]) .permission_mode(PermissionMode::DenyUnauthorized) .max_turns(50); // ── Kick off ────────────────────────────────────────────────────────── // Transition to Researching immediately. fsm.execute(actions::START, serde_json::json!({})).await?; let runner = Runner::new(agent); let mut stream = runner .run(serde_json::json!(task), RunnerConfig::default()) .await?; // ── Event loop ──────────────────────────────────────────────────────── let mut result = CodingResult { files_written: Vec::new(), test_output: String::new(), test_passed: false, research_report: String::new(), }; let fsm_ref = Arc::clone(&fsm); let research_ctr = Arc::clone(&research_calls); let files_written_ctr = Arc::clone(&files_written); let retries_ctr = Arc::clone(&retries); while let Some(event) = stream.recv().await { match &event { AgentEvent::ToolResult { name, output, .. } => { let current = fsm_ref.strategy.current_state()?; match name.as_str() { "deep_research" => { // Record the research report. result.research_report.push_str(&output.content); result.research_report.push_str("\n\n---\n\n"); // Increment research call counter. *research_ctr.lock().unwrap() += 1; let rc = *research_ctr.lock().unwrap(); // Advance FSM: researching → implementing. if current.0 == states::RESEARCHING { let _ = fsm_ref.execute( actions::RESEARCH_COMPLETE, serde_json::json!({ payload::RESEARCH_CALLS: rc }), ).await; } } "write_file" => { *files_written_ctr.lock().unwrap() += 1; // Extract the file path from the tool call input if available. result.files_written.push( output.content.lines().next().unwrap_or("unknown").to_owned() ); } "run_command" => { // Check test results. if current.0 == states::IMPLEMENTING { // First, try to advance to Testing. let fw = *files_written_ctr.lock().unwrap(); let _ = fsm_ref.execute( actions::RUN_TESTS, serde_json::json!({ payload::FILES_WRITTEN: fw }), ).await; } let current = fsm_ref.strategy.current_state()?; if current.0 == states::TESTING { let passed = output.content.contains("test result: ok"); result.test_output = output.content.clone(); if passed { result.test_passed = true; let _ = fsm_ref.execute( actions::TESTS_PASS, serde_json::json!({}), ).await; } else { let r = *retries_ctr.lock().unwrap(); let _ = fsm_ref.execute( actions::TESTS_FAIL, serde_json::json!({ payload::RETRIES: r }), ).await; *retries_ctr.lock().unwrap() += 1; } } } _ => {} } } AgentEvent::Error { message } => { anyhow::bail!("Agent error: {message}"); } _ => {} } } Ok(result) } }
Step 9: The main binary
// src/main.rs mod agent; mod backend; mod fsm; mod research; mod tools; use clap::Parser; #[derive(Parser)] #[command(version, about = "Coding agent with deep research capability")] struct Cli { /// The coding task to complete. task: String, /// Project root directory. #[arg(short, long, default_value = ".")] project: String, } #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); let project_root = std::path::Path::new(&cli.project) .canonicalize()? .to_string_lossy() .into_owned(); println!("Task: {}", cli.task); println!("Project root: {project_root}"); println!(); let result = agent::run_coding_agent(&cli.task, &project_root).await?; println!("\n── Research report ──────────────────────────────────────\n"); println!("{}", result.research_report); println!("\n── Test output ──────────────────────────────────────────\n"); println!("{}", result.test_output); if result.test_passed { println!("\nPipeline completed successfully."); println!("Files modified: {:?}", result.files_written); } else { eprintln!("\nTests did not pass after all retries."); std::process::exit(1); } Ok(()) }
Run it:
export ANTHROPIC_API_KEY="..."
cargo run -- \
--project /path/to/my-crate \
"Add a words_longer_than function to src/text.rs that takes a &str \
and a usize threshold and returns Vec<&str> of words longer than \
the threshold. Add tests."
What happens:
- The agent starts in
Idle, transitions toResearching. - The agent calls
deep_research("How is src/text.rs structured? What existing functions and tests exist?"). - Inside
deep_research, theStateGraphruns:explore_nodereads the codebase with sub-agents →synthesise_nodeproduces a report → the report string is returned. - The FSM transitions to
Implementing. The agent can now callwrite_file. - The agent writes
src/text.rs, callscargo test. - The FSM transitions to
Testing. On pass →Done. On fail → back toImplementing(up to 3 retries).
Step 10: Testing the composition
Unit test: the research tool returns a report
#![allow(unused)] fn main() { // tests/research_tool.rs use synwire_test_utils::fake_chat_model::FakeChatModel; /// Verify the deep_research tool compiles its graph and accepts input. #[tokio::test] async fn deep_research_tool_accepts_query() { // In a real test, you'd inject a FakeChatModel into the graph nodes. // Here we just verify the tool builds without error. let tool = synwire_research_coder::research::tool::deep_research_tool( "/tmp/test-project".to_owned(), ); assert!(tool.is_ok()); let tool = tool.unwrap(); assert_eq!(synwire_core::tools::Tool::name(&tool), "deep_research"); } }
Integration test: FSM blocks premature writes
#![allow(unused)] fn main() { // tests/fsm_integration.rs use synwire_core::agents::execution_strategy::{ExecutionStrategy, StrategyError}; use synwire_research_coder::fsm::*; /// The FSM must reject write_file before deep_research has been called. #[tokio::test] async fn fsm_blocks_implement_before_research() { let fsm = build_coder_fsm().unwrap(); // Start → Researching. fsm.execute(actions::START, serde_json::json!({})).await.unwrap(); // Try to skip straight to implementing. let err = fsm .execute( actions::RESEARCH_COMPLETE, serde_json::json!({ payload::RESEARCH_CALLS: 0 }), ) .await .expect_err("should be blocked by guard"); assert!(matches!(err, StrategyError::GuardRejected(_))); } }
Verify graph topology
#![allow(unused)] fn main() { // tests/research_graph.rs use synwire_research_coder::research::graph::build_research_graph; #[test] fn research_graph_has_two_nodes() { let graph = build_research_graph().unwrap(); let mut names = graph.node_names(); names.sort_unstable(); assert_eq!(names, ["explore", "synthesise"]); } #[test] fn research_graph_entry_is_explore() { let graph = build_research_graph().unwrap(); assert_eq!(graph.entry_point(), "explore"); } }
How the primitives compose
Coding Agent (FsmStrategy: Idle → Researching → Implementing → Testing → Done)
│
├── Tools available:
│ ├── deep_research ← wraps a StateGraph
│ │ └── explore_node (DirectStrategy agent)
│ │ └── read_file, list_dir, search_code
│ │ └── synthesise_node (one-shot agent)
│ │ └── (no tools — reasoning only)
│ ├── read_file
│ ├── write_file
│ ├── list_dir
│ ├── search_code
│ └── run_command
│
└── FSM enforces:
1. deep_research called ≥1 time before write_file
2. write_file called ≥1 time before run_command (test)
3. test retry ≤ 3 times
The StateGraph is encapsulated inside the tool's closure. The FsmStrategy governs the outer agent. Neither knows the other exists. The only connection is the tool interface: deep_research(query) → report.
| Layer | Crate | What it enforces |
|---|---|---|
| Outer agent | synwire-agent | Turn order via FSM guards |
| Research tool | synwire-orchestrator | Graph topology (explore → synthesise → END) |
| Tool trait | synwire-core | API contract: Fn(Value) → Future<ToolOutput> |
Extending the design
Multiple research tools with different graphs
Build separate tools for different research concerns:
#![allow(unused)] fn main() { // Code review tool: three nodes — read diff, check style, report issues. let code_review = code_review_tool(project_root.clone())?; // Dependency audit: two nodes — parse Cargo.toml, check advisories. let dep_audit = dependency_audit_tool(project_root.clone())?; let agent = Agent::new("coder", "claude-opus-4-6") .tool(deep_research) .tool(code_review) // another graph-as-tool .tool(dep_audit) // yet another graph-as-tool .tool(read) .tool(write) .tool(run); }
Each tool encapsulates its own StateGraph — the agent treats them all identically.
Recursive agents: a research tool that calls research tools
Since deep_research is just a tool, a sub-agent inside the research graph could itself have tools that wrap other graphs. This is structurally legal but should be used with care — recursion depth is bounded only by the max_turns limit on each agent.
Ollama for local inference
Replace "claude-opus-4-6" with any Ollama model:
#![allow(unused)] fn main() { use synwire_llm_ollama::ChatOllama; let model = ChatOllama::builder().model("codestral").build()?; let runner = Runner::new(agent).with_model(model); }
Both ChatOpenAI and ChatOllama implement BaseChatModel — no other changes needed. See Local Inference with Ollama.
Checkpointing
Add checkpointing to the research graph for long-running explorations:
#![allow(unused)] fn main() { use synwire_checkpoint::InMemoryCheckpointSaver; use std::sync::Arc; let saver = Arc::new(InMemoryCheckpointSaver::new()); let graph = build_research_graph()?.with_checkpoint_saver(saver); }
If the exploration agent crashes mid-run, restarting with the same thread_id resumes from the last completed node. See Tutorial 6: Checkpointing.
What you have built
| Component | What it does |
|---|---|
ResearchState | Typed graph state: query, raw findings, synthesised report |
explore_node | DirectStrategy agent that reads the codebase with file tools |
synthesise_node | One-shot agent that distils findings into a structured report |
build_research_graph | StateGraph: explore → synthesise → END |
deep_research_tool | StructuredTool that wraps the graph — called by the outer agent |
build_coder_fsm | FsmStrategy: idle → researching → implementing → testing → done |
run_coding_agent | Event loop that wires the FSM, tools, and agent together |
| FSM guards | has_researched, has_written, retry_allowed — structural safety |
See also
- Explanation: StateGraph vs FsmStrategy — when to use each primitive and how they compose
- Tutorial 7: Building a Coding Agent — the five-tool coding agent this tutorial extends
- Tutorial 6: Checkpointing —
SqliteSaverfor durable graph state - Tutorial 3: Execution Strategies —
FsmStrategyandDirectStrategyfrom first principles - Explanation: synwire-orchestrator —
StateGraph, channels, and conditional edges - Explanation: synwire-agent —
Runner, middleware, and backend reference - Explanation: synwire-core —
Tooltrait,StructuredTool, andToolSchema - How-To: VFS Backends —
CompositeProvider,HttpBackend - Local Inference with Ollama — run the pipeline locally
Background: AI Workflows vs AI Agents — the research pipeline is a workflow (fixed stages); the coding agent is an agent (open-ended). This tutorial shows how workflows can live inside agents as tools. Context Engineering — the research report is context the agent engineers for itself by choosing when and what to research.
Tutorial 9: Semantic Search
Time: ~1 hour Prerequisites: Rust 1.85+, completion of Your First Agent
Background: Retrieval-Augmented Generation — semantic search is the retrieval component of RAG. Instead of matching exact text, it finds code by meaning.
This tutorial covers three ways to use Synwire's semantic search:
- Direct API — call
LocalProvidermethods from your own code - As a built-in agent tool — the agent calls
semantic_searchalongside file and shell tools - As a StateGraph node — semantic search as a stage in a multi-step pipeline
All three approaches use the same underlying pipeline: walk → chunk → embed → store → search. The difference is who controls when and how it runs.
📖 Rust note:
Arc<dyn Vfs>is a thread-safe reference-counted pointer to a trait object.Arclets multiple tools share one VFS provider without copying it.dyn Vfsmeans "any type implementing theVfstrait" — the concrete type (LocalProvider) is erased at runtime.
Step 1: Enable the feature flag
Add the semantic-search feature to your synwire-agent dependency:
[dependencies]
synwire-agent = { version = "0.1", features = ["semantic-search"] }
This pulls in synwire-index, synwire-chunker, synwire-embeddings-local,
and synwire-vectorstore-lancedb — everything needed for local semantic search.
Note: The first time the embedding models are used, fastembed downloads ~30 MB from Hugging Face Hub and caches them locally. Subsequent runs load from cache with no network access.
Step 2: Create a LocalProvider with semantic search
LocalProvider is the VFS implementation for local filesystem access. When the
semantic-search feature is enabled, it gains index, index_status, and
semantic_search capabilities.
use synwire_agent::vfs::local::LocalProvider;
use synwire_core::vfs::protocol::Vfs;
use synwire_core::vfs::types::{IndexOptions, SemanticSearchOptions};
use std::path::PathBuf;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let project_root = PathBuf::from("/path/to/your/project");
let vfs = LocalProvider::new(project_root)?;
// The VFS reports its capabilities — INDEX and SEMANTIC_SEARCH are included:
let caps = vfs.capabilities();
println!("Capabilities: {:?}", caps);
Ok(())
}
Step 3: Index a directory
Call index() to start building the semantic index. This returns immediately
with an IndexHandle — the actual work runs in a background task.
let handle = vfs.index("src", IndexOptions {
force: false, // reuse cache if available
include: vec![], // no include filter (index everything)
exclude: vec![
"target/**".into(), // skip build artifacts
"*.lock".into(), // skip lock files
],
max_file_size: Some(1_048_576), // skip files over 1 MiB
}).await?;
println!("Indexing started: id={}", handle.index_id);
What happens in the background
- Walk:
synwire-indexrecursively traverses the directory, applying your include/exclude filters and file size limit. - Chunk: Each file is split into semantic units. Code files are parsed with tree-sitter to extract functions, structs, classes, and other definitions. Non-code files use a recursive character splitter.
- Embed: Each chunk is converted into a 384-dimension vector using BAAI/bge-small-en-v1.5 (local ONNX inference).
- Store: Vectors are written to a LanceDB table cached on disk.
- Watch: A file watcher starts monitoring for changes to keep the index up to date.
Step 4: Wait for indexing to complete
Poll index_status() to check progress:
use synwire_core::vfs::types::IndexStatus;
loop {
let status = vfs.index_status(&handle.index_id).await?;
match status {
IndexStatus::Pending => println!("Waiting to start..."),
IndexStatus::Indexing { progress } => {
println!("Indexing: {:.0}%", progress * 100.0);
}
IndexStatus::Ready(result) => {
println!(
"Done! {} files indexed, {} chunks produced (cached: {})",
result.files_indexed,
result.chunks_produced,
result.was_cached,
);
break;
}
IndexStatus::Failed(err) => {
eprintln!("Indexing failed: {err}");
return Err(err.into());
}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
📖 Rust note: The
loop { ... break }pattern runs untilbreakis reached. Thematcharms handle each variant of the [IndexStatus] enum. Rust's exhaustive matching ensures you handle every possible state — if a new variant is added, the compiler tells you.
For a medium-sized Rust project (~500 files), indexing typically takes 5–30 seconds depending on CPU speed and whether models need downloading.
Step 5: Search by meaning
Now search for code by what it does, not what it is called:
let results = vfs.semantic_search("error handling and recovery logic", SemanticSearchOptions {
top_k: Some(5),
min_score: None, // no minimum score threshold
file_filter: vec![], // search all indexed files
rerank: Some(true), // enable cross-encoder reranking (default)
}).await?;
for result in &results {
println!("--- {} (lines {}-{}, score: {:.3}) ---",
result.file,
result.line_start,
result.line_end,
result.score,
);
if let Some(ref sym) = result.symbol {
println!("Symbol: {sym}");
}
if let Some(ref lang) = result.language {
println!("Language: {lang}");
}
// Print the first 200 characters of content
let preview: String = result.content.chars().take(200).collect();
println!("{preview}");
println!();
}
Understanding results
Each SemanticSearchResult contains:
| Field | Description |
|---|---|
file | Path relative to the indexed directory |
line_start | 1-indexed first line of the matching chunk |
line_end | 1-indexed last line of the matching chunk |
content | The full chunk text (function body, paragraph, etc.) |
score | Relevance score (higher = more relevant after reranking) |
symbol | Function/struct/class name, if extracted from AST |
language | Programming language, if detected |
Step 6: Filter results
Use file_filter globs to restrict search to specific paths:
// Only search Rust files in the auth module:
let auth_results = vfs.semantic_search("credential validation", SemanticSearchOptions {
top_k: Some(3),
min_score: Some(0.5),
file_filter: vec!["src/auth/**/*.rs".into()],
rerank: Some(true),
}).await?;
Use min_score to exclude low-confidence results. The appropriate threshold
depends on your use case — start with None and observe the score distribution,
then set a threshold that filters noise.
Step 7: Incremental updates
After the initial index, the file watcher keeps it up to date automatically. When you save a file, the watcher detects the change, re-chunks the file, re-embeds it, and updates the vector store. No manual re-indexing needed.
To force a full re-index (e.g. after a large merge):
let handle = vfs.index("src", IndexOptions {
force: true, // ignore cache, re-index everything
..Default::default()
}).await?;
Direct API: complete example
use synwire_agent::vfs::local::LocalProvider;
use synwire_core::vfs::protocol::Vfs;
use synwire_core::vfs::types::{IndexOptions, IndexStatus, SemanticSearchOptions};
use std::path::PathBuf;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Create VFS with local filesystem access
let vfs = LocalProvider::new(PathBuf::from("."))?;
// 2. Start indexing
let handle = vfs.index("src", IndexOptions::default()).await?;
// 3. Wait for completion
loop {
match vfs.index_status(&handle.index_id).await? {
IndexStatus::Ready(_) => break,
IndexStatus::Failed(e) => return Err(e.into()),
_ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
}
}
// 4. Search by meaning
let results = vfs.semantic_search(
"database connection pooling",
SemanticSearchOptions::default(),
).await?;
for r in &results {
println!("{} (lines {}-{}): {:.3}", r.file, r.line_start, r.line_end, r.score);
}
Ok(())
}
Built-in agent tools: semantic search as a tool call
The VFS tools module automatically generates index, index_status, and semantic_search tools when the provider has the INDEX and SEMANTIC_SEARCH capabilities. You don't write tool wrappers — they're built in.
How it works
vfs_tools() inspects the provider's capabilities and emits only the tools the provider supports:
use std::sync::Arc;
use synwire_agent::vfs::local::LocalProvider;
use synwire_core::vfs::{vfs_tools, OutputFormat};
use std::path::PathBuf;
let vfs = Arc::new(LocalProvider::new(PathBuf::from("."))?);
// This returns tools for ls, read, write, grep, glob, find,
// AND index, index_status, semantic_search (because LocalProvider
// with semantic-search feature has those capabilities).
let tools = vfs_tools(Arc::clone(&vfs) as Arc<_>, OutputFormat::Plain);
for tool in &tools {
println!(" {}: {}", tool.name(), tool.description());
}
Giving the agent semantic search
Pass the VFS tools to an agent — the LLM sees semantic_search as a callable tool alongside read_file, grep, etc.:
use std::sync::Arc;
use synwire_agent::vfs::local::LocalProvider;
use synwire_core::agents::agent_node::Agent;
use synwire_core::agents::runner::{Runner, RunnerConfig};
use synwire_core::agents::streaming::AgentEvent;
use synwire_core::vfs::{vfs_tools, OutputFormat};
use std::path::PathBuf;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let vfs = Arc::new(
LocalProvider::new(PathBuf::from("."))?
);
// Built-in tools include semantic_search, index, index_status,
// plus all file operations (read, write, ls, grep, glob, etc.)
let tools = vfs_tools(Arc::clone(&vfs) as Arc<_>, OutputFormat::Plain);
let agent = Agent::new("code-assistant", "claude-opus-4-6")
.system_prompt(
"You are a code assistant with access to the local filesystem.\n\
You have both grep (exact text search) and semantic_search \
(meaning-based search).\n\n\
IMPORTANT: Before using semantic_search, you must call the \
`index` tool to build the index, then poll `index_status` \
until it reports Ready.\n\n\
Use grep for known patterns (function names, error strings).\n\
Use semantic_search for conceptual queries ('how are errors \
handled?', 'authentication flow')."
)
.tools(tools) // all VFS tools including semantic_search
.max_turns(30);
let runner = Runner::new(agent);
let mut stream = runner
.run(
serde_json::json!("Find all the error handling patterns in this project"),
RunnerConfig::default(),
)
.await?;
while let Some(event) = stream.recv().await {
match event {
AgentEvent::TextDelta { content } => print!("{content}"),
AgentEvent::ToolCallStart { name, .. } => {
eprintln!("\n[calling {name}]");
}
_ => {}
}
}
Ok(())
}
The agent autonomously decides the workflow:
- Calls
index("src", {})to start indexing - Polls
index_statusuntilReady - Calls
semantic_search("error handling patterns", { top_k: 10 }) - Reads the results, possibly calls
read_fileon interesting hits for full context - Synthesises an answer
You write zero tool wrappers — vfs_tools handles everything.
Combining grep and semantic search
The built-in tools let the agent choose the right search for each sub-query:
let agent = Agent::new("researcher", "claude-opus-4-6")
.system_prompt(
"You have two search tools:\n\
- `grep`: fast exact text/regex search — use for known identifiers\n\
- `semantic_search`: meaning-based search — use for conceptual queries\n\n\
Strategy: start with semantic_search for broad understanding, then \
use grep to find exact call sites of specific symbols you discover."
)
.tools(vfs_tools(vfs, OutputFormat::Plain))
.max_turns(30);
The agent might:
semantic_search("authentication and authorization")→ findsfn verify_tokeninauth.rsgrep("verify_token")→ finds all 14 call sites across the codebaseread_file("src/middleware/auth_middleware.rs")→ reads the main consumer
This grep-then-semantic or semantic-then-grep pattern is natural for LLMs and requires no special orchestration.
StateGraph node: semantic search in a pipeline
For structured multi-step workflows, embed semantic search as a node in a StateGraph. This is useful when search results feed into a downstream processing step — summarisation, code generation, or report writing.
Example: Research pipeline with semantic search
This graph indexes the codebase, searches for relevant code, and produces a summary:
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use synwire_derive::State;
use synwire_orchestrator::constants::END;
use synwire_orchestrator::error::GraphError;
use synwire_orchestrator::graph::StateGraph;
use synwire_agent::vfs::local::LocalProvider;
use synwire_core::vfs::protocol::Vfs;
use synwire_core::vfs::types::{IndexOptions, IndexStatus, SemanticSearchOptions};
/// Pipeline state flowing through three nodes.
#[derive(State, Debug, Clone, Default, Serialize, Deserialize)]
struct ResearchState {
/// The conceptual query to research.
#[reducer(last_value)]
query: String,
/// Project root path.
#[reducer(last_value)]
project_root: String,
/// Raw search results (file:lines → content).
#[reducer(topic)]
search_hits: Vec<String>,
/// Final summary produced by the summarise node.
#[reducer(last_value)]
summary: String,
}
Node 1: Index and search
This node handles both indexing and searching — it's a pure Rust function, no agent needed:
/// Index the codebase and run a semantic search.
async fn search_node(mut state: ResearchState) -> Result<ResearchState, GraphError> {
let vfs = LocalProvider::new(state.project_root.clone().into())
.map_err(|e| GraphError::NodeError { message: e.to_string() })?;
// Start indexing.
let handle = vfs.index("src", IndexOptions::default()).await
.map_err(|e| GraphError::NodeError { message: e.to_string() })?;
// Wait for completion.
loop {
match vfs.index_status(&handle.index_id).await
.map_err(|e| GraphError::NodeError { message: e.to_string() })?
{
IndexStatus::Ready(_) => break,
IndexStatus::Failed(e) => {
return Err(GraphError::NodeError { message: e.to_string() });
}
_ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
}
}
// Search by meaning.
let results = vfs.semantic_search(&state.query, SemanticSearchOptions {
top_k: Some(10),
rerank: Some(true),
..Default::default()
}).await
.map_err(|e| GraphError::NodeError { message: e.to_string() })?;
// Record hits as "file:start-end → content" strings.
for r in &results {
let hit = format!(
"{}:{}-{} [score={:.3}{}]\n{}",
r.file,
r.line_start,
r.line_end,
r.score,
r.symbol.as_deref().map_or(String::new(), |s| format!(", symbol={s}")),
r.content,
);
state.search_hits.push(hit);
}
Ok(state)
}
Node 2: Summarise
An LLM agent reads the search hits and produces a structured summary:
use synwire_core::agents::agent_node::Agent;
use synwire_core::agents::runner::{Runner, RunnerConfig};
use synwire_core::agents::streaming::AgentEvent;
/// Summarise the search results into a concise report.
async fn summarise_node(mut state: ResearchState) -> Result<ResearchState, GraphError> {
let system = "You receive semantic search results from a codebase and produce \
a concise technical summary. Group findings by theme. Include \
file paths and line numbers for every claim.";
let prompt = format!(
"Query: {}\n\nSearch results ({} hits):\n\n{}",
state.query,
state.search_hits.len(),
state.search_hits.join("\n---\n"),
);
let agent = Agent::new("summariser", "claude-opus-4-6")
.system_prompt(system)
.max_turns(1);
let runner = Runner::new(agent);
let mut stream = runner
.run(serde_json::json!(prompt), RunnerConfig::default())
.await
.map_err(|e| GraphError::NodeError { message: e.to_string() })?;
let mut summary = String::new();
while let Some(event) = stream.recv().await {
match event {
AgentEvent::TextDelta { content } => summary.push_str(&content),
AgentEvent::Error { message } => {
return Err(GraphError::NodeError { message });
}
_ => {}
}
}
state.summary = summary;
Ok(state)
}
Assembling the graph
let mut graph = StateGraph::<ResearchState>::new();
graph.add_node("search", Box::new(|s| Box::pin(search_node(s))))?;
graph.add_node("summarise", Box::new(|s| Box::pin(summarise_node(s))))?;
graph
.set_entry_point("search")
.add_edge("search", "summarise")
.add_edge("summarise", END);
let pipeline = graph.compile()?;
// Run the pipeline.
let result = pipeline.invoke(ResearchState {
query: "How does error propagation work across module boundaries?".into(),
project_root: "/path/to/project".into(),
..Default::default()
}).await?;
println!("{}", result.summary);
When to use each approach
| Approach | Use when | Example |
|---|---|---|
| Direct API | You control the flow yourself; no agent involved | CLI tools, scripts, tests |
| Built-in agent tool | The agent decides when to search; search is one of many actions | Coding agents, Q&A bots, interactive assistants |
| StateGraph node | Search is a fixed stage in a multi-step pipeline | Research pipelines, batch analysis, report generation |
The built-in tool approach is the most common — it gives the agent maximum autonomy while requiring zero wrapper code. The graph approach is best when search must happen at a specific point in a deterministic workflow.
Wrapping the pipeline as an agent tool
Following the pattern from Tutorial 8, you can wrap the entire search → summarise graph as a StructuredTool and give it to an agent:
use std::sync::Arc;
use synwire_core::tools::{StructuredTool, ToolOutput, ToolSchema};
fn semantic_research_tool(project_root: String) -> Result<StructuredTool, synwire_core::error::SynwireError> {
let graph = Arc::new(build_research_graph()?);
let root = project_root.clone();
StructuredTool::builder()
.name("semantic_research")
.description(
"Searches the codebase by meaning and returns a summarised report. \
Use for broad conceptual queries about how the codebase works."
)
.schema(ToolSchema {
name: "semantic_research".into(),
description: "Semantic codebase research".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Conceptual query about the codebase"
}
},
"required": ["query"]
}),
})
.func(move |input| {
let graph = Arc::clone(&graph);
let project_root = root.clone();
Box::pin(async move {
let query = input["query"].as_str().unwrap_or("overview").to_owned();
let result = graph.invoke(ResearchState {
query,
project_root,
..Default::default()
}).await.map_err(|e| synwire_core::error::SynwireError::Tool(
synwire_core::error::ToolError::InvocationFailed {
message: format!("research pipeline failed: {e}"),
},
))?;
Ok(ToolOutput {
content: result.summary,
..Default::default()
})
})
})
.build()
}
Now the agent has three levels of search capability:
| Tool | Granularity | When the agent uses it |
|---|---|---|
grep | Exact text/regex match | Known identifiers, error strings |
semantic_search | Raw vector search results | Focused conceptual queries |
semantic_research | Summarised research report | Broad "how does X work?" questions |
The agent picks the right tool for the job — grep for precision, semantic_search for discovery, semantic_research for understanding.
What you learned
- The
semantic-searchfeature flag enables local semantic search onLocalProvider index()starts background indexing and returns anIndexHandleimmediatelyindex_status()polls progress until the index is readysemantic_search()finds code by meaning, with optional filtering and rerankingvfs_tools()automatically generates agent tools for all VFS capabilities — includingindex,index_status, andsemantic_searchStateGraphnodes can run the index/search pipeline as a deterministic stage- The graph can be wrapped as a
StructuredToolfor agent-driven research
See also
- Semantic Search Architecture — the four-stage pipeline in depth
- Semantic Search How-To — task-focused recipes
- synwire-chunker — AST-aware code chunking
- synwire-embeddings-local — local ONNX embedding models
- synwire-vectorstore-lancedb — LanceDB vector storage
- synwire-index — indexing pipeline lifecycle
- Tutorial 8: Deep Research + Coding Agent — graph-as-tool composition pattern
- Tutorial 7: Building a Coding Agent — combine semantic search with tool use
Background: RAG techniques — semantic search is the retrieval step. The summarise node or the agent's reasoning is the generation step. The two-stage retrieve-then-rerank pipeline improves relevance without sacrificing speed.
Sandboxed Command Execution
Time: ~30 minutes
Prerequisites: Rust 1.85+, Cargo, runc on $PATH
This tutorial builds an agent whose LLM can run shell commands inside an isolated sandbox through tool calls. Three scenarios show progressively complex interactions:
- Oneshot command — LLM runs a compiler, gets exit code + diagnostics
- Long-lived command with polling — LLM starts a test suite in background, polls for completion, reads partial output
- CLI that prompts for confirmation (HITL) — LLM runs a tool that asks for human input, recognises the prompt, and hands the terminal to the user
Note: File listing, reading, and writing are handled by VFS tools (
vfs_list,vfs_read,vfs_write). Userun_commandfor things VFS can't do: compiling, running tests, invoking CLI tools, package management.
What you are building
An agent with thirteen sandbox tools (backed by expectrl for
cross-platform PTY support), automatically wired via .with_sandbox(config):
| Tool | LLM calls it to... |
|---|---|
run_command | Execute a command (oneshot or background) |
open_shell | Start an interactive PTY session |
shell_write | Send keystrokes to a PTY session |
shell_read | Read available output (non-blocking) |
shell_expect | Wait for a regex pattern (with capture groups) |
shell_expect_cases | Wait for one of N patterns (switch/case) |
shell_batch | Run a send/expect sequence in one call |
shell_signal | Send an OS signal (Ctrl-C, SIGTERM, etc.) |
list_processes | See all running processes |
wait_for_process | Block until a process exits |
read_process_output | Read captured stdout/stderr |
kill_process | Send a signal to a process |
process_stats | Get CPU/memory/status for a process |
Setup
cargo new synwire-sandbox-demo
cd synwire-sandbox-demo
[dependencies]
synwire = { path = "../../crates/synwire", features = ["sandbox"] }
synwire-core = { path = "../../crates/synwire-core" }
tokio = { version = "1", features = ["full"] }
Build the agent
use synwire::agent::prelude::*;
use synwire::sandbox::SandboxedAgent;
use synwire_core::agents::sandbox::{
FilesystemConfig, IsolationLevel, NetworkConfig, ProcessTracking,
ResourceLimits, SandboxConfig, SecurityPreset, SecurityProfile,
};
let config = SandboxConfig {
enabled: true,
isolation: IsolationLevel::Namespace,
filesystem: Some(FilesystemConfig {
allow_write: vec![".".into()],
deny_write: vec![],
deny_read: vec![],
inherit_readable: true,
}),
network: Some(NetworkConfig {
enabled: false,
..Default::default()
}),
security: SecurityProfile {
standard: SecurityPreset::Baseline,
..Default::default()
},
resources: Some(ResourceLimits {
memory_bytes: Some(512 * 1024 * 1024),
cpu_quota: Some(1.0),
max_pids: Some(64),
exec_timeout_secs: Some(30),
}),
process_tracking: ProcessTracking {
enabled: true,
max_tracked: Some(64),
},
..Default::default()
};
// with_sandbox() does all the wiring:
// - Finds runc on $PATH
// - Creates ProcessRegistry + ProcessVisibilityScope
// - Registers ProcessPlugin with all 9 tools
// - Sets the sandbox config on the agent
let (agent, handle) = Agent::<()>::new("sandbox-agent", "gpt-4")
.description("Agent with sandboxed command execution")
.max_turns(20)
.with_sandbox(config);
// handle.registry and handle.scope are available for sub-agent wiring
That's it for setup. The LLM now has all nine tools available. The rest of this tutorial shows what the LLM's tool calls look like in each scenario.
Scenario 1: Oneshot command — compile and check
The LLM has edited a Rust file via VFS tools and wants to check if it
compiles. It runs cargo check as a oneshot command:
{
"tool": "run_command",
"input": {
"command": "cargo",
"args": ["check", "--message-format=json"],
"wait": true,
"timeout_secs": 60
}
}
The tool spawns the command inside a namespace container, waits for it to exit, and returns:
{
"pid": 42,
"exit_code": 1,
"timed_out": false,
"stdout": "{\"reason\":\"compiler-message\",\"message\":{\"rendered\":\"error[E0308]: mismatched types\\n --> src/main.rs:14:5\\n |\\n14 | 42u32\\n | ^^^^^ expected `String`, found `u32`\\n\"}}",
"stderr": "error: could not compile `myproject` (bin \"myproject\") due to 1 previous error"
}
The LLM parses the compiler JSON, identifies the type mismatch at
src/main.rs:14, uses VFS tools to fix the code, then runs
cargo check again. One tool call per compilation attempt — the
simplest path.
How it works internally
run_commandtranslatesSandboxConfigto an OCI runtime spec- Calls
runc run --bundle <tmpdir> <id>with stdout/stderr redirected to files (so output survives even if the process is killed) - Waits up to
timeout_secsfor the process to exit - Reads the captured output files
- Returns everything in one JSON response
If timeout_secs is exceeded, the process is killed and timed_out: true
is returned.
Scenario 2: Long-lived command with polling — test suite
The LLM starts the full test suite which takes a while. It uses
wait: false to get a PID back immediately and monitors progress:
Turn 1 — start the tests:
{
"tool": "run_command",
"input": {
"command": "cargo",
"args": ["nextest", "run", "--no-fail-fast"],
"wait": false
}
}
Response:
{
"pid": 87,
"status": "running",
"hint": "Use wait_for_process to block until exit, or read_process_output to read partial output."
}
Turn 2 — check if it's done yet:
{
"tool": "wait_for_process",
"input": {
"pid": 87,
"timeout_ms": 10000
}
}
Response (still running after 10s):
{
"pid": 87,
"status": "timeout",
"message": "process still running after 10000ms"
}
Turn 3 — read partial output to see progress:
{
"tool": "read_process_output",
"input": {
"pid": 87,
"stream": "stderr"
}
}
Response:
Starting 47 tests across 8 binaries
PASS [ 0.234s] synwire-core::tools::test_tool_schema_validation
PASS [ 0.567s] synwire-core::tools::test_structured_tool_invoke
PASS [ 1.123s] synwire-orchestrator::test_graph_compilation
RUNNING synwire-sandbox::test_namespace_spawn_echo
The LLM sees tests are progressing and decides to wait longer.
Turn 4 — wait for completion:
{
"tool": "wait_for_process",
"input": {
"pid": 87,
"timeout_ms": 120000
}
}
Response:
{
"pid": 87,
"status": "exited",
"exit_code": 0
}
Turn 5 — read final output:
{
"tool": "read_process_output",
"input": {
"pid": 87,
"stream": "stderr"
}
}
Starting 47 tests across 8 binaries
...
Summary [ 12.456s] 47 tests run: 47 passed, 0 failed, 0 skipped
Key points
wait: falsereturns immediately — the process runs in backgroundmonitor_childupdates the registry when the process exitsread_process_outputreads from files on disk, so output is available even while the process is still running (buffered by the OS)- The LLM controls the polling loop through its FSM turns — it can do other work between polls
- Output files are in a
TempDir— automatically cleaned up when the lastArc<CapturedOutput>is dropped (Godefersemantics)
Scenario 3: CLI prompts for confirmation (HITL)
The LLM needs to run terraform apply which prompts the user to type
yes before making changes. The LLM can't (and shouldn't) type the
confirmation itself — it uses shell_expect to detect the prompt, then
hands off to the user.
Turn 1 — open a shell and run terraform:
{
"tool": "open_shell",
"input": {
"shell": "/bin/sh"
}
}
Response:
{
"session_id": "a1b2c3d4-...",
"shell": "/bin/sh",
"hint": "Use shell_write to send commands and shell_read to see output."
}
Turn 2 — start the terraform apply:
{
"tool": "shell_write",
"input": {
"session_id": "a1b2c3d4-...",
"input": "terraform apply -no-color\n"
}
}
Turn 3 — wait for the confirmation prompt using shell_expect:
{
"tool": "shell_expect",
"input": {
"session_id": "a1b2c3d4-...",
"pattern": "Enter a value:",
"timeout_secs": 120
}
}
shell_expect reads from the PTY in a loop until "Enter a value:" appears
in the accumulated output, then returns everything captured up to the
match:
{
"matched": true,
"pattern": "Enter a value:",
"output": "$ terraform apply -no-color\n\nTerraform will perform the following actions:\n\n # aws_instance.web will be created\n + resource \"aws_instance\" \"web\" {\n + ami = \"ami-0c55b159cbfafe1f0\"\n + instance_type = \"t2.micro\"\n + tags = {\n + \"Name\" = \"production-web\"\n }\n }\n\nPlan: 1 to add, 0 to change, 0 to destroy.\n\nDo you want to perform these actions?\n Terraform will perform the actions described above.\n Only 'yes' will be accepted to approve.\n\n Enter a value: "
}
The LLM gets matched: true and the full plan output in a single tool
call — no polling loop needed.
Turn 4 — the LLM recognises this is a confirmation prompt and hands off to the user:
The LLM responds to the user (not a tool call):
Terraform wants to create an
aws_instance.web(t2.micro in production). The plan is:
- +1 resource:
aws_instance.web- No changes or deletions
The terminal is waiting for you to type
yesto approve or anything else to cancel. Please type your response in the shell session.
The user types yes directly in the PTY (the controller fd is connected
to their terminal via the SandboxHandle).
Turn 5 — wait for terraform to finish applying:
{
"tool": "shell_expect",
"input": {
"session_id": "a1b2c3d4-...",
"pattern": "Apply complete!",
"timeout_secs": 300
}
}
Response:
{
"matched": true,
"pattern": "Apply complete!",
"output": " Enter a value: yes\n\naws_instance.web: Creating...\naws_instance.web: Still creating... [10s elapsed]\naws_instance.web: Creation complete after 23s [id=i-0a1b2c3d4e5f67890]\n\nApply complete! Resources: 1 added, 0 changed, 0 destroyed.\n"
}
The LLM confirms the apply succeeded. Two shell_expect calls replaced
what would have been a manual polling loop of shell_read calls.
Why this matters
The LLM never types yes itself — it reads the prompt, explains
it to the user, and waits for the user to confirm. This pattern
applies to any CLI that requires human confirmation:
terraform apply/terraform destroykubectl deletewith--confirmapt upgrade/dnf update- SSH host key verification
- GPG key trust decisions
- Database migration tools (
diesel migration run --confirm) - Any interactive installer
How PTY handoff works
LLM agent synwire runc
│ │ │
│── open_shell() ──────────▶│ │
│ │── bind(console.sock) ───▶│
│ │── spawn("runc run │
│ │ --console-socket ...")─▶│
│ │ │── create PTY
│ │ │── setsid + TIOCSCTTY
│ │◀── SCM_RIGHTS(pty_fd) ───│
│◀── {session_id} ──────────│ │
│ │ │
│── shell_write(cmd) ──────▶│── write(pty_fd) ────────▶│── container stdin
│── shell_expect( │ │
│ "Enter a value:")────▶│── read loop ◀────────────│── container stdout
│ │ (polls every 100ms) │
│◀── {matched: true, │ │
│ output: "Plan: 1... │ │
│ Enter a value:"}──────│ │
│ │ │
│ "Please type yes to │ │
│ confirm in the terminal" │ │
│ │ │
│ USER │── write(pty_fd) ────────▶│── "yes\n"
│ │ │── applies changes
│── shell_expect( │ │
│ "Apply complete!") ──▶│── read loop ◀────────────│── "Apply complete!"
│◀── {matched: true} ──────│ │
When to use each mode
| Scenario | Tool | Why |
|---|---|---|
| Compile, lint, format | run_command(wait: true) | One call, one answer |
| Test suite, long build | run_command(wait: false) + polling | LLM controls timing |
| Detect CLI prompt | shell_expect(pattern) | Blocks until pattern appears — no polling loop |
| CLI asks for confirmation | shell_expect → hand to user | User types the approval |
| Multiple possible outcomes | shell_expect_cases | Match first of N patterns with flow control |
| Multi-step scripted flow | shell_batch | Send/expect sequence in one call |
| Cancel a running command | shell_signal("SIGINT") | Ctrl-C equivalent |
| SSH/GPG key prompts | shell_expect("password:") → hand to user | Secrets stay in the PTY |
| Raw PTY I/O | shell_write + shell_read | When expect patterns are unknown |
| File listing, read, write | VFS tools | No sandbox needed |
Scenario 4: Switch/case with shell_expect_cases
The LLM runs ssh user@host and doesn't know whether it will get a
password prompt, a host key verification prompt, or a shell prompt
(already authenticated). shell_expect_cases handles all three:
{
"tool": "shell_expect_cases",
"input": {
"session_id": "a1b2c3d4-...",
"cases": [
{
"pattern": "password:",
"tag": "needs_user",
"label": "Password prompt — hand off to user"
},
{
"pattern": "Are you sure you want to continue connecting",
"tag": "needs_user",
"label": "Host key verification — hand off to user"
},
{
"pattern": "\\$\\s*$",
"tag": "ok",
"label": "Shell prompt — already authenticated"
}
],
"timeout_secs": 30
}
}
Response (password prompt was first):
{
"matched": true,
"matched_case": 0,
"tag": "needs_user",
"label": "Password prompt — hand off to user",
"output": "user@host's password: ",
"captures": ["password:"]
}
The LLM sees tag: "needs_user" and tells the user to type their
password. If the tag had been "ok", the LLM would continue
autonomously.
Auto-response with capture groups
Cases can include a respond field for auto-responses. Use $1, $2 to
substitute captured regex groups:
{
"cases": [
{
"pattern": "version (\\d+\\.\\d+)",
"tag": "ok",
"respond": "Detected version $1\n",
"label": "Version detected"
}
]
}
Scenario 5: Scripted interaction with shell_batch
The LLM needs to run a multi-step CLI interaction in one tool call — no
round-trips. shell_batch runs send/expect steps sequentially:
{
"tool": "shell_batch",
"input": {
"session_id": "a1b2c3d4-...",
"steps": [
{ "type": "send", "input": "git status --porcelain\n" },
{ "type": "expect", "pattern": "\\$\\s*$", "timeout_secs": 5 },
{ "type": "send", "input": "cargo test --no-fail-fast 2>&1 | tail -5\n" },
{ "type": "expect", "pattern": "test result:", "timeout_secs": 120 }
],
"timeout_secs": 30
}
}
Response:
{
"steps": [
{ "index": 0, "step_type": "send", "success": true },
{
"index": 1, "step_type": "expect", "success": true,
"output": "$ git status --porcelain\n M src/lib.rs\n?? src/new_file.rs\n$ ",
"captures": ["$ "]
},
{ "index": 2, "step_type": "send", "success": true },
{
"index": 3, "step_type": "expect", "success": true,
"output": "$ cargo test ...\ntest result: ok. 47 passed; 0 failed; 0 ignored",
"captures": ["test result:"]
}
],
"completed": 4,
"total": 4
}
The LLM gets both the working tree status and test results in a single tool call. If any step fails (timeout or expect error), execution stops and the partial results are returned.
Batch with switch/case
Batches support expect_cases steps for branching:
{
"steps": [
{ "type": "send", "input": "npm publish\n" },
{
"type": "expect_cases",
"cases": [
{ "pattern": "Enter OTP:", "tag": "needs_user", "label": "2FA required" },
{ "pattern": "npm ERR!", "tag": "fail", "label": "Publish failed" },
{ "pattern": "\\+ my-package@", "tag": "ok", "label": "Published successfully" }
],
"timeout_secs": 60
}
]
}
Parent-child visibility
When a parent agent spawns sub-agents, each gets its own
ProcessRegistry. The parent can see all sub-agent processes; sub-agents
can only see their own:
let (parent_agent, parent_handle) = Agent::<()>::new("parent", "gpt-4")
.with_sandbox(config.clone());
let child_registry = Arc::new(RwLock::new(ProcessRegistry::new(Some(16))));
parent_handle.scope
.add_child_registry("research-agent", Arc::clone(&child_registry))
.await;
| Operation | Parent sees | Child sees |
|---|---|---|
list_processes | own + child | own only |
read_process_output | own + child | own only |
kill_process | own only | own only |
Next steps
- Sandbox setup: Process Sandboxing — cgroup delegation, gVisor, WSL2, macOS Seatbelt
- Approval gates: Approval Gates — require human approval before executing commands
- Permission modes: Permission Modes — control which tools need approval
- VFS operations: File and Shell Operations — the virtual filesystem layer above the sandbox
Getting Started with the MCP Server
This tutorial is a placeholder. Content will be added when
synwire-mcp-serverreaches its first stable release.
What you will build: A working synwire-mcp-server configuration for Claude Code that indexes a Rust project and enables semantic search, code graph queries, and LSP integration.
Prerequisites:
- Rust toolchain (stable)
- Claude Code installed
- A Rust project to index
See the synwire-mcp-server explanation for current installation and configuration instructions.
Authoring Your First Agent Skill
This tutorial is a placeholder. Content will be added in a future release.
What you will build: A complete agent skill following the agentskills.io specification that the MCP server discovers and makes available as a tool.
Prerequisites:
synwire-mcp-serverinstalled and running- Basic understanding of YAML
See the synwire-agent-skills explanation for the SKILL.md format, available runtimes, and discovery paths.
Setting Up Semantic Search
This tutorial is a placeholder. Content will be added in a future release.
What you will build: A fully indexed project with semantic search, hybrid BM25+vector search, and code graph queries working end-to-end.
Prerequisites:
synwire-mcp-serverinstalled- A project of at least 1 000 lines of code to make search interesting
See these existing resources:
- How-To: Semantic Search — task-focused recipes
- Semantic Search Architecture — design rationale
- Tutorial 09: Semantic Search — existing walkthrough using the Rust API directly
Fault Localization with SBFL
Time: ~45 minutes Prerequisites: Rust 1.85+, completion of Your First Agent, familiarity with test coverage concepts
Background: Spectrum-Based Fault Localization -- a family of techniques that rank code locations by how strongly they correlate with test failures.
This tutorial shows how to use Synwire's SbflRanker to identify suspicious files from test coverage data, then fuse those rankings with semantic search results to produce a combined fault-likelihood score.
What SBFL does
When a test suite has failures, some source lines are covered by failing tests but not by passing tests. SBFL assigns a suspiciousness score to each line using a statistical formula. Synwire uses the Ochiai coefficient:
score = ef / sqrt((ef + nf) * (ef + ep))
Where:
ef= number of failing tests that cover this linenf= number of passing tests that cover this lineep= number of passing tests that do not cover this line
A score of 1.0 means the line is covered exclusively by failing tests. A score of 0.0 means no failing test touches it.
Step 1: Add dependencies
[dependencies]
synwire-agent = { version = "0.1" }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
Step 2: Build coverage records
CoverageRecord holds per-line coverage data. In practice you would parse output from cargo-llvm-cov, gcov, or a DAP coverage session. Here we construct records directly:
use synwire_agent::sbfl::{CoverageRecord, SbflRanker};
fn example_coverage() -> Vec<CoverageRecord> {
vec![
// Line 42 of buggy.rs: hit by 8 failing tests, 2 passing, 0 passing miss it
CoverageRecord { file: "src/buggy.rs".into(), line: 42, ef: 8, nf: 2, np: 0 },
CoverageRecord { file: "src/buggy.rs".into(), line: 43, ef: 6, nf: 4, np: 0 },
// clean.rs: never hit by failing tests
CoverageRecord { file: "src/clean.rs".into(), line: 10, ef: 0, nf: 5, np: 5 },
CoverageRecord { file: "src/clean.rs".into(), line: 11, ef: 0, nf: 3, np: 7 },
// utils.rs: sometimes hit by failing tests but also by many passing tests
CoverageRecord { file: "src/utils.rs".into(), line: 20, ef: 3, nf: 7, np: 0 },
]
}
Step 3: Rank files by suspiciousness
SbflRanker computes the Ochiai score for every line, then ranks files by their maximum score:
fn main() {
let records = example_coverage();
let ranker = SbflRanker::new(records);
let ranked = ranker.rank_files();
println!("Files ranked by fault likelihood:");
for (file, score) in &ranked {
println!(" {file}: {score:.3}");
}
// Output:
// src/buggy.rs: 0.894
// src/utils.rs: 0.548
// src/clean.rs: 0.000
}
src/buggy.rs scores highest because line 42 is covered almost exclusively by failing tests.
Step 4: Fuse with semantic search
SBFL tells you where failures concentrate. Semantic search tells you what code is relevant to a bug description. Fusing both signals produces better results than either alone.
use synwire_agent::sbfl::fuse_sbfl_semantic;
let sbfl_scores = ranker.rank_files();
// Semantic search results (from Tutorial 09) -- score = relevance to bug description
let semantic_scores = vec![
("src/utils.rs".into(), 0.85_f32),
("src/buggy.rs".into(), 0.60),
("src/handler.rs".into(), 0.45),
];
// Fuse with 70% weight on SBFL, 30% on semantic similarity
let fused = fuse_sbfl_semantic(&sbfl_scores, &semantic_scores, 0.7);
println!("\nFused ranking (SBFL 70% + semantic 30%):");
for (file, score) in &fused {
println!(" {file}: {score:.3}");
}
Adjusting sbfl_weight lets you shift emphasis. Use higher SBFL weight (0.7--0.8) when coverage data is reliable. Use lower weight (0.3--0.5) when coverage is sparse but the bug description is precise.
Step 5: Use as an MCP tool
The code.fault_localize MCP tool wraps this pipeline. When running synwire-mcp-server, an agent can call it directly:
{
"tool": "code.fault_localize",
"arguments": {
"coverage": [
{"file": "src/buggy.rs", "line": 42, "ef": 8, "nf": 2, "np": 0},
{"file": "src/clean.rs", "line": 10, "ef": 0, "nf": 5, "np": 5}
],
"semantic_results": [
{"file": "src/buggy.rs", "score": 0.6},
{"file": "src/utils.rs", "score": 0.85}
],
"sbfl_weight": 0.7
}
}
The tool returns files ranked by combined score. The agent can then read the top-ranked files and investigate further.
Step 6: Wire it into an agent
Give the agent both code.fault_localize and VFS tools so it can autonomously collect coverage, rank files, and read the suspicious code:
use synwire_core::agents::agent_node::Agent;
use synwire_core::agents::runner::{Runner, RunnerConfig};
let agent = Agent::new("debugger", "claude-opus-4-6")
.system_prompt(
"You are a debugging assistant. When given a failing test:\n\
1. Run the test suite to collect coverage data\n\
2. Call code.fault_localize with the coverage records\n\
3. Read the top-ranked files\n\
4. Identify the root cause and suggest a fix"
)
.tools(tools) // VFS tools + code.fault_localize
.max_turns(20);
let runner = Runner::new(agent);
let mut rx = runner
.run(serde_json::json!("test_auth_token is failing -- find the bug"), RunnerConfig::default())
.await?;
What you learned
- The Ochiai coefficient ranks code lines by how strongly they correlate with test failures
SbflRankeraggregates line-level scores to file-level rankingsfuse_sbfl_semanticcombines SBFL with semantic search for better fault localization- The
code.fault_localizeMCP tool exposes this pipeline to agents
See also
- Tutorial 9: Semantic Search -- generating the semantic scores to fuse with SBFL
- Tutorial 15: Dataflow Analysis -- tracing how a variable reaches the faulty line
- Tutorial 18: Building a Debugging Agent -- end-to-end debugging workflow
- How-To: DAP Integration -- collecting coverage via the debug adapter
Dataflow Analysis
Time: ~30 minutes Prerequisites: Rust 1.85+, completion of Your First Agent
Background: Dataflow analysis traces how values propagate through code -- where a variable is defined, where it is reassigned, and which expressions contribute to its current value. This is essential for understanding why a variable holds an unexpected value.
This tutorial shows how to use the DataflowTracer to trace variable origins in source code, and how to integrate it into an agent workflow for automated debugging.
What the tracer does
DataflowTracer performs heuristic backward slicing: given a variable name and source text, it finds assignment sites (x = ...) and definition sites (let x = ...) by pattern matching. Each result is a DataflowHop containing the file, line, code snippet, and origin kind.
This is a lightweight analysis -- it does not require compilation or a running language server. For precise type-aware tracing, combine it with LSP tools (see How-To: LSP Integration).
Step 1: Add dependencies
[dependencies]
synwire-agent = { version = "0.1" }
Step 2: Trace a variable in Rust source
use synwire_agent::dataflow::DataflowTracer;
fn main() {
let source = r#"
fn process_request(input: &str) -> Result<Response, Error> {
let config = load_config()?;
let timeout = config.timeout_ms;
let client = HttpClient::new(timeout);
let response = client.get(input)?;
let status = response.status();
status = override_status(status);
Ok(Response::new(status))
}
"#;
let tracer = DataflowTracer::new(10);
let hops = tracer.trace(source, "status", "src/handler.rs");
for hop in &hops {
println!(
"[{}] {}:{} -- {}",
hop.origin.kind,
hop.origin.file,
hop.origin.line,
hop.origin.snippet,
);
}
}
Output:
[definition] src/handler.rs:6 -- let status = response.status();
[assignment] src/handler.rs:7 -- status = override_status(status);
The tracer found two sites: the initial let binding and a subsequent reassignment. An agent investigating an unexpected status value now knows to look at both response.status() and override_status.
Step 3: Control trace depth
The max_hops parameter limits how many origin sites are returned. Use a small value (2--3) for focused traces, or a larger value (10+) for thorough analysis of heavily-reassigned variables:
// Only find the first 2 assignment sites
let tracer = DataflowTracer::new(2);
let hops = tracer.trace(source, "x", "lib.rs");
assert!(hops.len() <= 2);
Step 4: Trace variables in other languages
The tracer works on any language that uses = for assignment and let for bindings. For languages like Python or JavaScript, the assignment patterns still match:
let python_source = r#"
def calculate_total(items):
total = 0
for item in items:
price = item.get_price()
total = total + price
total = apply_discount(total)
return total
"#;
let tracer = DataflowTracer::new(10);
let hops = tracer.trace(python_source, "total", "cart.py");
for hop in &hops {
println!("{}: line {} -- {}", hop.origin.kind, hop.origin.line, hop.origin.snippet);
}
// definition: line 2 -- total = 0
// assignment: line 5 -- total = total + price
// assignment: line 6 -- total = apply_discount(total)
Step 5: Use as an MCP tool
The code.trace_dataflow MCP tool wraps DataflowTracer. An agent calls it with a file path and variable name:
{
"tool": "code.trace_dataflow",
"arguments": {
"file": "src/handler.rs",
"variable": "status",
"max_hops": 10
}
}
The server reads the file, runs the tracer, and returns the hops as structured text.
Step 6: Integrate into an agent workflow
Combine dataflow tracing with file reading and semantic search to build an automated variable investigator:
use std::sync::Arc;
use synwire_core::agents::agent_node::Agent;
use synwire_core::agents::runner::{Runner, RunnerConfig};
use synwire_core::agents::streaming::AgentEvent;
let agent = Agent::new("tracer", "claude-opus-4-6")
.system_prompt(
"You investigate unexpected variable values. Your workflow:\n\
1. Read the file containing the variable\n\
2. Call code.trace_dataflow to find all assignment sites\n\
3. For each assignment, use semantic_search or grep to find the \
called functions\n\
4. Explain the full data flow from origin to the point of failure"
)
.tools(tools) // VFS tools + code.trace_dataflow
.max_turns(15);
let runner = Runner::new(agent);
let mut rx = runner
.run(
serde_json::json!("The variable `timeout` in src/client.rs has value 0 -- trace where it comes from"),
RunnerConfig::default(),
)
.await?;
while let Some(event) = rx.recv().await {
match event {
AgentEvent::TextDelta { content } => print!("{content}"),
AgentEvent::ToolCallStart { name, .. } => eprintln!("\n[calling {name}]"),
_ => {}
}
}
The agent reads the file, calls code.trace_dataflow to find assignment sites for timeout, then reads each called function to explain the full data flow.
Limitations
- The tracer uses text pattern matching, not a full AST. It may produce false positives for variables whose names appear in comments or strings.
- It does not trace across function boundaries. Use LSP
goto_definitionto follow values through call chains. - For languages without
letor=assignment syntax, results may be incomplete.
What you learned
DataflowTracerfinds where a variable is defined and modified using heuristic pattern matchingmax_hopscontrols how many origin sites are returned- The tracer works across languages that use standard assignment syntax
- The
code.trace_dataflowMCP tool makes this available to agents - Combine with LSP tools for cross-function tracing
See also
- Tutorial 14: Fault Localization -- ranking files by suspiciousness
- Tutorial 16: Code Analysis Tools -- combining dataflow with call graphs
- How-To: LSP Integration -- precise type-aware goto-definition
- Tutorial 18: Building a Debugging Agent -- full debugging workflow
Code Analysis Tools
Time: ~1 hour Prerequisites: Rust 1.85+, completion of Tutorial 9: Semantic Search
Background: Code Intelligence -- understanding code structure through call graphs, data flow, and semantic search enables agents to reason about codebases the way developers do.
This tutorial shows how to combine Synwire's analysis tools -- call graphs, semantic search, and dataflow tracing -- to build an agent that investigates bug reports by understanding code structure.
The DynamicCallGraph API
DynamicCallGraph is an incrementally-built directed graph. Each edge represents a caller-to-callee relationship discovered via LSP goto-definition or static analysis. The graph supports three key queries:
use synwire_agent::call_graph::DynamicCallGraph;
let mut graph = DynamicCallGraph::new();
// Build the graph edge by edge (typically populated by LSP results)
graph.add_edge("main", "parse_config");
graph.add_edge("main", "run_server");
graph.add_edge("run_server", "handle_request");
graph.add_edge("handle_request", "validate_auth");
graph.add_edge("handle_request", "execute_query");
graph.add_edge("execute_query", "db_connect");
// Query 1: What does handle_request call?
let callees = graph.callees("handle_request");
println!("handle_request calls: {:?}", callees);
// ["validate_auth", "execute_query"]
// Query 2: Who calls execute_query?
let callers = graph.callers("execute_query");
println!("execute_query called by: {:?}", callers);
// ["handle_request"]
// Query 3: Are there cycles?
println!("Has cycles: {}", graph.has_cycle());
// false
Step 1: Build a call graph from LSP
In practice, the call graph is populated by following LSP goto-definition results. The MCP server's code.trace_callers tool does this automatically, but you can also build it programmatically:
use synwire_agent::call_graph::{CallNode, DynamicCallGraph};
/// Build a call graph by following goto-definition for each function call.
async fn build_call_graph(
lsp: &synwire_lsp::client::LspClient,
entry_file: &str,
) -> DynamicCallGraph {
let mut graph = DynamicCallGraph::new();
// Get all symbols in the entry file
let symbols = lsp.document_symbols(entry_file).await.unwrap_or_default();
for symbol in &symbols {
// For each function, find what it calls via references
let refs = lsp.references(entry_file, symbol.line, symbol.column).await;
if let Ok(locations) = refs {
for loc in &locations {
graph.add_edge(&symbol.name, &loc.symbol_name);
}
}
}
graph
}
Step 2: Detect dependency cycles
Circular dependencies often indicate architectural problems. The has_cycle method uses depth-first search to detect them:
let mut graph = DynamicCallGraph::new();
graph.add_edge("module_a::init", "module_b::setup");
graph.add_edge("module_b::setup", "module_c::configure");
graph.add_edge("module_c::configure", "module_a::init"); // cycle!
if graph.has_cycle() {
println!("Circular dependency detected!");
// An agent could report the cycle and suggest refactoring
}
Step 3: Combine call graph with semantic search
Use semantic search to find relevant code, then expand understanding with the call graph:
use synwire_core::vfs::types::SemanticSearchOptions;
// Step 1: Semantic search finds entry points
let results = vfs.semantic_search(
"authentication token validation",
SemanticSearchOptions { top_k: Some(5), ..Default::default() },
).await?;
// Step 2: For each result, query the call graph to find callers and callees
let mut graph = DynamicCallGraph::new();
for result in &results {
if let Some(ref symbol) = result.symbol {
// Use LSP or MCP code.trace_callers to populate edges
let callees = get_callees_from_lsp(symbol).await;
for callee in &callees {
graph.add_edge(symbol, callee);
}
}
}
// Step 3: Find all callers of the token validator
let callers = graph.callers("validate_token");
println!("validate_token is called by: {:?}", callers);
This pattern -- semantic search for discovery, call graph for structure -- is how agents build a mental model of unfamiliar code.
Step 4: Use analysis tools via MCP
The MCP server exposes three analysis tools that agents can call:
| Tool | Namespace | Purpose |
|---|---|---|
code.trace_callers | code | Query callers/callees of a symbol |
code.trace_dataflow | code | Trace variable assignments backward |
code.fault_localize | code | Rank files by test failure correlation |
An agent investigating a bug typically uses them in sequence:
// 1. Find relevant code
{"tool": "index.search", "arguments": {"query": "payment processing error"}}
// 2. Understand the call chain
{"tool": "code.trace_callers", "arguments": {"symbol": "process_payment", "direction": "both"}}
// 3. Trace the problematic variable
{"tool": "code.trace_dataflow", "arguments": {"file": "src/payment.rs", "variable": "amount"}}
// 4. Rank files by fault likelihood (if tests are failing)
{"tool": "code.fault_localize", "arguments": {"coverage": [...]}}
Step 5: Build a bug investigation agent
Combine all analysis tools into a single agent that can investigate a bug report end-to-end:
use std::sync::Arc;
use synwire_core::agents::agent_node::Agent;
use synwire_core::agents::runner::{Runner, RunnerConfig};
use synwire_core::agents::streaming::AgentEvent;
let agent = Agent::new("investigator", "claude-opus-4-6")
.system_prompt(
"You are a code investigation agent. Given a bug report, follow this process:\n\n\
1. Use index.search to find code related to the bug description\n\
2. Use code.trace_callers to understand what calls what\n\
3. Use code.trace_dataflow on suspicious variables\n\
4. Use read to examine specific functions in detail\n\
5. Synthesize your findings into a root cause analysis\n\n\
Always explain your reasoning at each step."
)
.tools(tools) // VFS + index.search + code.trace_callers + code.trace_dataflow
.max_turns(30);
let runner = Runner::new(agent);
let mut rx = runner
.run(
serde_json::json!(
"Bug #1234: Users report that discount codes are applied twice \
when checking out with multiple items. The total shown is lower \
than expected."
),
RunnerConfig::default(),
)
.await?;
while let Some(event) = rx.recv().await {
match event {
AgentEvent::TextDelta { content } => print!("{content}"),
AgentEvent::ToolCallStart { name, .. } => {
eprintln!("\n[{name}]");
}
_ => {}
}
}
The agent might:
- Search for "discount code application" via
index.search - Find
apply_discountinsrc/checkout.rs - Query
code.trace_callersfor callers ofapply_discount-- discovers it is called from bothprocess_itemandfinalize_cart - Trace
discount_amountviacode.trace_dataflow-- finds it is accumulated without resetting - Report that the discount is applied per-item and per-cart, causing double application
What you learned
DynamicCallGraphstores caller/callee relationships and detects cycles- Call graph queries reveal code structure that text search cannot
- Combining semantic search (find relevant code) with call graphs (understand structure) and dataflow (trace values) gives agents comprehensive code understanding
- The MCP tools
code.trace_callers,code.trace_dataflow, andcode.fault_localizeare available viasynwire-mcp-server
See also
- Tutorial 14: Fault Localization -- SBFL ranking
- Tutorial 15: Dataflow Analysis -- tracing variable origins
- Tutorial 9: Semantic Search -- meaning-based code search
- Tutorial 18: Building a Debugging Agent -- full end-to-end debugging agent
- How-To: LSP Integration -- using LSP for precise goto-definition
Advanced MCP Server Setup
Time: ~1 hour
Prerequisites: Rust 1.85+, synwire-mcp-server installed, completion of Tutorial 11: Getting Started with the MCP Server
This tutorial covers advanced synwire-mcp-server configuration: enabling LSP and DAP tools, setting up the daemon for multi-project indexing, and using namespace-based tool discovery to reduce token usage.
MCP server architecture
The synwire-mcp-server binary runs as a stdio-based MCP server. It communicates with the host (Claude Code, an IDE, or your own agent) over stdin/stdout using the MCP protocol.
Internally it connects to the synwire-daemon, a singleton background process that owns the embedding model, file watchers, indexing pipelines, and all persistent state. Multiple MCP server instances share a single daemon.
Host (Claude Code)
| stdio
v
synwire-mcp-server (thin proxy)
| Unix domain socket
v
synwire-daemon (singleton, owns indices + LSP + DAP)
Step 1: Basic configuration
The minimal configuration indexes a single project:
{
"mcpServers": {
"synwire": {
"command": "synwire-mcp-server",
"args": ["--project", "."]
}
}
}
This registers all VFS tools (fs.read, fs.write, fs.edit, fs.grep, fs.glob, fs.tree, code.skeleton), indexing tools (index.run, index.status, index.search), and meta tools (meta.tool_search, meta.tool_list).
Step 2: LSP tools (auto-detected by default)
When --project is set, the MCP server automatically detects which language
servers to use. It scans the project directory for file extensions, matches them
against the built-in LanguageServerRegistry (22+ servers), and checks if the
binary is on your PATH.
With the basic config from Step 1 and rust-analyzer installed, a Rust project
gets LSP tools with zero extra flags:
{
"mcpServers": {
"synwire": {
"command": "synwire-mcp-server",
"args": ["--project", "."]
}
}
}
Auto-detection enables four additional tools:
| Tool | Description |
|---|---|
lsp.hover | Get type information and documentation for a symbol at a position |
lsp.goto_definition | Jump to where a symbol is defined |
lsp.references | Find all references to a symbol |
lsp.document_symbols | List all symbols in a file |
The MCP server launches the language server as a child process, manages document synchronisation, and translates MCP tool calls into LSP requests.
Overriding auto-detection
Use --lsp to force a specific server (e.g., when multiple servers handle the
same language):
# Force pyright instead of auto-detected pylsp
synwire-mcp-server --project . --lsp pyright
Disabling LSP entirely
Use --no-lsp to disable auto-detection and all LSP tools:
synwire-mcp-server --project . --no-lsp
Polyglot repos
Auto-detection supports polyglot repositories. If your project contains both
.rs and .ts files and both rust-analyzer and typescript-language-server
are installed, both are detected. The server logs which servers were found:
INFO Auto-detected language servers servers=["rust-analyzer", "typescript-language-server"]
Currently the primary server (most common extension) is used for tool dispatch. Future versions will support concurrent multi-server dispatch.
Supported language servers
The built-in LanguageServerRegistry includes definitions for 22+ language servers. Common examples:
| Language | Command |
|---|---|
| Rust | rust-analyzer |
| TypeScript | typescript-language-server |
| Python | pyright or pylsp |
| Go | gopls |
| C/C++ | clangd |
# Rust project (auto-detected, or explicit)
synwire-mcp-server --project . --lsp rust-analyzer
# Python project
synwire-mcp-server --project . --lsp pyright
# Go project
synwire-mcp-server --project . --lsp gopls
Step 3: DAP tools (auto-detected by default)
Like LSP, debug adapters are auto-detected when --project is set. The
server maps detected file extensions to language identifiers and checks if a
matching adapter binary is on PATH.
For a Rust project with codelldb installed, debugging tools appear
automatically:
{
"mcpServers": {
"synwire": {
"command": "synwire-mcp-server",
"args": ["--project", "."]
}
}
}
Use --dap to override, or --no-dap to disable:
# Override: force a specific adapter
synwire-mcp-server --project . --dap codelldb
# Disable all DAP tools
synwire-mcp-server --project . --no-dap
This enables debugging tools:
| Tool | Description |
|---|---|
debug.set_breakpoints | Set breakpoints at a file and line |
debug.evaluate | Evaluate an expression in the current debug context |
The DAP plugin also exposes session management tools (debug.launch, debug.attach, debug.continue, debug.step_over, debug.variables, etc.) through the agent plugin system.
Step 4: Tool discovery with tool_search
With LSP, DAP, VFS, indexing, and analysis tools all registered, the full tool set exceeds 25 tools. Listing all of them in every prompt wastes tokens. The tool_search and tool_list meta-tools solve this.
How it works
At startup, the server registers all tools with a ToolSearchIndex. Tools are grouped into namespaces:
| Namespace | Tools |
|---|---|
fs | fs.read, fs.write, fs.edit, fs.grep, fs.glob, fs.tree |
code | code.search, code.definition, code.references, code.skeleton, code.trace_callers, code.trace_callees, code.trace_dataflow, code.fault_localize, code.community_search, code.community_members, code.graph_query |
index | index.run, index.status, index.search, index.hybrid_search |
lsp | lsp.hover, lsp.goto_definition, lsp.references, lsp.document_symbols |
debug | debug.launch, debug.attach, debug.set_breakpoints, debug.evaluate, ... (14 total) |
vcs | vcs.clone_repo |
meta | meta.tool_search, meta.tool_list |
Browse a namespace
{"tool": "meta.tool_search", "arguments": {"namespace": "lsp"}}
Returns all LSP tools with name and description.
Search by intent
{"tool": "meta.tool_search", "arguments": {"query": "find where a function is defined"}}
Returns the most relevant tools ranked by keyword + semantic similarity.
Progressive discovery
The index applies adaptive scoring: tools already returned in previous searches get a 0.8x penalty, surfacing unseen tools in subsequent queries. This naturally guides the agent toward tools it has not yet tried.
Step 5: Agent skills integration
Agent skills are reusable tool bundles following the agentskills.io specification. They live in two locations:
- Global:
$XDG_DATA_HOME/synwire/skills/(shared across projects) - Project-local:
.synwire/skills/(project-specific)
Each skill is a directory containing:
my-skill/
SKILL.md # Name, description, parameters, runtime
scripts/ # Lua, Rhai, or WASM implementation
references/ # Documentation the skill can read
assets/ # Static files
The MCP server discovers skills at startup and registers them with tool_search. The agent sees only name and description until it activates a skill, at which point the full body is loaded.
Creating a project-local skill
mkdir -p .synwire/skills/lint-fix
Create .synwire/skills/lint-fix/SKILL.md:
---
name: lint-fix
description: Run the project linter and auto-fix warnings
runtime: tool-sequence
parameters:
- name: path
type: string
description: File or directory to lint
---
1. Run `cargo clippy --fix --allow-dirty` on the target path
2. Read the output and report any remaining warnings
The tool-sequence runtime means the skill is a sequence of existing tool calls -- no scripting needed. The MCP server expands the steps into tool calls at invocation time.
Step 6: Daemon configuration
The daemon starts automatically when the first MCP server connects and stops 5 minutes after the last client disconnects. You can configure it via environment variables or a config file:
# Environment variables
export SYNWIRE_PRODUCT=myapp # Isolates storage from other instances
export SYNWIRE_EMBEDDING_MODEL=bge-small-en-v1.5
export RUST_LOG=synwire=debug
# Config file (~/.config/synwire/config.toml)
[daemon]
embedding_model = "bge-small-en-v1.5"
log_level = "info"
grace_period_secs = 300
Multi-project indexing
The daemon identifies projects by RepoId (git first-commit hash) and WorktreeId (repo + worktree path). Multiple MCP servers pointing at different worktrees of the same repo share a single RepoId but maintain separate indices.
# Terminal 1: main branch
cd ~/projects/myapp
synwire-mcp-server --project .
# Terminal 2: feature branch (different worktree, same repo)
cd ~/projects/myapp-feature
synwire-mcp-server --project .
Both connect to the same daemon. Indices are stored separately under $XDG_CACHE_HOME/synwire/<repo_id>/<worktree_id>/.
Full configuration example
With auto-detection, a minimal config is usually sufficient:
{
"mcpServers": {
"synwire": {
"command": "synwire-mcp-server",
"args": ["--project", "."]
}
}
}
For full control, all options can be specified explicitly:
{
"mcpServers": {
"synwire": {
"command": "synwire-mcp-server",
"args": [
"--project", ".",
"--lsp", "rust-analyzer",
"--dap", "codelldb",
"--product-name", "myapp",
"--embedding-model", "bge-small-en-v1.5",
"--log-level", "info"
]
}
}
}
To disable auto-detection for one or both integrations:
{
"mcpServers": {
"synwire": {
"command": "synwire-mcp-server",
"args": [
"--project", ".",
"--no-lsp",
"--no-dap"
]
}
}
}
What you learned
- LSP and DAP tools are auto-detected when
--projectis set --lspand--dapoverride auto-detection with a specific server/adapter--no-lspand--no-dapdisable auto-detection and all related tools- Polyglot repos detect multiple servers; the primary language is used for dispatch
meta.tool_searchandmeta.tool_listprovide namespace-based progressive tool discovery- Agent skills are discovered from global and project-local directories
- The daemon manages shared state across multiple MCP server instances
- Multi-worktree projects share a
RepoIdbut maintain separate indices
See also
- Tutorial 11: Getting Started with the MCP Server -- basic setup
- Tutorial 12: Authoring Your First Agent Skill -- skill authoring
- How-To: LSP Integration -- LSP tool details
- How-To: DAP Integration -- DAP tool details
- How-To: MCP Integration -- MCP transport options
- synwire-daemon -- daemon architecture
Building a Debugging Agent
Time: ~90 minutes Prerequisites: Rust 1.85+, completion of tutorials 14, 15, and 16
Background: ReAct Agents -- the reason-then-act loop that debugging agents use. Each observation (search result, coverage data, variable trace) feeds back into the next reasoning step.
This tutorial builds an end-to-end debugging agent that accepts a bug report and produces a root cause analysis with a proposed fix. It combines semantic search, SBFL fault localization, dataflow analysis, LSP tools, and file operations into a single agent.
What you are building
A binary that:
- Accepts a bug report as input
- Uses semantic search to find relevant code
- Uses SBFL to identify suspicious files from test coverage
- Uses dataflow analysis to trace variable origins
- Uses LSP tools for precise type information
- Produces a root cause analysis and suggests a fix
Step 1: Dependencies
[dependencies]
synwire-agent = { version = "0.1", features = ["semantic-search"] }
synwire-core = { version = "0.1" }
synwire-lsp = { version = "0.1" }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
Step 2: Set up the VFS and tools
The agent needs access to file operations, semantic search, and analysis tools. Start by creating the VFS and collecting all tools:
use std::sync::Arc;
use synwire_agent::vfs::local::LocalProvider;
use synwire_core::vfs::{vfs_tools, OutputFormat};
use std::path::PathBuf;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let project_root = PathBuf::from(".");
let vfs = Arc::new(LocalProvider::new(project_root)?);
// VFS tools: fs.read, fs.write, fs.edit, fs.grep, fs.glob, fs.tree,
// code.skeleton, index.run, index.status, index.search
let tools = vfs_tools(Arc::clone(&vfs) as Arc<_>, OutputFormat::Plain);
// The MCP server adds code.fault_localize, code.trace_dataflow, and
// code.trace_callers on top of VFS tools. When using the Rust API
// directly, we construct them as StructuredTool instances.
Ok(())
}
Step 3: Create analysis tools
Wrap the SBFL, dataflow, and call graph modules as agent tools:
use synwire_core::tools::{StructuredTool, ToolOutput, ToolSchema};
use synwire_agent::sbfl::{CoverageRecord, SbflRanker, fuse_sbfl_semantic};
use synwire_agent::dataflow::DataflowTracer;
use synwire_agent::call_graph::DynamicCallGraph;
fn fault_localize_tool() -> StructuredTool {
StructuredTool::builder()
.name("code.fault_localize")
.description(
"Rank source files by fault likelihood using SBFL/Ochiai. \
Provide coverage data as an array of {file, line, ef, nf, np} objects."
)
.schema(ToolSchema {
name: "code.fault_localize".into(),
description: "SBFL fault localization".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"coverage": {
"type": "array",
"items": { "type": "object" }
}
},
"required": ["coverage"]
}),
})
.func(|input| Box::pin(async move {
let coverage: Vec<CoverageRecord> = input["coverage"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| Some(CoverageRecord {
file: v["file"].as_str()?.to_owned(),
line: v["line"].as_u64()? as u32,
ef: v["ef"].as_u64()? as u32,
nf: v["nf"].as_u64()? as u32,
np: v["np"].as_u64()? as u32,
}))
.collect();
let ranker = SbflRanker::new(coverage);
let ranked = ranker.rank_files();
let output = ranked
.iter()
.map(|(f, s)| format!("{f}: {s:.3}"))
.collect::<Vec<_>>()
.join("\n");
Ok(ToolOutput { content: output, ..Default::default() })
}))
.build()
.expect("valid tool")
}
fn dataflow_trace_tool(vfs: Arc<dyn synwire_core::vfs::protocol::Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("code.trace_dataflow")
.description(
"Trace a variable's assignments backward through a source file. \
Returns definition and assignment sites."
)
.schema(ToolSchema {
name: "code.trace_dataflow".into(),
description: "Variable dataflow tracing".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"file": { "type": "string" },
"variable": { "type": "string" },
"max_hops": { "type": "integer" }
},
"required": ["file", "variable"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let file = input["file"].as_str().unwrap_or("");
let variable = input["variable"].as_str().unwrap_or("");
let max_hops = input["max_hops"].as_u64().unwrap_or(10) as u32;
let content = vfs.read(file).await.map_err(|e|
synwire_core::error::SynwireError::Tool(
synwire_core::error::ToolError::InvocationFailed {
message: e.to_string(),
},
)
)?;
let tracer = DataflowTracer::new(max_hops);
let hops = tracer.trace(&content, variable, file);
let output = hops
.iter()
.map(|h| format!(
"[{}] {}:{} -- {}",
h.origin.kind, h.origin.file, h.origin.line, h.origin.snippet,
))
.collect::<Vec<_>>()
.join("\n");
Ok(ToolOutput { content: output, ..Default::default() })
})
})
.build()
.expect("valid tool")
}
Step 4: Configure the agent
The system prompt guides the agent through a structured debugging workflow:
use synwire_core::agents::agent_node::Agent;
use synwire_core::agents::runner::{Runner, RunnerConfig};
use synwire_core::agents::streaming::AgentEvent;
let mut all_tools = tools; // VFS tools from Step 2
all_tools.push(Box::new(fault_localize_tool()));
all_tools.push(Box::new(dataflow_trace_tool(Arc::clone(&vfs) as Arc<_>)));
let agent = Agent::new("debugger", "claude-opus-4-6")
.system_prompt(
"You are an expert debugging agent. Follow this structured process:\n\n\
# Phase 1: Understand the bug\n\
- Parse the bug report for symptoms, expected vs actual behaviour\n\
- Identify key terms, error messages, and affected features\n\n\
# Phase 2: Locate relevant code\n\
- Call `index.run` then `index.search` with the bug description\n\
- Use `fs.grep` for specific error messages or identifiers\n\
- Use `fs.tree` to understand project structure if needed\n\n\
# Phase 3: Analyse fault likelihood\n\
- If test coverage data is available, call `code.fault_localize`\n\
- Read the top-ranked files\n\n\
# Phase 4: Trace data flow\n\
- For suspicious variables, call `code.trace_dataflow`\n\
- Follow the chain of assignments to find the origin\n\n\
# Phase 5: Report\n\
- State the root cause with file paths and line numbers\n\
- Explain the causal chain from origin to symptom\n\
- Suggest a specific fix with code changes\n\n\
Be methodical. Show your reasoning at each phase."
)
.tools(all_tools)
.max_turns(40);
Step 5: Run the agent
let runner = Runner::new(agent);
let config = RunnerConfig::default();
let bug_report = serde_json::json!(
"Bug #4521: When a user submits a form with special characters in the \
'name' field (e.g. O'Brien), the server returns a 500 error. \
The error log shows: 'SqliteError: unrecognized token near O'. \
This started after the recent refactor of the user service."
);
let mut rx = runner.run(bug_report, config).await?;
while let Some(event) = rx.recv().await {
match event {
AgentEvent::TextDelta { content } => print!("{content}"),
AgentEvent::ToolCallStart { name, .. } => {
eprintln!("\n--- [{name}] ---");
}
AgentEvent::ToolCallEnd { name, .. } => {
eprintln!("--- [/{name}] ---\n");
}
AgentEvent::TurnComplete { reason } => {
println!("\n\n[Agent finished: {reason:?}]");
}
AgentEvent::Error { message } => {
eprintln!("Error: {message}");
}
_ => {}
}
}
Expected agent behaviour
For the SQL injection bug above, the agent would typically:
- Semantic search for "SQL query construction" and "user input sanitisation"
fs.grepfor the error stringunrecognized token- Read the user service files found by search
- Dataflow trace the
namevariable to find where it enters the SQL query - Report that user input is interpolated directly into a SQL string without escaping, and suggest using parameterised queries
Step 6: Add LSP for precision
When synwire-lsp is available, add LSP tools for type-aware investigation:
use synwire_lsp::{client::LspClient, config::LspServerConfig, tools::lsp_tools};
let lsp_config = LspServerConfig::new("rust-analyzer");
let lsp_client = LspClient::start(&lsp_config).await?;
lsp_client.initialize().await?;
let lsp_tool_set = lsp_tools(Arc::new(lsp_client));
// Add LSP tools to the agent's tool set
all_tools.extend(lsp_tool_set);
With LSP tools, the agent can:
lsp.hoverto check the type of a variable (isnamea&stror a sanitisedSafeString?)lsp.goto_definitionto find the exact function that builds the SQL querylsp.referencesto find all call sites that pass unsanitised input
Putting it all together
The complete main.rs:
use std::sync::Arc;
use synwire_agent::vfs::local::LocalProvider;
use synwire_core::agents::agent_node::Agent;
use synwire_core::agents::runner::{Runner, RunnerConfig};
use synwire_core::agents::streaming::AgentEvent;
use synwire_core::vfs::{vfs_tools, OutputFormat};
use std::path::PathBuf;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let vfs = Arc::new(LocalProvider::new(PathBuf::from("."))?);
let mut tools = vfs_tools(Arc::clone(&vfs) as Arc<_>, OutputFormat::Plain);
tools.push(Box::new(fault_localize_tool()));
tools.push(Box::new(dataflow_trace_tool(Arc::clone(&vfs) as Arc<_>)));
let agent = Agent::new("debugger", "claude-opus-4-6")
.system_prompt("...") // system prompt from Step 4
.tools(tools)
.max_turns(40);
let runner = Runner::new(agent);
let bug = std::env::args().nth(1).unwrap_or_else(|| {
"Describe the bug here".to_string()
});
let mut rx = runner
.run(serde_json::json!(bug), RunnerConfig::default())
.await?;
while let Some(event) = rx.recv().await {
match event {
AgentEvent::TextDelta { content } => print!("{content}"),
AgentEvent::ToolCallStart { name, .. } => {
eprintln!("\n--- [{name}] ---");
}
AgentEvent::TurnComplete { reason } => {
println!("\n[{reason:?}]");
}
_ => {}
}
}
Ok(())
}
Run it:
cargo run -- "Bug #4521: form submission with special characters causes 500 error"
What you learned
- A debugging agent combines semantic search, SBFL, dataflow tracing, and file operations
- The system prompt structures the agent's investigation into phases
- Analysis modules from
synwire-agentcan be wrapped asStructuredToolinstances - LSP tools add type-aware precision to the investigation
- The agent autonomously decides which tools to call and in what order
See also
- Tutorial 7: Building a Coding Agent -- general coding agent pattern
- Tutorial 14: Fault Localization -- SBFL in depth
- Tutorial 15: Dataflow Analysis -- variable tracing
- Tutorial 16: Code Analysis Tools -- call graphs and combined analysis
- Tutorial 17: Advanced MCP Setup -- all these tools via MCP
- How-To: Approval Gates -- requiring human approval before the agent applies fixes
Building a Custom MCP Server
Time: ~60 minutes Prerequisites: Rust 1.85+, familiarity with Tutorial 11 and Tutorial 17
This tutorial builds a custom MCP server binary from scratch using Synwire's tool
composition primitives. By the end you will have a working stdio JSON-RPC server
that exposes file operations, code analysis tools, a custom tool, and a
multi-step investigation workflow -- all wired through a single
CompositeToolProvider.
Architecture
graph LR
subgraph "MCP Host"
Host[Claude Code / Cursor / IDE]
end
subgraph "Your MCP Server (stdio)"
Router[CompositeToolProvider]
MW[LoggingInterceptor]
end
subgraph "Backends"
VFS[LocalProvider<br/>fs.* tools]
Code[code_tool_provider<br/>code.* tools]
Idx[index_tool_provider<br/>index.* tools]
LSP[lsp_tool_provider<br/>lsp.* tools]
DAP[debug_tool_provider<br/>debug.* tools]
Custom[custom.greet]
end
Host -- JSON-RPC stdin --> MW
MW --> Router
Router --> VFS
Router --> Code
Router --> Idx
Router --> LSP
Router --> DAP
Router --> Custom
The host sends MCP requests over stdin. The server dispatches each tool call
through middleware, resolves the target tool from the CompositeToolProvider,
invokes it, and writes the result to stdout.
Step 1: Create the project
cargo new my-mcp-server
cd my-mcp-server
Add dependencies to Cargo.toml:
[dependencies]
synwire = { version = "0.1" }
synwire-core = { version = "0.1" }
synwire-agent = { version = "0.1", features = ["semantic-search"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Step 2: Assemble tools with CompositeToolProvider
Synwire ships pre-built tool providers grouped by namespace. Combine them into a single provider:
use std::path::PathBuf;
use std::sync::Arc;
use synwire_agent::vfs::local::LocalProvider;
use synwire_core::tools::{CompositeToolProvider, ToolProvider};
use synwire_core::vfs::vfs_tools;
use synwire_agent::tools::{code_tool_provider, index_tool_provider};
fn build_provider(project: PathBuf) -> Arc<CompositeToolProvider> {
let vfs = Arc::new(LocalProvider::new(project).expect("valid project path"));
let mut composite = CompositeToolProvider::new();
// fs.read, fs.write, fs.edit, fs.grep, fs.glob, fs.tree
composite.add_provider(vfs_tools(Arc::clone(&vfs) as Arc<_>));
// code.search, code.definition, code.references, code.trace_callers,
// code.trace_dataflow, code.fault_localize, and more
composite.add_provider(code_tool_provider(Arc::clone(&vfs) as Arc<_>));
// index.run, index.status, index.search, index.hybrid_search
composite.add_provider(index_tool_provider(Arc::clone(&vfs) as Arc<_>));
Arc::new(composite)
}
Each provider registers its tools under a namespace prefix. When the host calls
fs.read, the CompositeToolProvider routes it to the VFS provider.
Step 3: Define a custom tool
Use the #[tool] derive macro to create a tool in your own namespace:
use synwire_derive::tool;
use synwire_core::tools::{ToolOutput, ToolError};
/// Greet a user by name.
///
/// Returns a friendly greeting. Use this tool when the user asks
/// for a personalised welcome message.
#[tool(name = "custom.greet")]
async fn greet(
/// The name of the person to greet.
name: String,
/// Optional language code (default: "en").
lang: Option<String>,
) -> Result<ToolOutput, ToolError> {
let greeting = match lang.as_deref().unwrap_or("en") {
"es" => format!("Hola, {name}!"),
"fr" => format!("Bonjour, {name}!"),
"de" => format!("Hallo, {name}!"),
_ => format!("Hello, {name}!"),
};
Ok(ToolOutput::text(greeting))
}
Register it alongside the built-in providers:
composite.add_tool(greet_tool());
Step 4: Wire middleware for logging
Wrap every tool call in a LoggingInterceptor so you can observe traffic on
stderr:
use synwire_core::tools::middleware::{
ToolCallInterceptor, LoggingInterceptor, InterceptorStack,
};
fn build_interceptor_stack() -> InterceptorStack {
let mut stack = InterceptorStack::new();
stack.push(LoggingInterceptor::new(tracing::Level::DEBUG));
stack
}
The LoggingInterceptor emits a tracing span for each tool call containing
the tool name, a truncated argument summary, and the wall-clock duration. All
output goes to stderr -- stdout is reserved for the MCP protocol.
Step 5: Add a composed multi-step workflow
Turn a StateGraph into a single tool. This example creates code.investigate
-- a tool that chains semantic search, file read, and LLM analysis into one
call:
use synwire_orchestrator::{StateGraph, CompiledGraph};
use synwire_core::tools::ToolOutput;
fn investigate_tool(
provider: Arc<CompositeToolProvider>,
) -> impl synwire_core::tools::Tool {
let mut graph = StateGraph::new("investigate");
// Node 1: semantic search for the query
graph.add_node("search", {
let p = Arc::clone(&provider);
move |state: &mut InvestigateState| {
let p = Arc::clone(&p);
Box::pin(async move {
let tool = p.get_tool("index.search").expect("index.search registered");
let result = tool.invoke(serde_json::json!({
"query": state.query,
"top_k": 3,
})).await?;
state.search_results = result.content;
Ok(())
})
}
});
// Node 2: read the top-ranked file
graph.add_node("read", {
let p = Arc::clone(&provider);
move |state: &mut InvestigateState| {
let p = Arc::clone(&p);
Box::pin(async move {
let first_file = state.search_results
.lines()
.next()
.unwrap_or("")
.to_string();
let tool = p.get_tool("fs.read").expect("fs.read registered");
let result = tool.invoke(serde_json::json!({
"path": first_file,
})).await?;
state.file_content = result.content;
Ok(())
})
}
});
// Node 3: produce a summary
graph.add_node("summarise", |state: &mut InvestigateState| {
Box::pin(async move {
state.summary = format!(
"## Search results\n{}\n\n## Top file content\n{}",
state.search_results, state.file_content,
);
Ok(())
})
});
graph.add_edge("search", "read");
graph.add_edge("read", "summarise");
graph.set_entry("search");
// Convert the graph into a callable tool
graph.as_tool(
"code.investigate",
"Search for code matching a query, read the top result, \
and return a combined summary. Use when you need to understand \
how a feature is implemented.",
)
}
Register it:
composite.add_tool(investigate_tool(Arc::clone(&provider)));
Step 6: Serve via stdio JSON-RPC
The server reads newline-delimited JSON-RPC requests from stdin, dispatches each tool call through the interceptor stack and provider, and writes responses to stdout:
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader};
async fn serve(
provider: Arc<CompositeToolProvider>,
interceptors: InterceptorStack,
) -> Result<(), Box<dyn std::error::Error>> {
let stdin = BufReader::new(io::stdin());
let mut stdout = io::stdout();
let mut lines = stdin.lines();
while let Some(line) = lines.next_line().await? {
let request: serde_json::Value = serde_json::from_str(&line)?;
let method = request["method"].as_str().unwrap_or("");
match method {
"tools/list" => {
let tools = provider.list_tools();
let response = serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": { "tools": tools },
});
let mut out = serde_json::to_string(&response)?;
out.push('\n');
stdout.write_all(out.as_bytes()).await?;
stdout.flush().await?;
}
"tools/call" => {
let name = request["params"]["name"].as_str().unwrap_or("");
let args = request["params"]["arguments"].clone();
let result = interceptors
.wrap(name, &args, || async {
let tool = provider
.get_tool(name)
.ok_or_else(|| format!("unknown tool: {name}"))?;
tool.invoke(args.clone()).await
})
.await;
let response = match result {
Ok(output) => serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": {
"content": [{ "type": "text", "text": output.content }],
},
}),
Err(e) => serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": {
"content": [{ "type": "text", "text": e.to_string() }],
"isError": true,
},
}),
};
let mut out = serde_json::to_string(&response)?;
out.push('\n');
stdout.write_all(out.as_bytes()).await?;
stdout.flush().await?;
}
_ => {
// Ignore unknown methods (initialize, notifications, etc.)
}
}
}
Ok(())
}
Step 7: Full working main.rs
use std::path::PathBuf;
use std::sync::Arc;
mod tools; // greet_tool(), investigate_tool()
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Send logs to stderr only -- stdout is the MCP transport.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter("my_mcp_server=debug,synwire=info")
.init();
let project = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
// Assemble all tool providers.
let provider = build_provider(project);
// Add custom tools.
let provider = {
let mut p = (*provider).clone();
p.add_tool(greet_tool());
p.add_tool(investigate_tool(Arc::new(p.clone())));
Arc::new(p)
};
// Build the interceptor stack.
let interceptors = build_interceptor_stack();
tracing::info!("MCP server ready, listening on stdin");
serve(provider, interceptors).await
}
Configure it in .claude/mcp.json:
{
"my-tools": {
"command": "./target/release/my-mcp-server",
"args": ["/path/to/project"]
}
}
What you learned
CompositeToolProvidermerges multiple namespace-grouped providers into one dispatch tablevfs_tools(),code_tool_provider(), andindex_tool_provider()supply pre-built tools underfs.*,code.*, andindex.*namespaces- The
#[tool]macro creates tools with automatic JSON Schema generation LoggingInterceptortraces every tool call without touching tool implementationsStateGraph::as_tool()turns a multi-step graph into a single callable tool- A stdio MCP server is a thin JSON-RPC loop: read stdin, dispatch to
CompositeToolProvider, write stdout
See also
- Tutorial 11: Getting Started with the MCP Server -- using the built-in server
- Tutorial 17: Advanced MCP Server Setup -- LSP, DAP, and daemon configuration
- How-To: MCP Integration -- MCP transport options
- How-To: Middleware Stack -- custom interceptors
- How-To: Tool Output Formats -- TOON, JSON, Markdown output
Custom Tool
Using the #[tool] macro
The simplest way to create a tool:
use synwire_derive::tool;
use synwire_core::error::SynwireError;
/// Reverses the input string.
#[tool]
async fn reverse(text: String) -> Result<String, SynwireError> {
Ok(text.chars().rev().collect())
}
// Use: let tool = reverse_tool()?;
Using StructuredToolBuilder
For dynamic tool creation:
use synwire_core::tools::{StructuredTool, ToolOutput};
let tool = StructuredTool::builder()
.name("word_count")
.description("Counts words in text")
.parameters(serde_json::json!({
"type": "object",
"properties": {
"text": {"type": "string", "description": "Text to count"}
},
"required": ["text"]
}))
.func(|input| Box::pin(async move {
let text = input["text"].as_str().unwrap_or("");
let count = text.split_whitespace().count();
Ok(ToolOutput {
content: format!("{count} words"),
artifact: None,
})
}))
.build()?;
Implementing the Tool trait
For full control:
use synwire_core::tools::{Tool, ToolOutput, ToolSchema};
use synwire_core::error::SynwireError;
use synwire_core::BoxFuture;
struct HttpFetcher {
schema: ToolSchema,
}
impl HttpFetcher {
fn new() -> Self {
Self {
schema: ToolSchema {
name: "http_fetch".into(),
description: "Fetches a URL".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"url": {"type": "string"}
},
"required": ["url"]
}),
},
}
}
}
impl Tool for HttpFetcher {
fn name(&self) -> &str { &self.schema.name }
fn description(&self) -> &str { &self.schema.description }
fn schema(&self) -> &ToolSchema { &self.schema }
fn invoke(
&self,
input: serde_json::Value,
) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
Box::pin(async move {
let url = input["url"].as_str().unwrap_or("");
// Fetch URL...
Ok(ToolOutput {
content: format!("Fetched: {url}"),
artifact: None,
})
})
}
}
Returning artifacts
Tools can return both text content and structured artifacts:
Ok(ToolOutput {
content: "Found 3 results".into(),
artifact: Some(serde_json::json!([
{"title": "Result 1", "url": "https://example.com/1"},
{"title": "Result 2", "url": "https://example.com/2"},
{"title": "Result 3", "url": "https://example.com/3"},
])),
})
Switch Provider
All chat models implement the BaseChatModel trait, so switching providers requires only changing the constructor.
Using trait objects
use synwire_core::language_models::BaseChatModel;
use synwire_core::messages::Message;
async fn ask(
model: &dyn BaseChatModel,
question: &str,
) -> Result<String, synwire_core::error::SynwireError> {
let messages = vec![Message::human(question)];
let result = model.invoke(&messages, None).await?;
Ok(result.message.content().as_text())
}
Selecting at runtime
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_llm_openai::ChatOpenAI;
use synwire_llm_ollama::ChatOllama;
fn create_model(provider: &str) -> Result<Box<dyn BaseChatModel>, Box<dyn std::error::Error>> {
match provider {
"openai" => Ok(Box::new(
ChatOpenAI::builder()
.model("gpt-4o-mini")
.api_key_env("OPENAI_API_KEY")
.build()?
)),
"ollama" => Ok(Box::new(
ChatOllama::builder()
.model("llama3.2")
.build()?
)),
"fake" => Ok(Box::new(
FakeChatModel::new(vec!["Fake response".into()])
)),
_ => Err("Unknown provider".into()),
}
}
Feature flags for optional providers
Use Cargo feature flags to conditionally compile providers:
[dependencies]
synwire = "0.1"
[features]
default = []
openai = ["synwire/openai"]
ollama = ["synwire/ollama"]
Testing with FakeChatModel
Replace any real provider with FakeChatModel in tests:
#[cfg(test)]
mod tests {
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
#[tokio::test]
async fn test_my_agent() {
let model: Box<dyn BaseChatModel> = Box::new(
FakeChatModel::new(vec!["Test response".into()])
);
// Use model in your agent...
}
}
Add Checkpointing
Checkpointing persists graph state between supersteps, enabling pause/resume and state inspection.
In-memory checkpointing
For development and testing:
use synwire_checkpoint::memory::InMemoryCheckpointSaver;
let saver = InMemoryCheckpointSaver::new();
SQLite checkpointing
For persistent storage:
use synwire_checkpoint_sqlite::saver::SqliteSaver;
let saver = SqliteSaver::new("checkpoints.db")?;
BaseCheckpointSaver trait
All checkpoint implementations satisfy BaseCheckpointSaver:
use synwire_checkpoint::base::BaseCheckpointSaver;
async fn save_state(
saver: &dyn BaseCheckpointSaver,
thread_id: &str,
state: serde_json::Value,
) -> Result<(), Box<dyn std::error::Error>> {
// Save checkpoint...
Ok(())
}
Key-value store
For arbitrary key-value storage alongside checkpoints, use BaseStore:
use synwire_checkpoint::store::base::BaseStore;
Custom checkpoint backend
Implement BaseCheckpointSaver for your own storage backend. Use the conformance test suite to validate:
use synwire_checkpoint_conformance::run_conformance_tests;
// In a test:
run_conformance_tests(|| your_saver_factory()).await;
See also
- Checkpointing Tutorial — hands-on: in-memory → SQLite → fork
- Checkpointing Explanation —
BaseStore, serde protocol, and trade-offs - Pregel Execution Model — how checkpoints relate to supersteps
Custom Channel
Channels control how state is accumulated in graph execution. Synwire provides built-in channels, but you can implement your own.
Built-in channels
| Channel | Behaviour |
|---|---|
LastValue | Stores the most recent value (overwrites) |
Topic | Appends all values (accumulator) |
AnyValue | Accepts any single value |
BinaryOperator | Combines values with a custom function |
NamedBarrier | Synchronisation barrier |
Ephemeral | Value cleared after each read |
Implementing BaseChannel
use synwire_orchestrator::channels::traits::BaseChannel;
use synwire_orchestrator::error::GraphError;
struct MaxChannel {
key: String,
value: Option<serde_json::Value>,
}
impl MaxChannel {
fn new(key: impl Into<String>) -> Self {
Self { key: key.into(), value: None }
}
}
impl BaseChannel for MaxChannel {
fn key(&self) -> &str { &self.key }
fn update(&mut self, values: Vec<serde_json::Value>) -> Result<(), GraphError> {
for v in values {
match (&self.value, &v) {
(Some(current), _) if v.as_f64() > current.as_f64() => {
self.value = Some(v);
}
(None, _) => {
self.value = Some(v);
}
_ => {}
}
}
Ok(())
}
fn get(&self) -> Option<&serde_json::Value> { self.value.as_ref() }
fn checkpoint(&self) -> serde_json::Value {
self.value.clone().unwrap_or(serde_json::Value::Null)
}
fn restore_checkpoint(&mut self, value: serde_json::Value) {
self.value = Some(value);
}
fn consume(&mut self) -> Option<serde_json::Value> { self.value.take() }
fn is_available(&self) -> bool { self.value.is_some() }
}
Channel requirements
When implementing BaseChannel:
updatereceives a batch of values from a single superstepgetmust return the current accumulated valuecheckpoint/restore_checkpointenable state persistenceconsumetakes the value and resets the channel- Implement
Send + Sync(required by the trait bound)
Graph Interrupts
Graph interrupts allow you to pause execution at a specific node and resume later, useful for human-in-the-loop workflows.
How interrupts work
When a node returns GraphError::Interrupt, execution pauses. The graph state at that point can be checkpointed and later resumed by re-invoking with the saved state.
Implementing an interrupt
use synwire_orchestrator::error::GraphError;
graph.add_node("review", Box::new(|state| {
Box::pin(async move {
let needs_review = state["needs_review"]
.as_bool()
.unwrap_or(false);
if needs_review {
return Err(GraphError::Interrupt {
message: "Human review required".into(),
});
}
Ok(state)
})
}))?;
Handling interrupts
match compiled.invoke(state).await {
Ok(result) => {
// Normal completion
}
Err(GraphError::Interrupt { message }) => {
// Save state for later resumption
println!("Paused: {message}");
}
Err(e) => {
// Other error
}
}
Resume pattern
After human review modifies the state:
// Modify state based on human input
state["needs_review"] = serde_json::json!(false);
state["human_feedback"] = serde_json::json!("Approved");
// Re-invoke from the interrupted node
let result = compiled.invoke(state).await?;
Combining with checkpointing
For durable interrupts, checkpoint the state before pausing:
use synwire_checkpoint::memory::InMemoryCheckpointSaver;
let saver = InMemoryCheckpointSaver::new();
// Save state on interrupt, restore on resume
Custom Provider
Implement BaseChatModel to add support for a new LLM provider.
Implement the trait
use synwire_core::error::SynwireError;
use synwire_core::language_models::{BaseChatModel, ChatResult, ChatChunk};
use synwire_core::messages::Message;
use synwire_core::runnables::RunnableConfig;
use synwire_core::tools::ToolSchema;
use synwire_core::{BoxFuture, BoxStream};
pub struct MyProvider {
model_name: String,
api_key: String,
}
impl BaseChatModel for MyProvider {
fn invoke<'a>(
&'a self,
messages: &'a [Message],
config: Option<&'a RunnableConfig>,
) -> BoxFuture<'a, Result<ChatResult, SynwireError>> {
Box::pin(async move {
// Make API call to your provider...
let response_text = "Response from my provider";
Ok(ChatResult {
message: Message::ai(response_text),
generation_info: None,
cost: None,
})
})
}
fn stream<'a>(
&'a self,
messages: &'a [Message],
config: Option<&'a RunnableConfig>,
) -> BoxFuture<'a, Result<BoxStream<'a, Result<ChatChunk, SynwireError>>, SynwireError>> {
Box::pin(async move {
// Return a stream of ChatChunk values...
let chunks = vec![Ok(ChatChunk {
delta_content: Some("Response".into()),
delta_tool_calls: Vec::new(),
finish_reason: Some("stop".into()),
usage: None,
})];
Ok(Box::pin(futures_util::stream::iter(chunks))
as BoxStream<'_, Result<ChatChunk, SynwireError>>)
})
}
fn model_type(&self) -> &str {
"my-provider"
}
}
Builder pattern
Use the builder pattern for ergonomic construction:
pub struct MyProviderBuilder {
model: Option<String>,
api_key: Option<String>,
}
impl MyProviderBuilder {
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn api_key_env(mut self, env_var: &str) -> Self {
self.api_key = std::env::var(env_var).ok();
self
}
pub fn build(self) -> Result<MyProvider, SynwireError> {
Ok(MyProvider {
model_name: self.model.unwrap_or_else(|| "default".into()),
api_key: self.api_key.ok_or(SynwireError::Credential {
message: "API key required".into(),
})?,
})
}
}
Embeddings provider
Implement Embeddings for embedding support:
use synwire_core::embeddings::Embeddings;
use synwire_core::error::SynwireError;
use synwire_core::BoxFuture;
impl Embeddings for MyProvider {
fn embed_documents<'a>(
&'a self,
texts: &'a [String],
) -> BoxFuture<'a, Result<Vec<Vec<f32>>, SynwireError>> {
Box::pin(async move {
// Call embedding API...
Ok(vec![vec![0.0; 768]; texts.len()])
})
}
fn embed_query<'a>(
&'a self,
text: &'a str,
) -> BoxFuture<'a, Result<Vec<f32>, SynwireError>> {
Box::pin(async move {
Ok(vec![0.0; 768])
})
}
}
Testing your provider
Use FakeChatModel as a reference for expected behaviour, and write tests against the BaseChatModel trait:
#[tokio::test]
async fn test_invoke_returns_ai_message() {
let model = MyProvider::builder()
.model("test")
.build()
.unwrap();
let result = model.invoke(&[Message::human("Hi")], None).await.unwrap();
assert_eq!(result.message.message_type(), "ai");
}
Enable Tracing
Synwire supports OpenTelemetry-based tracing via the tracing feature flag on synwire-core.
Enable the feature
[dependencies]
synwire-core = { version = "0.1", features = ["tracing"] }
Setup tracing subscriber
use tracing_subscriber::prelude::*;
fn init_tracing() {
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.init();
}
Callbacks for custom observability
Implement CallbackHandler to receive events during execution:
use synwire_core::callbacks::CallbackHandler;
use synwire_core::BoxFuture;
struct MetricsCallback;
impl CallbackHandler for MetricsCallback {
fn on_llm_start<'a>(
&'a self,
model_type: &'a str,
messages: &'a [synwire_core::messages::Message],
) -> BoxFuture<'a, ()> {
Box::pin(async move {
// Record metrics, emit spans, etc.
})
}
fn on_llm_end<'a>(
&'a self,
response: &'a serde_json::Value,
) -> BoxFuture<'a, ()> {
Box::pin(async move {
// Record latency, token usage, etc.
})
}
}
Attaching callbacks to invocations
Pass callbacks via RunnableConfig:
use synwire_core::runnables::RunnableConfig;
let config = RunnableConfig {
callbacks: vec![Box::new(MetricsCallback)],
..Default::default()
};
let result = model.invoke(&messages, Some(&config)).await?;
Filtering callback events
Override the ignore_* methods to skip categories:
impl CallbackHandler for MetricsCallback {
fn ignore_tool(&self) -> bool { true } // Skip tool events
fn ignore_llm(&self) -> bool { false } // Keep LLM events
}
Credentials
Synwire provides a credential management system with secret redaction and multiple provider strategies.
Credential providers
| Provider | Source | Use case |
|---|---|---|
EnvCredentialProvider | Environment variables | Production deployments |
StaticCredentialProvider | Hardcoded values | Testing only |
Using environment variables
use synwire_core::credentials::EnvCredentialProvider;
use synwire_core::credentials::CredentialProvider;
let provider = EnvCredentialProvider::new("OPENAI_API_KEY");
let secret = provider.get_credential()?;
// secret is a SecretValue that redacts on Display/Debug
SecretValue
API keys are wrapped in SecretValue (from the secrecy crate) to prevent accidental logging:
use synwire_core::credentials::SecretValue;
let secret = SecretValue::new("sk-abc123".into());
// println!("{secret}") prints "[REDACTED]"
Static credentials for testing
use synwire_core::credentials::StaticCredentialProvider;
use synwire_core::credentials::CredentialProvider;
let provider = StaticCredentialProvider::new("test-key");
let secret = provider.get_credential()?;
Provider integration
Providers like ChatOpenAI accept credentials via their builders:
use synwire_llm_openai::ChatOpenAI;
// From environment variable
let model = ChatOpenAI::builder()
.model("gpt-4o-mini")
.api_key_env("OPENAI_API_KEY")
.build()?;
Custom credential provider
Implement CredentialProvider for custom sources (vaults, config files, etc.):
use synwire_core::credentials::CredentialProvider;
use synwire_core::credentials::SecretValue;
use synwire_core::error::SynwireError;
struct VaultCredentialProvider {
key_name: String,
}
impl CredentialProvider for VaultCredentialProvider {
fn get_credential(&self) -> Result<SecretValue, SynwireError> {
// Fetch from vault...
Ok(SecretValue::new("fetched-key".into()))
}
}
Retry and Fallback
Synwire provides built-in retry and fallback mechanisms for handling transient errors.
Retry configuration
Wrap any RunnableCore with retry logic:
use synwire_core::runnables::{RunnableRetry, RetryConfig};
use synwire_core::error::SynwireErrorKind;
let config = RetryConfig::new()
.max_retries(3)
.retry_on(vec![SynwireErrorKind::Model]); // Only retry model errors
let retryable = RunnableRetry::new(my_runnable, config);
Fallbacks
Chain multiple runnables as fallbacks. If the primary fails, the next is tried:
use synwire_core::runnables::with_fallbacks;
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
let primary = FakeChatModel::new(vec!["Primary response".into()]);
let fallback = FakeChatModel::new(vec!["Fallback response".into()]);
// with_fallbacks tries each in order
let resilient = with_fallbacks(vec![
Box::new(primary),
Box::new(fallback),
]);
Error kinds for matching
SynwireErrorKind categorises errors for retry/fallback decisions:
| Kind | When to retry |
|---|---|
Model | Rate limits, timeouts, transient failures |
Tool | Tool invocation failures |
Parse | Output parsing failures (consider with caution) |
Embedding | Embedding API failures |
Credential | Typically not retryable |
Serialization | Not retryable |
Combining retry and fallback
use synwire_core::runnables::{RunnableRetry, RetryConfig, with_fallbacks};
use synwire_core::error::SynwireErrorKind;
// Primary with retry
let primary_with_retry = RunnableRetry::new(
primary_model,
RetryConfig::new()
.max_retries(2)
.retry_on(vec![SynwireErrorKind::Model]),
);
// Fallback without retry
let resilient = with_fallbacks(vec![
Box::new(primary_with_retry),
Box::new(fallback_model),
]);
Callback on retry
Monitor retries via the CallbackHandler:
impl CallbackHandler for MyCallback {
fn on_retry<'a>(
&'a self,
attempt: u32,
error: &'a str,
) -> BoxFuture<'a, ()> {
Box::pin(async move {
eprintln!("Retry attempt {attempt}: {error}");
})
}
}
How to: Use the Virtual Filesystem (VFS)
Goal: Give an LLM agent filesystem-like access to heterogeneous data sources through a single interface that mirrors coreutils.
Quick start
Instantiate a VFS provider, call vfs_tools to get pre-built tools, and pass them to create_react_agent. The LLM can then ls, read, grep, tree, find, write, edit, etc.
#![allow(unused)] fn main() { use std::sync::Arc; use synwire_agent::vfs::local::LocalProvider; use synwire_core::vfs::{Vfs, OutputFormat, vfs_tools}; use synwire_orchestrator::prebuilt::create_react_agent; // 1. Create a VFS scoped to the project directory. let vfs: Arc<dyn Vfs> = Arc::new( LocalProvider::new("/home/user/project")? ); // 2. Get all tools the provider supports — automatically. let tools = vfs_tools(Arc::clone(&vfs), OutputFormat::Toon); // 3. Hand them to the react agent. let graph = create_react_agent(model, tools)?; let result = graph.invoke(initial_state).await?; }
That's it. vfs_tools inspects capabilities() and only includes tools the provider actually supports. The OutputFormat controls how structured results (directory listings, grep matches, etc.) are serialized before the LLM sees them.
The LLM sees these as callable tools:
Agent: "Let me explore the project."
→ ls { path: ".", recursive: false }
→ tree { path: "src", max_depth: 2 }
→ head { path: "src/main.rs", lines: 20 }
→ grep { pattern: "TODO", path: "src", file_type: "rust" }
→ edit { path: "src/main.rs", old: "// TODO", new: "// DONE" }
VFS providers
Choose the provider based on what data the LLM should access.
LocalProvider — real filesystem with path-traversal protection
All operations are scoped to root. Any path escaping root is rejected with VfsError::PathTraversal.
#![allow(unused)] fn main() { use synwire_agent::vfs::local::LocalProvider; let vfs: Arc<dyn Vfs> = Arc::new(LocalProvider::new("/home/user/project")?); let tools = vfs_tools(vfs, OutputFormat::Toon); }
MemoryProvider — ephemeral in-memory storage
No persistence. Ideal for agent scratchpads and test fixtures.
#![allow(unused)] fn main() { use synwire_core::vfs::MemoryProvider; let vfs: Arc<dyn Vfs> = Arc::new(MemoryProvider::new()); let tools = vfs_tools(vfs, OutputFormat::Toon); // The LLM can write and read files in memory during its run. // Everything is lost when the provider is dropped. }
CompositeProvider — mount multiple providers by path prefix
The LLM sees a unified filesystem. Routes operations to the provider whose prefix is the longest match.
#![allow(unused)] fn main() { use synwire_agent::vfs::composite::{CompositeProvider, Mount}; use synwire_agent::vfs::local::LocalProvider; use synwire_core::vfs::MemoryProvider; let vfs: Arc<dyn Vfs> = Arc::new(CompositeProvider::new(vec![ Mount { prefix: "/workspace".to_string(), backend: Box::new(LocalProvider::new("/home/user/project")?), }, Mount { prefix: "/scratch".to_string(), backend: Box::new(MemoryProvider::new()), }, ])); let tools = vfs_tools(vfs, OutputFormat::Toon); // /workspace/... → real files /scratch/... → ephemeral in-memory }
The LLM can call mount to discover what's available:
Agent: → mount {}
Result:
/workspace LocalProvider [ls, read, write, edit, grep, glob, ...]
/scratch MemoryProvider [ls, read, write, edit, grep, glob, ...]
Cross-boundary operations: cp and mv across mount boundaries work automatically — the composite reads from the source mount and writes to the destination mount. For mv, the source is deleted after the write succeeds.
Root-level operations: ls / shows mount prefixes as virtual directories. grep from root searches all mounts. pwd returns /.
StoreProvider — key-value persistence as a filesystem
Wraps any [BaseStore] implementation. Keys map to paths as /<namespace>/<key>.
#![allow(unused)] fn main() { use synwire_agent::vfs::store::{InMemoryStore, StoreProvider}; let vfs: Arc<dyn Vfs> = Arc::new(StoreProvider::new("agent1", InMemoryStore::new())); let tools = vfs_tools(vfs, OutputFormat::Json); }
Available tools
vfs_tools generates these tools (only those the provider supports, plus mount which is always available):
| Tool | Coreutil | Description |
|---|---|---|
mount | mount | Show mounted providers, their paths, and capabilities |
pwd | pwd | Print working directory |
cd | cd | Change working directory |
ls | ls | List directory contents (-a, -R, long format) |
tree | tree | Recursive directory tree (-L, -d) |
read | cat | Read entire file |
head | head | First N lines (-n) |
tail | tail | Last N lines (-n) |
stat | stat | File metadata |
wc | wc | Line/word/byte counts |
write | > | Write file (create/overwrite) |
append | >> | Append to file |
mkdir | mkdir | Create directory (-p) |
touch | touch | Create empty file / update timestamp |
edit | sed | Find and replace in file |
diff | diff | Compare two files (-U) |
rm | rm | Remove file/directory (-r, -f) |
cp | cp | Copy (-r, -n) |
mv | mv | Move / rename |
grep | grep | Search file contents (regex, -i, file type filter) |
glob | — | Find files by glob pattern |
find | find | Search by name, type, depth, size |
Capability checking
Providers declare what they support via VfsCapabilities bitflags. vfs_tools handles this automatically, but you can check manually:
#![allow(unused)] fn main() { use synwire_core::vfs::types::VfsCapabilities; if vfs.capabilities().contains(VfsCapabilities::FIND) { // provider supports find } }
Sandbox tools (non-VFS)
Command execution, process management, and archive handling live in the sandbox module — useful for coding agents but a different concern from filesystem abstraction.
#![allow(unused)] fn main() { use synwire_agent::sandbox::shell::Shell; use synwire_agent::sandbox::process::ProcessManager; use synwire_agent::sandbox::archive::ArchiveManager; }
See also
- How to: Tool Output Formats — JSON vs TOON, setting defaults per agent
- How to: Perform Advanced Search —
GrepOptionsreference
How to: Configure Tool Output Formats
Goal: Control how structured data from VFS operations and tools is serialized before being passed to the LLM.
Why it matters
When an LLM calls a tool like ls or find, the result is a Rust struct (Vec<DirEntry>, Vec<FindEntry>, etc.). Before the LLM sees it, the data must be serialized to text. The format you choose affects:
- Token usage — TOON can reduce tokens by 30–60 % for tabular data
- Model comprehension — JSON is universally understood; TOON is optimised for LLM consumption
- Debugging — pretty JSON is easiest to read in logs
The three formats
| Format | When to use | Token cost |
|---|---|---|
OutputFormat::Json | Debugging, human review | Highest |
OutputFormat::JsonCompact | Bandwidth-sensitive, small payloads | Medium |
OutputFormat::Toon | Production LLM agents, tabular data | Lowest |
Example — ls returning two files:
JSON (136 tokens):
[
{"name": "main.rs", "path": "/src/main.rs", "is_dir": false, "size": 1024},
{"name": "lib.rs", "path": "/src/lib.rs", "is_dir": false, "size": 512}
]
TOON (~40 tokens):
[2]{name,path,is_dir,size}:
main.rs,/src/main.rs,false,1024
lib.rs,/src/lib.rs,false,512
Setting the default on an agent
#![allow(unused)] fn main() { use synwire_core::vfs::OutputFormat; let agent = Agent::new() .name("coding-agent") .model("claude-sonnet-4-20250514") .tool_output_format(OutputFormat::Toon) .build(); }
All tools on this agent will use TOON by default when formatting their output.
Overriding per-tool
Individual tools can call format_output directly with a different format:
#![allow(unused)] fn main() { use synwire_core::vfs::{format_output, OutputFormat}; // Inside a tool implementation: let entries = vfs.ls(path, opts).await?; // Use JSON for this specific tool, regardless of the agent default. let content = format_output(&entries, OutputFormat::Json)?; }
Using format_output
format_output accepts any Serialize value and an OutputFormat:
#![allow(unused)] fn main() { use synwire_core::vfs::{format_output, OutputFormat}; // Serialize a Vec<DirEntry> to TOON. let text = format_output(&entries, OutputFormat::Toon)?; // Serialize a TreeEntry to compact JSON. let text = format_output(&tree, OutputFormat::JsonCompact)?; // Serialize a DiffResult to pretty JSON. let text = format_output(&diff, OutputFormat::Json)?; }
The toon feature is enabled by default. If you compile without it (default-features = false), OutputFormat::Toon falls back to pretty JSON.
See also
- TOON specification
- How to: Use the VFS — VFS providers and operations
How to: Configure the Middleware Stack
Goal: Build a MiddlewareStack, add the provided middleware components, and implement your own custom middleware.
Overview
MiddlewareStack runs its components in insertion order. Each component receives a MiddlewareInput (messages + context metadata) and returns a MiddlewareResult:
MiddlewareResult::Continue(MiddlewareInput)— pass (possibly modified) input to the next component.MiddlewareResult::Terminate(String)— stop the chain immediately and return the given message.
System prompt additions and tools from all components are collected in declaration order.
#![allow(unused)] fn main() { use synwire_core::agents::middleware::MiddlewareStack; let mut stack = MiddlewareStack::new(); // Push components in the order they should run. stack.push(component_a); stack.push(component_b); let result = stack.run(input).await?; let prompts = stack.system_prompt_additions(); let tools = stack.tools(); }
FilesystemMiddleware
Advertises filesystem tools (ls, read_file, write_file, edit_file, rm, pwd, cd) and adds a system prompt note about their availability. The runner wires the tools to the configured LocalProvider at start-up.
#![allow(unused)] fn main() { use synwire_agent::middleware::filesystem::FilesystemMiddleware; stack.push(FilesystemMiddleware); }
GitMiddleware
Advertises git tools and injects a system prompt describing available git operations.
#![allow(unused)] fn main() { use synwire_agent::middleware::git::GitMiddleware; stack.push(GitMiddleware); }
HttpMiddleware
Advertises HTTP request tools and injects a system prompt describing them.
#![allow(unused)] fn main() { use synwire_agent::middleware::http::HttpMiddleware; stack.push(HttpMiddleware); }
ProcessMiddleware
Advertises process management tools (list_processes, kill_process, spawn_background, execute, list_jobs).
#![allow(unused)] fn main() { use synwire_agent::middleware::process::ProcessMiddleware; stack.push(ProcessMiddleware); }
ArchiveMiddleware
Advertises archive tools (create_archive, extract_archive, list_archive).
#![allow(unused)] fn main() { use synwire_agent::middleware::archive::ArchiveMiddleware; stack.push(ArchiveMiddleware); }
PipelineMiddleware
Advertises the pipeline execution tool.
#![allow(unused)] fn main() { use synwire_agent::middleware::pipeline::PipelineMiddleware; stack.push(PipelineMiddleware); }
SummarisationMiddleware
Monitors message and token counts. When a threshold is exceeded, it sets summarisation_pending: true in the context metadata so the runner can trigger a summarisation step.
#![allow(unused)] fn main() { use synwire_agent::middleware::summarisation::{SummarisationMiddleware, SummarisationThresholds}; let thresholds = SummarisationThresholds { max_messages: Some(40), max_tokens: Some(60_000), max_context_utilisation: Some(0.75), }; stack.push(SummarisationMiddleware::new(thresholds)); // Use defaults (50 messages / 80,000 tokens / 80% utilisation). stack.push(SummarisationMiddleware::default()); }
PromptCachingMiddleware
Marks system prompts for provider-side caching. Push it before any middleware that adds large static system prompt additions.
#![allow(unused)] fn main() { use synwire_agent::middleware::prompt_caching::PromptCachingMiddleware; stack.push(PromptCachingMiddleware); }
PatchToolCallsMiddleware
Repairs malformed tool call JSON emitted by the model (e.g. missing required fields, incorrect types) before the agent attempts to execute them.
#![allow(unused)] fn main() { use synwire_agent::middleware::patch_tool_calls::PatchToolCallsMiddleware; stack.push(PatchToolCallsMiddleware); }
EnvironmentMiddleware
Injects environment variable tools (get_env, set_env, list_env) and adds a system prompt explaining how to use them.
#![allow(unused)] fn main() { use synwire_agent::middleware::environment::EnvironmentMiddleware; stack.push(EnvironmentMiddleware); }
Ordering
Middlewares execute in the order they were pushed. Recommended ordering for a typical agent:
PromptCachingMiddleware— mark static prompts before any additions accumulatePatchToolCallsMiddleware— fix model output before tools runFilesystemMiddleware/HttpMiddleware/GitMiddleware/ … — capability injectionSummarisationMiddleware— history compaction last so it sees the full message list
Implementing a custom middleware
Implement the Middleware trait. Only override the methods you need; process defaults to Continue and tools / system_prompt_additions default to empty.
#![allow(unused)] fn main() { use synwire_core::agents::middleware::{Middleware, MiddlewareInput, MiddlewareResult}; use synwire_core::agents::error::AgentError; use synwire_core::BoxFuture; struct AuditMiddleware { log_prefix: String, } impl Middleware for AuditMiddleware { fn name(&self) -> &str { "audit" } fn process( &self, input: MiddlewareInput, ) -> BoxFuture<'_, Result<MiddlewareResult, AgentError>> { let prefix = self.log_prefix.clone(); Box::pin(async move { tracing::info!("{prefix}: {} messages in context", input.messages.len()); // Return unchanged input to continue the chain. Ok(MiddlewareResult::Continue(input)) }) } fn system_prompt_additions(&self) -> Vec<String> { vec!["All operations are audited.".to_string()] } } }
To terminate the chain early (e.g. a rate-limit guard):
#![allow(unused)] fn main() { fn process( &self, _input: MiddlewareInput, ) -> BoxFuture<'_, Result<MiddlewareResult, AgentError>> { Box::pin(async move { if self.rate_limit_exceeded() { return Ok(MiddlewareResult::Terminate( "Rate limit exceeded — request blocked".to_string(), )); } Ok(MiddlewareResult::Continue(_input)) }) } }
See also
How to: Configure Approval Gates
Goal: Control which operations an agent is allowed to perform without user intervention by wiring approval callbacks.
Core types
An ApprovalRequest is passed to the gate whenever a risky operation is about to run:
#![allow(unused)] fn main() { pub struct ApprovalRequest { pub operation: String, // e.g. "write_file", "kill_process" pub description: String, // human-readable summary of what will happen pub risk: RiskLevel, pub timeout_secs: Option<u64>, pub context: serde_json::Value, // operation arguments or extra metadata } }
RiskLevel is an ordered enum (lower to higher):
| Variant | Typical operations |
|---|---|
None | Read-only (file read, ls, status) |
Low | Reversible writes (write file, edit) |
Medium | Deletions, overwrites |
High | System changes, process spawning |
Critical | Irreversible or destructive |
The callback returns an ApprovalDecision:
| Variant | Effect |
|---|---|
Allow | Proceed once |
Deny | Block this invocation |
AllowAlways | Proceed and cache approval for this operation name |
Abort | Stop the entire agent run |
AllowModified { modified_context } | Proceed with a rewritten context |
AutoApproveCallback
Approves everything. Use in tests or sandboxed environments where unrestricted execution is acceptable.
#![allow(unused)] fn main() { use synwire_core::vfs::approval::AutoApproveCallback; let gate = AutoApproveCallback; }
AutoDenyCallback
Denies everything. Use as a safe default when building ThresholdGate with an inner callback that should never actually fire.
#![allow(unused)] fn main() { use synwire_core::vfs::approval::AutoDenyCallback; let gate = AutoDenyCallback; }
ThresholdGate
Auto-approves any operation whose risk is at or below threshold. Operations above the threshold are delegated to an inner ApprovalCallback. Decisions of AllowAlways are cached per operation name so the inner callback is not called again.
#![allow(unused)] fn main() { use synwire_agent::vfs::threshold_gate::ThresholdGate; use synwire_core::vfs::approval::{AutoDenyCallback, RiskLevel}; // Auto-approve up to Medium risk; deny anything Higher automatically. let gate = ThresholdGate::new(RiskLevel::Medium, AutoDenyCallback); }
Use with an interactive callback for production:
#![allow(unused)] fn main() { use synwire_core::vfs::approval::{ApprovalCallback, ApprovalDecision, ApprovalRequest}; use synwire_core::BoxFuture; struct CliPrompt; impl ApprovalCallback for CliPrompt { fn request(&self, req: ApprovalRequest) -> BoxFuture<'_, ApprovalDecision> { Box::pin(async move { eprintln!("[approval] {} — {:?}", req.description, req.risk); eprint!("Allow? [y/N/always] "); // Read from stdin in a real implementation. ApprovalDecision::Allow }) } } let gate = ThresholdGate::new(RiskLevel::Low, CliPrompt); }
Implementing a custom ApprovalCallback
#![allow(unused)] fn main() { use synwire_core::vfs::approval::{ApprovalCallback, ApprovalDecision, ApprovalRequest}; use synwire_core::BoxFuture; struct PolicyCallback { allowed_operations: Vec<String>, } impl ApprovalCallback for PolicyCallback { fn request(&self, req: ApprovalRequest) -> BoxFuture<'_, ApprovalDecision> { Box::pin(async move { if self.allowed_operations.iter().any(|op| req.operation.starts_with(op)) { ApprovalDecision::Allow } else { ApprovalDecision::Deny } }) } } }
The ApprovalCallback trait requires Send + Sync. Use Arc<Mutex<_>> for any mutable internal state.
Interplay with PermissionMode
ThresholdGate enforces risk-based decisions independently of PermissionMode. For rule-based tool-name filtering, see How to: Configure Permission Modes. A typical setup layers both:
PermissionRulepatterns allow or deny by tool name before the operation is submitted.ThresholdGateintercepts anything that reaches execution and applies risk thresholds.
See also
Background: Context Engineering for AI Agents — how to design the context and controls around an AI agent, including approval mechanisms.
How to: Manage Agent Sessions
Goal: Create, persist, resume, fork, rewind, tag, and delete agent sessions using SessionManager.
Core types
#![allow(unused)] fn main() { pub struct SessionMetadata { pub id: String, // UUID pub name: Option<String>, pub tags: Vec<String>, pub agent_name: String, pub created_at: i64, // Unix milliseconds pub updated_at: i64, // Unix milliseconds pub turn_count: u32, pub total_tokens: u64, } pub struct Session { pub metadata: SessionMetadata, pub messages: Vec<serde_json::Value>, // conversation history pub state: serde_json::Value, // arbitrary agent state } }
InMemorySessionManager
The built-in implementation. All data is lost when the process exits. Use it for tests or ephemeral agents; use the checkpoint-backed implementation for persistence.
#![allow(unused)] fn main() { use synwire_agent::session::manager::InMemorySessionManager; use synwire_core::agents::session::{Session, SessionManager, SessionMetadata}; let mgr = InMemorySessionManager::new(); }
Save and resume
#![allow(unused)] fn main() { use serde_json::json; // Construct a new session. let session = Session { metadata: SessionMetadata { id: uuid::Uuid::new_v4().to_string(), name: Some("my-task".to_string()), tags: vec!["production".to_string()], agent_name: "code-agent".to_string(), created_at: 0, updated_at: 0, turn_count: 0, total_tokens: 0, }, messages: Vec::new(), state: json!({"cwd": "/home/user"}), }; mgr.save(&session).await?; // Later — in the same or a new call — resume by ID. let loaded = mgr.resume(&session.metadata.id).await?; }
save sets updated_at to the current time. Calling save on an existing ID updates it in place.
List sessions
Returns all sessions ordered by updated_at descending (most recently active first).
#![allow(unused)] fn main() { let sessions: Vec<SessionMetadata> = mgr.list().await?; for s in &sessions { println!("{} {:?} turns={}", s.id, s.name, s.turn_count); } }
Fork a session
Creates an independent copy with a new UUID. The fork shares history up to the fork point but diverges independently afterwards.
#![allow(unused)] fn main() { let fork_meta = mgr.fork(&session.metadata.id, Some("experiment-branch".to_string())).await?; assert_ne!(fork_meta.id, session.metadata.id); }
Pass None as the name to auto-generate a name of the form "<original name> (fork)".
Rewind to a previous turn
Truncates the message list to turn_index entries (zero-based). Useful for re-running from a specific point without forking.
#![allow(unused)] fn main() { // Keep only the first 3 messages. let rewound = mgr.rewind(&session.metadata.id, 3).await?; assert_eq!(rewound.messages.len(), 3); }
Tag a session
Adds one or more tags without duplicates. Existing tags are preserved.
#![allow(unused)] fn main() { mgr.tag(&session.metadata.id, vec!["reviewed".to_string(), "archived".to_string()]).await?; }
Rename a session
#![allow(unused)] fn main() { mgr.rename(&session.metadata.id, "final-delivery".to_string()).await?; }
Delete a session
#![allow(unused)] fn main() { mgr.delete(&session.metadata.id).await?; // Subsequent resume returns AgentError::Session. }
Implementing SessionManager
To persist sessions to a database or remote store, implement the SessionManager trait. All methods return BoxFuture so async storage drivers are supported.
#![allow(unused)] fn main() { use synwire_core::agents::session::{Session, SessionManager, SessionMetadata}; use synwire_core::agents::error::AgentError; use synwire_core::BoxFuture; struct RedisSessionManager { /* ... */ } impl SessionManager for RedisSessionManager { fn list(&self) -> BoxFuture<'_, Result<Vec<SessionMetadata>, AgentError>> { Box::pin(async move { /* query Redis ZREVRANGE */ todo!() }) } fn resume(&self, session_id: &str) -> BoxFuture<'_, Result<Session, AgentError>> { let id = session_id.to_string(); Box::pin(async move { /* GET id */ todo!() }) } fn save(&self, session: &Session) -> BoxFuture<'_, Result<(), AgentError>> { let session = session.clone(); Box::pin(async move { /* SET id */ todo!() }) } fn delete(&self, session_id: &str) -> BoxFuture<'_, Result<(), AgentError>> { let id = session_id.to_string(); Box::pin(async move { /* DEL id */ todo!() }) } fn fork(&self, session_id: &str, new_name: Option<String>) -> BoxFuture<'_, Result<SessionMetadata, AgentError>> { todo!() } fn rewind(&self, session_id: &str, turn_index: u32) -> BoxFuture<'_, Result<Session, AgentError>> { todo!() } fn tag(&self, session_id: &str, tags: Vec<String>) -> BoxFuture<'_, Result<(), AgentError>> { todo!() } fn rename(&self, session_id: &str, new_name: String) -> BoxFuture<'_, Result<(), AgentError>> { todo!() } } }
See also
How to: Integrate MCP Servers
Using the standalone synwire-mcp-server
The easiest way to expose Synwire tools to an MCP host (Claude Code, GitHub Copilot, Cursor) is the standalone synwire-mcp-server binary.
Install
cargo install synwire-mcp-server
Configure Claude Code
Add to .claude/mcp.json in your project or home directory:
{
"synwire": {
"command": "synwire-mcp-server",
"args": ["--project", "."]
}
}
With LSP support:
{
"synwire": {
"command": "synwire-mcp-server",
"args": ["--project", ".", "--lsp", "rust-analyzer"]
}
}
Config file (alternative to CLI flags)
Create synwire.toml in your project root:
project = "."
product_name = "myapp"
lsp = "rust-analyzer"
embedding_model = "bge-small-en-v1.5"
log_level = "info"
{
"synwire": {
"command": "synwire-mcp-server",
"args": ["--config", "synwire.toml"]
}
}
CLI flags override config file values. See the synwire-mcp-server explanation for all available flags.
Goal: Connect to Model Context Protocol servers via stdio, HTTP, or in-process transports, and manage multiple servers through McpLifecycleManager.
Core trait: McpTransport
All transports implement:
#![allow(unused)] fn main() { pub trait McpTransport: Send + Sync { fn connect(&self) -> BoxFuture<'_, Result<(), AgentError>>; fn reconnect(&self) -> BoxFuture<'_, Result<(), AgentError>>; fn disconnect(&self) -> BoxFuture<'_, Result<(), AgentError>>; fn status(&self) -> BoxFuture<'_, McpServerStatus>; fn list_tools(&self) -> BoxFuture<'_, Result<Vec<McpToolDescriptor>, AgentError>>; fn call_tool(&self, tool_name: &str, arguments: Value) -> BoxFuture<'_, Result<Value, AgentError>>; } }
McpServerStatus carries the server name, McpConnectionState, call success/failure counters, and an enabled flag.
McpConnectionState progression: Disconnected → Connecting → Connected. After a drop: Connected → Reconnecting → Connected (or back to Disconnected on failure). Shutdown is terminal.
StdioMcpTransport
Manages a subprocess and exchanges newline-delimited JSON-RPC over its stdin/stdout.
#![allow(unused)] fn main() { use synwire_agent::mcp::stdio::StdioMcpTransport; use synwire_core::mcp::traits::McpTransport; use std::collections::HashMap; let transport = StdioMcpTransport::new( "my-mcp-server", "npx", vec!["-y".to_string(), "@modelcontextprotocol/server-filesystem".to_string()], HashMap::from([ ("MCP_WORKSPACE".to_string(), "/home/user/project".to_string()), ]), ); transport.connect().await?; let tools = transport.list_tools().await?; for t in &tools { println!("{}: {}", t.name, t.description); } let result = transport.call_tool("read_file", serde_json::json!({ "path": "/home/user/project/README.md" })).await?; transport.disconnect().await?; }
Reconnecting kills the existing subprocess and spawns a new one:
#![allow(unused)] fn main() { transport.reconnect().await?; }
HttpMcpTransport
Connects to an HTTP-based MCP server. The connect call performs a health check by issuing POST /tools/list.
#![allow(unused)] fn main() { use synwire_agent::mcp::http::HttpMcpTransport; let transport = HttpMcpTransport::new( "remote-mcp", "https://mcp.example.com", Some("Bearer sk-...".to_string()), Some(30), // timeout_secs; None defaults to 30 ); transport.connect().await?; let tools = transport.list_tools().await?; let result = transport.call_tool("search", serde_json::json!({"query": "rust async"})).await?; }
InProcessMcpTransport
Registers native Tool implementations and exposes them via the McpTransport interface without any subprocess or network hop. Useful for built-in toolsets that benefit from the MCP lifecycle API.
#![allow(unused)] fn main() { use synwire_agent::mcp::in_process::InProcessMcpTransport; use std::sync::Arc; let mut transport = InProcessMcpTransport::new("builtin-tools"); // Register any type that implements `synwire_core::tools::Tool`. transport.register(Arc::new(MyCustomTool)).await; transport.connect().await?; // transitions to Connected immediately let tools = transport.list_tools().await?; let result = transport.call_tool("my_tool", serde_json::json!({"input": "value"})).await?; }
reconnect on an in-process transport is equivalent to connect — it simply marks the state as Connected.
McpLifecycleManager
Manages multiple transports: connects all at start, monitors health, and reconnects dropped servers in the background.
#![allow(unused)] fn main() { use synwire_agent::mcp::lifecycle::McpLifecycleManager; use std::sync::Arc; use std::time::Duration; let manager = Arc::new(McpLifecycleManager::new()); // Register servers with individual reconnect delays. manager.register("filesystem", StdioMcpTransport::new(/* ... */), Duration::from_secs(5)).await; manager.register("web-search", HttpMcpTransport::new(/* ... */), Duration::from_secs(10)).await; // Connect all enabled servers. manager.start_all().await?; // Spawn background health monitor (polls every 30 s, reconnects as needed). Arc::clone(&manager).spawn_health_monitor(Duration::from_secs(30)); // Call a tool on a named server — auto-reconnects if needed. let result = manager.call_tool("filesystem", "read_file", serde_json::json!({ "path": "/project/src/main.rs" })).await?; // Inspect status of all servers. let statuses = manager.all_status().await; for s in &statuses { println!("{}: {:?} ok={} fail={}", s.name, s.state, s.calls_succeeded, s.calls_failed); } // Disable a server at runtime. manager.disable("web-search").await?; // Re-enable and reconnect. manager.enable("web-search").await?; // Shutdown. manager.stop_all().await?; }
Calling call_tool on a disabled server returns AgentError::Backend immediately without attempting to reconnect.
See also
How to: Configure Three-Tier Signal Routing
Goal: Define signal routes across strategy, agent, and plugin tiers so incoming signals produce the correct agent actions.
Core types
#![allow(unused)] fn main() { pub struct Signal { pub kind: SignalKind, pub payload: serde_json::Value, } pub enum SignalKind { Stop, UserMessage, ToolResult, Timer, Custom(String), } pub enum Action { Continue, GracefulStop, ForceStop, Transition(String), // FSM state name Custom(String), } }
SignalRoute
A route binds a SignalKind to an Action, with an optional predicate for finer-grained matching and a numeric priority for tie-breaking within the same tier.
#![allow(unused)] fn main() { use synwire_core::agents::signal::{Action, Signal, SignalKind, SignalRoute}; // Match all Stop signals. let route = SignalRoute::new(SignalKind::Stop, Action::GracefulStop, 10); // Match only non-empty user messages. fn non_empty(s: &Signal) -> bool { s.payload.as_str().is_some_and(|v| !v.is_empty()) } let guarded = SignalRoute::with_predicate( SignalKind::UserMessage, non_empty, Action::Continue, 20, // higher priority — wins over routes with lower values ); }
Predicates must be plain function pointers (fn(&Signal) -> bool) so SignalRoute remains Clone + Send + Sync.
ComposedRouter
Combines three tiers of routes. Within each tier the highest-priority matching route wins. The strategy tier always beats the agent tier, which always beats the plugin tier — regardless of priority values within tiers.
#![allow(unused)] fn main() { use synwire_core::agents::signal::{Action, ComposedRouter, Signal, SignalKind, SignalRoute, SignalRouter}; let strategy_routes = vec![ // Unconditionally force-stop on Stop signal from strategy level. SignalRoute::new(SignalKind::Stop, Action::ForceStop, 0), ]; let agent_routes = vec![ // Agent prefers graceful stop with higher intra-tier priority. SignalRoute::new(SignalKind::Stop, Action::GracefulStop, 100), SignalRoute::new(SignalKind::UserMessage, Action::Continue, 0), SignalRoute::new(SignalKind::Timer, Action::Transition("tick".to_string()), 0), ]; let plugin_routes = vec![ SignalRoute::new(SignalKind::Custom("metrics".to_string()), Action::Continue, 0), ]; let router = ComposedRouter::new(strategy_routes, agent_routes, plugin_routes); }
Routing a signal:
#![allow(unused)] fn main() { use serde_json::json; let signal = Signal::new(SignalKind::Stop, json!(null)); match router.route(&signal) { Some(Action::ForceStop) => { /* strategy tier matched */ } Some(Action::GracefulStop) => { /* agent tier matched */ } Some(action) => { /* other action */ } None => { /* no route — apply default behaviour */ } } }
Inspect all registered routes across tiers:
#![allow(unused)] fn main() { let all_routes = router.routes(); println!("{} routes registered", all_routes.len()); }
Custom routers
Implement SignalRouter to replace the composed approach entirely:
#![allow(unused)] fn main() { use synwire_core::agents::signal::{Action, Signal, SignalRouter, SignalRoute}; struct AlwaysContinueRouter; impl SignalRouter for AlwaysContinueRouter { fn route(&self, _signal: &Signal) -> Option<Action> { Some(Action::Continue) } fn routes(&self) -> Vec<SignalRoute> { Vec::new() } } }
Priority semantics summary
| Situation | Winner |
|---|---|
| Strategy route vs. agent route (same kind) | Strategy, regardless of priority values |
| Agent route vs. plugin route (same kind) | Agent, regardless of priority values |
| Two routes in the same tier, same kind | Highest priority value |
| Predicate fails on higher-priority route | Next matching route in same tier |
| No route matches in any tier | None — caller decides default |
See also
How to: Configure Permission Modes
Goal: Apply a PermissionMode preset and define PermissionRule patterns to control which tool operations the agent may perform.
PermissionMode presets
PermissionMode is Copy and Default (defaults to Default). It expresses a broad policy for the agent session:
#![allow(unused)] fn main() { use synwire_core::agents::permission::PermissionMode; let mode = PermissionMode::Default; // prompt for dangerous ops let mode = PermissionMode::AcceptEdits; // auto-approve file modifications let mode = PermissionMode::PlanOnly; // read-only, no mutations let mode = PermissionMode::BypassAll; // auto-approve everything (caution) let mode = PermissionMode::DenyUnauthorized; // deny unless a pre-approved rule matches }
| Mode | Behaviour |
|---|---|
Default | Allow safe operations; prompt for dangerous ones |
AcceptEdits | Auto-approve write/edit/rm; prompt for higher-risk ops |
PlanOnly | Block all mutations; safe for dry-run or planning phases |
BypassAll | Approve all operations without prompting |
DenyUnauthorized | Deny any operation that has no matching Allow rule |
PermissionRule
Rules match tool names using glob patterns and assign a PermissionBehavior:
#![allow(unused)] fn main() { use synwire_core::agents::permission::{PermissionBehavior, PermissionRule}; let rules = vec![ // Allow all file reads without prompting. PermissionRule { tool_pattern: "read_file".to_string(), behavior: PermissionBehavior::Allow, }, // Always ask before writing. PermissionRule { tool_pattern: "write_file".to_string(), behavior: PermissionBehavior::Ask, }, // Block process spawning entirely. PermissionRule { tool_pattern: "spawn_background".to_string(), behavior: PermissionBehavior::Deny, }, // Wildcard: allow all git operations. PermissionRule { tool_pattern: "git_*".to_string(), behavior: PermissionBehavior::Allow, }, ]; }
PermissionBehavior values:
| Variant | Meaning |
|---|---|
Allow | Permit without prompting |
Deny | Block immediately |
Ask | Delegate to the approval callback |
How rules interact with approval callbacks
Rules are evaluated before the operation reaches an approval gate:
- The runner matches the tool name against all
PermissionRulepatterns in order. - On
Deny— the operation is blocked immediately; the approval callback is not called. - On
Allow— the operation proceeds; the approval callback is not called. - On
Ask(or no matching rule) — the operation is forwarded to theApprovalCallback(e.g.ThresholdGate).
Under DenyUnauthorized mode, any tool with no matching rule and no Ask result is blocked as if Deny were returned.
Under BypassAll mode, Ask results are treated as Allow — the approval callback is still invoked but the answer is ignored.
Serialisation
Both types derive Serialize and Deserialize, so rules can be loaded from a config file:
#![allow(unused)] fn main() { use synwire_core::agents::permission::{PermissionMode, PermissionRule}; let rules: Vec<PermissionRule> = serde_json::from_str(r#"[ {"tool_pattern": "read_file", "behavior": "Allow"}, {"tool_pattern": "write_file", "behavior": "Ask"}, {"tool_pattern": "rm", "behavior": "Deny"} ]"#)?; let mode: PermissionMode = serde_json::from_str(r#""AcceptEdits""#)?; }
See also
Process Sandboxing
Synwire isolates agent-spawned processes using Linux cgroup v2 for resource accounting and OCI container runtimes for namespace isolation. Two runtimes are supported:
| Runtime | Binary | Isolation model |
|---|---|---|
| runc | runc | Linux namespaces + seccomp — processes share the host kernel |
| gVisor | runsc | User-space kernel — syscalls are intercepted by a Go-based sentry, providing a much stronger isolation boundary |
Prerequisites
| Requirement | Minimum version | Purpose |
|---|---|---|
| Linux kernel | 4.15 | cgroup v2 unified hierarchy |
| systemd | 239 | User cgroup delegation |
| runc | 1.1+ | Namespace isolation (standard) |
| runsc (gVisor) | latest | Namespace isolation (hardened) — optional |
WSL2 note: cgroup v2 is available but user delegation may not be enabled by default — see the WSL2 section below.
Architecture
Synwire uses battle-tested OCI runtimes for namespace isolation instead of a custom init binary. These runtimes handle all namespace, mount, seccomp, and capability setup including hardening against known CVEs.
For each container, synwire:
- Creates a temporary OCI bundle directory
- Generates an OCI runtime spec (
config.json) from theSandboxConfig - Generates
/etc/passwdand/etc/groupso the current user is resolvable inside the container (whoami,id,ls -laall work) - Runs
runc run --bundle <dir> <id>(orrunsc --rootless run ...for gVisor) - Cleans up the bundle when the container exits
Runtime selection
use synwire_sandbox::platform::linux::namespace::NamespaceContainer;
// Standard runc — finds "runc" on $PATH
let container = NamespaceContainer::new()?;
// gVisor — finds "runsc" on $PATH
let container = NamespaceContainer::with_gvisor()?;
// Explicit selection
use synwire_sandbox::platform::linux::namespace::OciRuntime;
let container = NamespaceContainer::with_runtime(OciRuntime::Gvisor)?;
User namespace and UID mapping
Rootless user namespaces only allow a single UID/GID mapping entry (without
the setuid newuidmap helper). runc's init process requires UID 0, so synwire
maps containerID 0 → hostID <real-uid>. The process runs as UID 0 inside
the namespace, which the kernel translates to the real UID for all host-side
operations (file ownership in bind mounts, etc.).
A generated /etc/passwd maps UID 0 to the real username — the same trick
Podman uses for rootless containers. Inside the container:
$ whoami
naadir
$ id
uid=0(naadir) gid=0(naadir) groups=0(naadir)
$ touch /tmp/test && ls -la /tmp/test
-rw-r--r-- 1 naadir naadir 0 Mar 16 12:00 /tmp/test
Capabilities
The default capability set is intentionally minimal — much tighter than Docker's default:
| Capability | Purpose |
|---|---|
CAP_KILL | Signal child processes spawned by the agent |
CAP_NET_BIND_SERVICE | Bind ports <1024 if networking is enabled |
CAP_SETPCAP | Drop further capabilities (supports no_new_privileges) |
Dropped from Docker's default: CHOWN, DAC_OVERRIDE, FSETID, FOWNER,
SETGID, SETUID, SYS_CHROOT, AUDIT_WRITE. Use capabilities_add in
SandboxConfig to grant additional capabilities if a specific use case
requires them.
gVisor differences
When using OciRuntime::Gvisor, synwire adjusts its behaviour automatically:
- No user namespace in the OCI spec — runsc manages its own user
namespace via
--rootless - No UID/GID mappings — handled internally by runsc
- No seccomp profile — gVisor's sentry kernel provides stronger syscall filtering than BPF-based seccomp; applying both causes compatibility issues
- Platform auto-detected — probes systrap first, falls back to ptrace if needed (see Platform auto-detection below)
cgroup hierarchy
Agent cgroups are placed as siblings of the synwire process's own cgroup:
user@1000.service/
app.slice/
code.scope/ ← synwire process lives here
synwire/
agents/<uuid>/ ← agent cgroups go here
When the CgroupV2Manager is dropped (agent terminated), it writes 1 to
cgroup.kill (Linux 5.14+) or falls back to SIGKILL per PID to ensure
immediate cleanup.
Installing runc
# Debian/Ubuntu
sudo apt install runc
# Fedora
sudo dnf install runc
# Arch
sudo pacman -S runc
Verify:
runc --version
Installing gVisor (optional)
gVisor provides a stronger isolation boundary by running a user-space kernel
that intercepts syscalls. Install runsc:
# Download and install runsc
ARCH=$(uname -m)
URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}"
wget "${URL}/runsc" "${URL}/runsc.sha512"
sha512sum -c runsc.sha512
chmod a+rx runsc
sudo mv runsc /usr/local/bin/
Or via a package manager (where available):
# Arch (AUR)
yay -S gvisor-bin
Verify:
runsc --version
Platform auto-detection
gVisor supports two syscall interception platforms:
| Platform | Mechanism | Performance | Compatibility |
|---|---|---|---|
| systrap (default) | Patches syscall instruction sites | Fastest | Requires CAP_SYS_PTRACE |
| ptrace | PTRACE_SYSEMU / CLONE_PTRACE | Slower | Universal |
On first use, synwire automatically probes whether systrap works by running
a trivial container (/bin/true). If systrap succeeds, it is used for all
future containers in the process. If it fails, synwire falls back to ptrace,
logs a warning, and caches the decision for the lifetime of the process —
no repeated probes.
Why systrap may fail: In rootless mode with --network=host, gVisor has
a bug where CAP_SYS_PTRACE is not included in the sandbox's ambient
capabilities. ConfigureCmdForRootless() in runsc/sandbox/sandbox.go
overwrites AmbientCaps without CAP_SYS_PTRACE (line 1059), and the
systrap capability check that would add it back is in the else branch
that only runs when host networking is not used (line 1143). This causes
systrap's PTRACE_ATTACH on stub threads to fail with EPERM. The ptrace
platform uses CLONE_PTRACE from the child instead, avoiding the issue.
When the gVisor bug is fixed upstream, the probe will succeed automatically and synwire will use systrap — no code change needed.
Enabling cgroup v2 delegation
Verify cgroup v2 is mounted
# Should show cgroup2 filesystem
mount | grep cgroup2
# Should list available controllers
cat /sys/fs/cgroup/cgroup.controllers
If /sys/fs/cgroup/cgroup.controllers does not exist, add
systemd.unified_cgroup_hierarchy=1 to your kernel command line and reboot.
Verify user delegation
# Show your process's cgroup
cat /proc/self/cgroup
# Output: 0::/user.slice/user-1000.slice/user@1000.service/app.slice/...
# Check the parent is writable
CGROUP_PATH=$(sed -n 's|0::|/sys/fs/cgroup|p' /proc/self/cgroup)
PARENT=$(dirname "$CGROUP_PATH")
ls -la "$PARENT"/cgroup.subtree_control
If the parent cgroup is not writable, ensure systemd user sessions are enabled:
systemctl --user status
# If "Failed to connect to bus":
loginctl enable-linger $USER
Enable controller delegation
If controllers (cpu, memory, pids) are not available in the user cgroup:
sudo mkdir -p /etc/systemd/system/user@.service.d
sudo tee /etc/systemd/system/user@.service.d/delegate.conf <<'EOF'
[Service]
Delegate=cpu cpuset io memory pids
EOF
sudo systemctl daemon-reload
# Log out and back in, or:
sudo systemctl restart user@$(id -u).service
Verify:
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.subtree_control
# Should show: cpu io memory pids
WSL2
WSL2 runs a custom init by default. Add to /etc/wsl.conf:
[boot]
systemd=true
Then restart WSL (wsl --shutdown from PowerShell).
Isolation levels
| Level | Mechanism | Requires |
|---|---|---|
CgroupTracking | cgroup v2 accounting only | user delegation |
Namespace | OCI container via runc (PID/mount/UTS/IPC/net namespaces) | runc + user namespaces |
Gvisor | OCI container via runsc (user-space kernel sandbox) | runsc + user namespaces |
macOS sandboxing
macOS lacks Linux namespaces and cgroup v2, so synwire uses platform-native mechanisms: Apple's Seatbelt sandbox for light isolation, and OCI container runtimes (Docker Desktop, Podman, or Colima) for strong isolation.
Seatbelt (light isolation)
Seatbelt uses Apple's sandbox-exec tool with Sandbox Profile Language (SBPL)
profiles. Synwire generates an SBPL profile from the SandboxConfig at
runtime, applying a deny-by-default model — all operations are denied
unless explicitly allowed.
Deprecation note: Apple has deprecated
sandbox-execand the public SBPL interface. It remains functional on current macOS versions and is widely used by build systems (Nix, Bazel). Synwire will migrate to a replacement if Apple provides one.
How profiles are generated
Synwire translates SandboxConfig fields into SBPL rules:
SandboxConfig field | SBPL effect |
|---|---|
network: true | (allow network*) |
network: false | Network operations remain denied |
filesystem.read_paths | (allow file-read* (subpath "...")) per path |
filesystem.write_paths | (allow file-write* (subpath "...")) per path |
filesystem.deny_paths | (deny file-read* file-write* (subpath "...")) — evaluated first |
SecurityPreset levels
| Preset | Filesystem | Network | Subprocesses |
|---|---|---|---|
Baseline | Read home, read/write workdir and tmpdir | Allowed | Allowed |
Privileged | Read/write home | Allowed | Allowed |
Restricted | Read/write workdir only | Denied | Denied |
Example SBPL profile
A Restricted preset with a workdir of /tmp/agent-work produces:
(version 1)
(deny default)
;; Allow basic process execution
(allow process-exec)
(allow process-fork)
(allow sysctl-read)
(allow mach-lookup)
;; Filesystem: workdir read/write
(allow file-read* file-write*
(subpath "/tmp/agent-work"))
;; Filesystem: system libraries (read-only)
(allow file-read*
(subpath "/usr/lib")
(subpath "/usr/share")
(subpath "/System")
(subpath "/Library/Frameworks")
(subpath "/private/var/db/dyld"))
;; Network: denied (restricted preset)
;; Subprocesses: denied (restricted preset)
(deny process-fork (with send-signal SIGKILL))
Usage
use synwire_sandbox::platform::macos::seatbelt::SeatbeltContainer;
let container = SeatbeltContainer::new(config)?;
container.run(command).await?;
Container runtime (strong isolation)
For stronger isolation on macOS, synwire uses a container runtime that runs
Linux in a lightweight VM. Synwire auto-detects the available runtime via
detect_container_runtime(), using a four-tier priority order:
Apple Container > Docker Desktop > Podman > Colima
use synwire_sandbox::platform::macos::container::detect_container_runtime;
let runtime = detect_container_runtime().await?;
// Returns ContainerRuntime::AppleContainer, ContainerRuntime::DockerDesktop,
// ContainerRuntime::Podman, or ContainerRuntime::Colima
Apple Container (preferred)
Apple Container is Apple's first-party tool for running Linux containers as lightweight VMs using macOS Virtualization.framework. It is the preferred strong-isolation runtime when available.
Requirements: macOS 26+ (Tahoe), Apple Silicon.
Installing Apple Container
# Via Homebrew
brew install apple/container/container
# Or download from GitHub releases
# https://github.com/apple/container/releases
Verify:
container --version
Note: Apple Container is preferred over all other runtimes when available because it is a first-party Apple tool with tighter system integration and lower overhead. If your Mac does not meet the requirements (macOS 26+ and Apple Silicon), synwire falls back to Docker Desktop, then Podman, then Colima.
Docker Desktop (widely installed)
Docker Desktop has the largest install base of any container runtime on macOS, making it the second-priority option. Although synwire does not use Docker on Linux (see the sandbox methodology for details on the daemon model concerns), the macOS situation is different: every macOS container runtime already runs a Linux VM, so the daemon-in-a-VM architecture does not add an extra layer of indirection.
Synwire checks for Docker Desktop by running docker version (not
docker --version). The --version flag only checks the CLI binary is
installed; docker version queries the daemon and fails if the Docker Desktop
VM is not running. This avoids false positives where the CLI is present but
the backend is stopped.
Docker Desktop, Podman, and Colima share identical docker run / podman run
CLI flag semantics, so synwire translates SandboxConfig into the same set
of flags for all three.
Installing Docker Desktop
Download and install from docker.com. Launch Docker Desktop and wait for the engine to start.
Verify:
docker version
Podman (fallback)
Podman runs a lightweight Linux VM (podman machine) and manages OCI
containers inside it. It is the fallback runtime when neither Apple Container
nor Docker Desktop is available. Synwire invokes Podman with the following
flags:
| Flag | Purpose |
|---|---|
--volume <host>:<container> | Bind-mount working directory |
--network none | Disable networking (when SandboxConfig denies it) |
--memory <limit> | Memory cap from ResourceLimits |
--cpus <count> | CPU cap from ResourceLimits |
--user <uid>:<gid> | Run as non-root inside the container |
--security-opt no-new-privileges | Prevent privilege escalation |
These same flags apply to Docker Desktop and Colima, which share identical CLI semantics.
Installing Podman
brew install podman
podman machine init
podman machine start
Verify:
podman info
Colima (last resort)
Colima wraps Lima to provide a Docker-compatible environment with minimal
configuration. Unlike bare Lima, Colima exposes a Docker socket so that the
standard docker run CLI works transparently. Synwire detects Colima by
running colima status to check that the Colima VM is running, then delegates
to docker run for container execution.
Colima is used only when Apple Container, Docker Desktop, and Podman are all unavailable.
Installing Colima
brew install colima
colima start
Verify:
colima status
docker version # Should succeed via Colima's Docker socket
Isolation levels (macOS)
| Level | Mechanism | Requires |
|---|---|---|
Seatbelt | sandbox-exec SBPL profiles | macOS (built-in) |
Container (Apple Container) | Lightweight Linux VM via Virtualization.framework | macOS 26+, Apple Silicon, container on $PATH |
Container (Docker Desktop) | OCI container in Docker Desktop VM | Docker Desktop running (docker version succeeds) |
Container (Podman) | OCI container in Podman Machine VM | podman on $PATH |
Container (Colima) | OCI container via Colima VM + Docker socket | colima on $PATH, Colima VM running |
Check user namespace support
# Should return 1
cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null || \
sysctl kernel.unprivileged_userns_clone
# If 0:
sudo sysctl -w kernel.unprivileged_userns_clone=1
echo 'kernel.unprivileged_userns_clone=1' | sudo tee /etc/sysctl.d/99-userns.conf
Running the tests
# Unprivileged tests (always work)
cargo test -p synwire-sandbox --test linux_e2e
# cgroup + runc namespace tests (require delegation + runc)
cargo test -p synwire-sandbox --test linux_e2e -- --ignored
# gVisor tests only (require runsc on $PATH)
cargo test -p synwire-sandbox --test linux_e2e gvisor -- --ignored
The cgroup tests gracefully skip if delegation is not available. The
namespace tests skip if runc is not found. The gVisor tests skip if
runsc is not found.
How to: Perform Advanced Search
Goal: Use Vfs::grep with GrepOptions to run ripgrep-style content searches against any backend.
GrepOptions reference
#![allow(unused)] fn main() { use synwire_core::vfs::grep_options::{GrepOptions, GrepOutputMode}; let opts = GrepOptions { path: None, // search root; None = backend's current working directory case_insensitive: false, invert: false, // true = return non-matching lines line_numbers: false, // include line numbers in results output_mode: GrepOutputMode::Content, before_context: 0, // lines to include before each match after_context: 0, // lines to include after each match context: None, // symmetric context (overrides before/after when set) max_matches: None, // stop after N total matches glob: None, // filename glob filter, e.g. "*.rs" file_type: None, // ripgrep-style type: "rust", "python", "go", etc. multiline: false, fixed_string: false, // treat pattern as literal, not regex }; }
All fields have Default implementations. Start from GrepOptions::default() and override only what you need.
GrepMatch fields
#![allow(unused)] fn main() { pub struct GrepMatch { pub file: String, pub line_number: usize, // 1-indexed when line_numbers: true; 0 otherwise pub column: usize, // 0-indexed byte offset of match start pub line_content: String, // the matching line pub before: Vec<String>, // lines before the match (up to before_context) pub after: Vec<String>, // lines after the match (up to after_context) } }
In Count mode, line_number holds the match count for the file and line_content is its string representation.
Case-insensitive search
#![allow(unused)] fn main() { use synwire_core::vfs::grep_options::GrepOptions; use synwire_core::vfs::protocol::Vfs; let opts = GrepOptions { case_insensitive: true, line_numbers: true, ..Default::default() }; let matches = backend.grep("todo", opts).await?; for m in &matches { println!("{}:{} {}", m.file, m.line_number, m.line_content); } }
Context lines
#![allow(unused)] fn main() { let opts = GrepOptions { context: Some(2), // 2 lines before AND after each match line_numbers: true, ..Default::default() }; // Or use asymmetric context: let opts = GrepOptions { before_context: 3, after_context: 1, ..Default::default() }; }
When both context and before_context/after_context are set, context takes precedence.
File-type filter
Accepts ripgrep-style type names. Supported aliases include rust/rs, python/py, js/javascript, ts/typescript, go, json, yaml/yml, toml, md/markdown, sh/bash.
#![allow(unused)] fn main() { let opts = GrepOptions { file_type: Some("rust".to_string()), ..Default::default() }; let matches = backend.grep("unwrap", opts).await?; // Returns only matches in *.rs files. }
Glob filter
Restricts results to files whose name matches the glob pattern. * matches any sequence of non-separator characters; ** matches anything.
#![allow(unused)] fn main() { let opts = GrepOptions { glob: Some("*.toml".to_string()), ..Default::default() }; let matches = backend.grep("version", opts).await?; }
Inverted match
Returns lines that do NOT match the pattern.
#![allow(unused)] fn main() { let opts = GrepOptions { invert: true, ..Default::default() }; // Lines that do not contain "TODO". let matches = backend.grep("TODO", opts).await?; }
Files-with-matches mode
Returns one entry per file (with empty line_content) rather than one entry per matching line.
#![allow(unused)] fn main() { use synwire_core::vfs::grep_options::GrepOutputMode; let opts = GrepOptions { output_mode: GrepOutputMode::FilesWithMatches, ..Default::default() }; let matches = backend.grep("panic!", opts).await?; let files: Vec<&str> = matches.iter().map(|m| m.file.as_str()).collect(); }
Count mode
Returns one entry per file with line_number set to the number of matches in that file.
#![allow(unused)] fn main() { let opts = GrepOptions { output_mode: GrepOutputMode::Count, ..Default::default() }; let counts = backend.grep("error", opts).await?; for c in &counts { println!("{}: {} occurrences", c.file, c.line_number); } }
Limiting results
#![allow(unused)] fn main() { let opts = GrepOptions { max_matches: Some(50), ..Default::default() }; }
The search stops after max_matches total matches across all files. Useful for large codebases where you only need the first few hits.
Scoped search path
#![allow(unused)] fn main() { let opts = GrepOptions { path: Some("src/backends".to_string()), file_type: Some("rust".to_string()), ..Default::default() }; let matches = backend.grep("VfsError", opts).await?; }
path is resolved relative to the backend's current working directory.
See also
- How to: Use the Backend Implementations —
Vfsoperations - How to: Configure the Middleware Stack
- Reference: Feature Flags
Semantic Search
Task-focused recipes for common semantic search operations.
Enable semantic search
Add the semantic-search feature to your synwire-agent dependency:
[dependencies]
synwire-agent = { version = "0.1", features = ["semantic-search"] }
This is an opt-in feature because it adds heavyweight dependencies (fastembed,
LanceDB, tree-sitter grammars). When disabled, LocalProvider omits the
INDEX and SEMANTIC_SEARCH capabilities and the corresponding VFS tools are
not offered to the LLM.
Index a directory
use synwire_agent::vfs::local::LocalProvider;
use synwire_core::vfs::protocol::Vfs;
use synwire_core::vfs::types::IndexOptions;
use std::path::PathBuf;
let vfs = LocalProvider::new(PathBuf::from("/path/to/project"))?;
let handle = vfs.index("src", IndexOptions {
force: false,
include: vec!["**/*.rs".into(), "**/*.py".into()],
exclude: vec!["target/**".into(), "**/node_modules/**".into()],
max_file_size: Some(1_048_576),
}).await?;
The include and exclude fields accept glob patterns. If include is empty,
all files are included. exclude patterns are always applied.
Wait for indexing to complete
use synwire_core::vfs::types::IndexStatus;
loop {
match vfs.index_status(&handle.index_id).await? {
IndexStatus::Ready(result) => {
println!("{} files, {} chunks", result.files_indexed, result.chunks_produced);
break;
}
IndexStatus::Failed(e) => return Err(e.into()),
_ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
}
}
Search by meaning
use synwire_core::vfs::types::SemanticSearchOptions;
let results = vfs.semantic_search("authentication flow", SemanticSearchOptions {
top_k: Some(10),
min_score: None,
file_filter: vec![],
rerank: Some(true),
}).await?;
Search within specific files
Use file_filter to restrict search to a subset of indexed files:
let results = vfs.semantic_search("error handling", SemanticSearchOptions {
top_k: Some(5),
file_filter: vec!["src/auth/**".into(), "src/middleware/**".into()],
..Default::default()
}).await?;
Disable reranking for faster results
Cross-encoder reranking improves accuracy but adds latency. Disable it when speed matters more than precision:
let results = vfs.semantic_search("database queries", SemanticSearchOptions {
rerank: Some(false),
..Default::default()
}).await?;
Force a full re-index
By default, index() reuses cached results if available. To force a fresh index
(e.g. after a large merge or branch switch):
let handle = vfs.index("src", IndexOptions {
force: true,
..Default::default()
}).await?;
Configure chunk sizes
The default chunk size (1 500 bytes with 200-byte overlap) works well for most
codebases. To adjust for very large or very small files, construct the
SemanticIndex directly:
use synwire_index::{SemanticIndex, IndexConfig};
let config = IndexConfig {
cache_base: None, // use OS default
chunk_size: 2000, // larger chunks for long functions
chunk_overlap: 300, // more context between chunks
};
Note: Chunk size only affects the text splitter fallback. AST-chunked code files always use one chunk per definition regardless of size.
Use a custom cache directory
By default, index data is stored under the OS cache directory
($XDG_CACHE_HOME/synwire/indices/ on Linux). To use a project-local cache:
use synwire_index::IndexConfig;
use std::path::PathBuf;
let config = IndexConfig {
cache_base: Some(PathBuf::from(".synwire-cache")),
..Default::default()
};
Stop the file watcher
The background file watcher starts automatically after indexing completes. To stop it (e.g. before shutting down):
// If using SemanticIndex directly:
index.unwatch(&path).await;
// If using LocalProvider, the watcher stops when the provider is dropped.
Combine semantic search with grep
Semantic search and grep serve different purposes. Use both in a complementary workflow:
// Step 1: Find the concept
let semantic_results = vfs.semantic_search(
"rate limiting middleware",
SemanticSearchOptions::default(),
).await?;
// Step 2: Find exact usages of the function you discovered
let grep_results = vfs.grep(
"apply_rate_limit",
synwire_core::vfs::types::GrepOptions::default(),
).await?;
Handle indexing errors
Individual file failures during indexing are logged and skipped — the pipeline continues with remaining files. To detect these:
- Check
IndexResult::files_indexedagainst expected file count. - Enable
tracingatWARNlevel to see per-file errors:
// In your application setup:
tracing_subscriber::fmt()
.with_env_filter("synwire_index=warn")
.init();
Agentic ignore files
LocalProvider automatically discovers and respects agentic ignore files —
.cursorignore, .aiignore, .claudeignore, .aiderignore, .copilotignore,
.codeiumignore, .tabbyignore, and .gitignore — by searching upward from
the provider's root directory to the filesystem root. Files matching any
discovered pattern are excluded from ls, grep, glob, and semantic indexing.
All ignore files use gitignore syntax, including negation (! prefix) and
directory-only patterns (trailing /):
# .cursorignore — exclude secrets and build artifacts
.env*
secret/
target/
node_modules/
!.env.example # but keep the example
To check which ignore files are in effect, create an AgenticIgnore directly:
use synwire_core::vfs::agentic_ignore::AgenticIgnore;
use std::path::Path;
let ai = AgenticIgnore::discover(Path::new("/path/to/project"));
assert!(ai.is_ignored(Path::new("/path/to/project/.env"), false));
Prevent indexing the root filesystem
index("/", ..) is always rejected with VfsError::IndexDenied. This is a
safety measure — indexing the entire filesystem would be extremely slow and
produce unusable results. Always index specific project directories.
Hybrid search (BM25 + vector)
When synwire-index is built with the hybrid-search feature, you can combine BM25 lexical scoring with vector semantic scoring:
#[cfg(feature = "hybrid-search")]
use synwire_index::{HybridSearchConfig, hybrid_search};
let config = HybridSearchConfig {
alpha: 0.5, // 0.0 = pure vector, 1.0 = pure BM25
top_k: 10,
};
let results = hybrid_search(&bm25_index, &vector_store, &embeddings, "auth", config).await?;
Tuning alpha
| alpha | Best for |
|---|---|
0.0 | Conceptual queries ("authentication logic") |
0.5 | General use — balanced (default) |
1.0 | Exact identifier queries ("MyStruct::authenticate") |
Start with alpha = 0.5 and adjust based on your query patterns. If you are searching for exact function names, increase alpha toward 1.0. If you are searching conceptually, decrease it toward 0.0.
See also
- Semantic Search Tutorial — step-by-step walkthrough
- Semantic Search Architecture — design rationale
- VFS Providers — general VFS operations
- Advanced Search (grep) — text pattern search
How to: Integrate Language Servers
Goal: Connect your agent to Language Server Protocol servers for code intelligence -- go-to-definition, hover, diagnostics, completions.
Quick start
Add synwire-lsp to your workspace dependencies and register the LspPlugin on the agent builder.
[dependencies]
synwire-lsp = { version = "0.1" }
use synwire_lsp::plugin::LspPlugin;
use synwire_lsp::registry::LanguageServerRegistry;
use synwire_lsp::config::LspPluginConfig;
let registry = LanguageServerRegistry::default_registry();
let lsp = LspPlugin::new(registry, LspPluginConfig::default());
let agent = Agent::new("coder", "coding assistant")
.plugin(Box::new(lsp))
.build()?;
The plugin registers five tools: lsp_hover, lsp_goto_definition, lsp_references, lsp_diagnostics, and lsp_completion. It also injects a system prompt telling the model which language servers are available.
Auto-start
LspPlugin detects language servers on PATH based on file extension. The first time a tool is called for a file, the plugin:
- Looks up the file extension in the
LanguageServerRegistry. - Checks whether the server binary is available via
which::which(). - Spawns the server in
--stdiomode if found. - Performs the LSP
initialize/initializedhandshake. - Sends
textDocument/didOpenfor the target file.
Subsequent calls to the same server reuse the running process. The plugin shuts down all servers when the agent session ends.
If no server is found for a language, the tool returns a structured error describing which server is expected and how to install it.
Example: hover and go-to-definition
A typical exchange with rust-analyzer:
use synwire_lsp::plugin::LspPlugin;
use synwire_lsp::registry::LanguageServerRegistry;
use synwire_lsp::config::LspPluginConfig;
use synwire_agent::Agent;
let registry = LanguageServerRegistry::default_registry();
let lsp = LspPlugin::new(registry, LspPluginConfig::default());
let agent = Agent::new("coder", "Rust coding assistant")
.plugin(Box::new(lsp))
.build()?;
// The model can now call:
// lsp_hover { path: "src/main.rs", line: 42, column: 10 }
// lsp_goto_definition { path: "src/main.rs", line: 42, column: 10 }
// lsp_references { path: "src/lib.rs", line: 15, column: 4 }
// lsp_diagnostics { path: "src/lib.rs" }
// lsp_completion { path: "src/main.rs", line: 42, column: 10 }
The model sees structured results containing type signatures, documentation strings, file locations, and severity-tagged diagnostics.
Document sync with VFS
When the agent writes or edits files through VFS tools, the LSP server must know about the changes to provide accurate results. LspPlugin subscribes to VFS write hooks automatically:
write/appendtriggerstextDocument/didOpenortextDocument/didChange.edittriggerstextDocument/didChangewith incremental edits.rmtriggerstextDocument/didClose.
This means the model can write code via VFS, then immediately call lsp_diagnostics to check for errors -- without manual synchronisation.
use synwire_lsp::config::LspPluginConfig;
// Disable automatic sync if you manage notifications yourself.
let config = LspPluginConfig {
auto_sync_vfs: false,
..Default::default()
};
Multi-server mode
Agents working across multiple languages spawn one server per language automatically. The plugin routes each tool call to the correct server based on file extension.
// The model opens a Go file and a Rust file in the same session.
// lsp_hover { path: "cmd/main.go", line: 10, column: 5 } -> gopls
// lsp_hover { path: "src/lib.rs", line: 20, column: 8 } -> rust-analyzer
Servers are started lazily. A project touching five languages only spawns servers for the languages the model actually queries.
To cap resource usage:
let config = LspPluginConfig {
max_concurrent_servers: 3,
server_idle_timeout: std::time::Duration::from_secs(300),
..Default::default()
};
Servers idle beyond server_idle_timeout are shut down and restarted on the next request.
Configuration
Use LspServerConfig for fine-grained control over individual servers.
use synwire_lsp::config::{LspPluginConfig, LspServerConfig};
use synwire_lsp::registry::LanguageServerRegistry;
use std::collections::HashMap;
let mut overrides = HashMap::new();
overrides.insert("rust".to_string(), LspServerConfig {
command: "rust-analyzer".to_string(),
args: vec![],
initialization_options: serde_json::json!({
"checkOnSave": { "command": "clippy" },
"cargo": { "allFeatures": true }
}),
env: vec![("RUST_LOG".to_string(), "info".to_string())],
root_uri_override: None,
});
let config = LspPluginConfig {
server_overrides: overrides,
..Default::default()
};
let registry = LanguageServerRegistry::default_registry();
let lsp = LspPlugin::new(registry, config);
LspServerConfig fields:
| Field | Type | Description |
|---|---|---|
command | String | Server binary name or path |
args | Vec<String> | CLI arguments appended after the command |
initialization_options | serde_json::Value | Sent in the LSP initialize request |
env | Vec<(String, String)> | Extra environment variables for the server process |
root_uri_override | Option<String> | Override the workspace root URI |
See also
- Explanation: synwire-lsp -- design rationale and protocol details
- How to: Configure Language Servers -- built-in server list, custom entries, TOML config
- How to: Integrate Debug Adapters -- DAP plugin for debugging support
- How to: Use the Virtual Filesystem (VFS) -- VFS providers and document sync
How to: Integrate Debug Adapters
Goal: Give your agent debugging capabilities -- set breakpoints, step through code, inspect variables -- via the Debug Adapter Protocol (DAP).
Quick start
Add synwire-dap to your workspace dependencies and register the DapPlugin on the agent builder.
[dependencies]
synwire-dap = { version = "0.1" }
use synwire_dap::plugin::DapPlugin;
use synwire_dap::config::DapPluginConfig;
let dap = DapPlugin::new(DapPluginConfig::default());
let agent = Agent::new("debugger", "debugging assistant")
.plugin(Box::new(dap))
.build()?;
The plugin registers these tools: debug.launch, debug.attach, debug.set_breakpoints, debug.continue, debug.step_over, debug.step_in, debug.step_out, debug.variables, debug.evaluate, debug.stack_trace, and debug.disconnect.
Launch vs attach
DAP supports two modes for starting a debug session.
Launch mode
The plugin spawns the debug adapter and the target program together.
// The model calls:
// debug.launch {
// adapter: "dlv-dap",
// program: "./cmd/myapp",
// args: ["--config", "dev.yaml"],
// cwd: "/home/user/project"
// }
Attach mode
The plugin connects to an already-running process or a debug adapter listening on a port.
// The model calls:
// debug.attach {
// adapter: "dlv-dap",
// pid: 12345
// }
//
// Or attach by address:
// debug.attach {
// adapter: "dlv-dap",
// host: "127.0.0.1",
// port: 2345
// }
Use launch mode for test debugging. Use attach mode for inspecting running services.
Example: debug a Go test
A typical debugging session with dlv-dap:
use synwire_dap::plugin::DapPlugin;
use synwire_dap::config::{DapPluginConfig, DapAdapterConfig};
use synwire_agent::Agent;
let config = DapPluginConfig {
adapters: vec![
DapAdapterConfig {
name: "dlv-dap".to_string(),
command: "dlv".to_string(),
args: vec!["dap".to_string()],
languages: vec!["go".to_string()],
},
],
..Default::default()
};
let dap = DapPlugin::new(config);
let agent = Agent::new("go-debugger", "Go debugging assistant")
.plugin(Box::new(dap))
.build()?;
// The model can now orchestrate a debugging session:
//
// 1. debug.launch { adapter: "dlv-dap", mode: "test", program: "./pkg/auth" }
// 2. debug.set_breakpoints { path: "pkg/auth/token_test.go", line: 42 }
// 3. debug.continue {}
// 4. debug.variables { scope: "local" }
// 5. debug.stack_trace {}
// 6. debug.step_over {}
// 7. debug.variables { scope: "local" }
// 8. debug.disconnect {}
The model receives structured data for each response: variable names, types, values, and stack frame locations. It can reason about program state and suggest fixes.
Event handling
When the debuggee hits a breakpoint or throws an exception, the plugin emits a dap_stopped signal. Configure automatic inspection so the model receives context without an extra round-trip:
use synwire_dap::config::DapPluginConfig;
let config = DapPluginConfig {
on_stopped: synwire_dap::config::StoppedBehaviour::AutoInspect {
// Automatically fetch locals and the top 5 stack frames on every stop.
include_locals: true,
stack_depth: 5,
},
..Default::default()
};
Available StoppedBehaviour variants:
| Variant | Effect |
|---|---|
Notify | Emit the signal only; the model decides what to inspect |
AutoInspect { .. } | Fetch variables and stack trace automatically, inject into context |
Ignore | Suppress the signal entirely (useful when scripting bulk stepping) |
Security: debug.evaluate requires approval
debug.evaluate executes arbitrary expressions in the debuggee's runtime. This is marked as RiskLevel::Critical and requires explicit approval through the configured approval gate.
use synwire_agent::vfs::threshold_gate::ThresholdGate;
use synwire_core::vfs::approval::{RiskLevel, AutoDenyCallback};
// Approve up to High risk automatically; debug.evaluate (Critical) still prompts.
let gate = ThresholdGate::new(RiskLevel::High, CliPrompt);
Other DAP tools are classified as follows:
| Risk level | Tools |
|---|---|
None | debug.variables, debug.stack_trace |
Low | debug.set_breakpoints, debug.continue, debug.step_over, debug.step_in, debug.step_out |
Medium | debug.launch, debug.attach, debug.disconnect |
Critical | debug.evaluate |
Configuration
DapAdapterConfig fields:
| Field | Type | Description |
|---|---|---|
name | String | Identifier used in tool calls |
command | String | Adapter binary name or path |
args | Vec<String> | CLI arguments for the adapter process |
languages | Vec<String> | File extensions this adapter handles |
env | Vec<(String, String)> | Extra environment variables |
launch_timeout | Duration | Max time to wait for adapter initialisation (default: 10s) |
DapPluginConfig fields:
| Field | Type | Description |
|---|---|---|
adapters | Vec<DapAdapterConfig> | Registered debug adapters |
on_stopped | StoppedBehaviour | How to handle breakpoint/exception stops |
max_concurrent_sessions | usize | Limit simultaneous debug sessions (default: 1) |
See also
- Explanation: synwire-dap -- design rationale and DAP protocol mapping
- How to: Integrate Language Servers -- LSP plugin for code intelligence
- How to: Configure Approval Gates -- controlling
debug.evaluateapproval - How to: Configure Permission Modes -- tool-level allow/deny rules
How to: Configure Language Servers
Goal: Discover, install, and configure language servers for your agent's LSP integration.
Built-in servers
LanguageServerRegistry::default_registry() ships with 23 entries covering the most common languages. The plugin auto-starts whichever server is found on PATH when the model first queries a file of that language.
| Language | Server | Command | Install |
|---|---|---|---|
| Rust | rust-analyzer | rust-analyzer | rustup component add rust-analyzer |
| Go | gopls | gopls serve | go install golang.org/x/tools/gopls@latest |
| Python | pylsp | pylsp | pip install python-lsp-server |
| Python | pyright | pyright-langserver --stdio | npm install -g pyright |
| TypeScript/JS | typescript-language-server | typescript-language-server --stdio | npm install -g typescript-language-server typescript |
| C/C++ | clangd | clangd | apt install clangd / brew install llvm |
| Java | jdtls | jdtls | Eclipse JDT.LS manual setup |
| C# | csharp-ls | csharp-ls | dotnet tool install csharp-ls |
| Ruby | solargraph | solargraph stdio | gem install solargraph |
| Ruby | ruby-lsp | ruby-lsp | gem install ruby-lsp |
| Lua | lua-language-server | lua-language-server | GitHub releases |
| Bash | bash-language-server | bash-language-server start | npm install -g bash-language-server |
| YAML | yaml-language-server | yaml-language-server --stdio | npm install -g yaml-language-server |
| Kotlin | kotlin-language-server | kotlin-language-server | GitHub releases |
| Scala | metals | metals | coursier install metals |
| Haskell | haskell-language-server | haskell-language-server-wrapper --lsp | ghcup install hls |
| Elixir | elixir-ls | language_server.sh | GitHub releases |
| Zig | zls | zls | GitHub releases |
| OCaml | ocaml-lsp | ocamllsp | opam install ocaml-lsp-server |
| Swift | sourcekit-lsp | sourcekit-lsp | Bundled with Xcode/Swift toolchain |
| PHP | phpactor | phpactor language-server | composer global require phpactor/phpactor |
| Terraform | terraform-ls | terraform-ls serve | brew install hashicorp/tap/terraform-ls |
| Dockerfile | dockerfile-language-server | docker-langserver --stdio | npm install -g dockerfile-language-server-nodejs |
Languages with two entries (Python, Ruby) use a priority order. The registry tries the first match and falls back to the second if the binary is not found.
Checking availability
Before starting an agent, verify that the servers you need are installed:
use synwire_lsp::registry::LanguageServerRegistry;
let registry = LanguageServerRegistry::default_registry();
// Check a single language.
if let Some(entry) = registry.lookup("rust") {
match which::which(&entry.command) {
Ok(path) => println!("rust-analyzer found at {}", path.display()),
Err(_) => eprintln!("rust-analyzer not found; install with: {}", entry.install_hint),
}
}
// Check all registered languages and report missing servers.
for entry in registry.all_entries() {
let available = which::which(&entry.command).is_ok();
println!(
"{:<20} {:<30} {}",
entry.language,
entry.server_name,
if available { "OK" } else { "MISSING" }
);
}
The LspPlugin performs this check lazily at first use. Missing servers produce a structured tool error rather than a panic.
Custom server config via TOML
Define additional servers or override built-in entries in a TOML file:
# lsp-servers.toml
[[servers]]
language = "nix"
server_name = "nil"
command = "nil"
args = []
install_hint = "nix profile install nixpkgs#nil"
extensions = ["nix"]
[[servers]]
language = "rust"
server_name = "rust-analyzer"
command = "rust-analyzer"
args = []
install_hint = "rustup component add rust-analyzer"
extensions = ["rs"]
[servers.initialization_options]
checkOnSave = { command = "clippy" }
cargo = { allFeatures = true }
Load the file into the registry:
use synwire_lsp::registry::LanguageServerRegistry;
use std::path::Path;
let mut registry = LanguageServerRegistry::default_registry();
registry.load_toml(Path::new("lsp-servers.toml"))?;
Entries with the same (language, server_name) pair replace the built-in entry. New language/server pairs are appended.
Custom server config via API
Add entries programmatically when TOML is not convenient:
use synwire_lsp::registry::{LanguageServerRegistry, ServerEntry};
let mut registry = LanguageServerRegistry::default_registry();
registry.register(ServerEntry {
language: "nix".to_string(),
server_name: "nil".to_string(),
command: "nil".to_string(),
args: vec![],
extensions: vec!["nix".to_string()],
install_hint: "nix profile install nixpkgs#nil".to_string(),
initialization_options: serde_json::Value::Null,
priority: 0,
});
Lower priority values are tried first. The default entries use priority 0 for the primary server and 10 for alternatives.
Per-language overrides
When multiple servers are registered for the same language, the registry picks the highest-priority (lowest number) server whose binary exists on PATH. Override the selection explicitly:
use synwire_lsp::registry::LanguageServerRegistry;
let mut registry = LanguageServerRegistry::default_registry();
// Prefer pyright over pylsp for Python files.
registry.set_priority("python", "pyright", 0);
registry.set_priority("python", "pylsp", 10);
// Or disable a server entirely.
registry.disable("python", "pylsp");
The disable method removes the entry from consideration without deleting it. Re-enable with registry.enable("python", "pylsp").
To check which server would be selected for a language:
if let Some(entry) = registry.resolve("python") {
println!("Python will use: {} ({})", entry.server_name, entry.command);
}
resolve checks both priority and binary availability, returning the best candidate.
ServerEntry fields
| Field | Type | Description |
|---|---|---|
language | String | Language identifier (used in registry lookups) |
server_name | String | Human-readable server name |
command | String | Binary name or absolute path |
args | Vec<String> | CLI arguments appended after the command |
extensions | Vec<String> | File extensions that map to this server |
install_hint | String | Shown to the model when the binary is missing |
initialization_options | serde_json::Value | Sent in the LSP initialize request |
priority | u8 | Lower wins when multiple servers match (default: 0) |
See also
- How to: Integrate Language Servers -- using
LspPluginwith the agent builder - Explanation: synwire-lsp -- architecture and protocol handling
Migration Guide
Pre-StorageLayout to StorageLayout paths
Before StorageLayout was introduced, Synwire stored data under ad-hoc paths. This guide covers moving existing data to the new layout.
What changed
| Data | Old path | New path |
|---|---|---|
| Vector indices | $CACHE/synwire/indices/ | StorageLayout::index_cache(worktree) |
| Code graphs | $CACHE/synwire/graphs/ | StorageLayout::graph_dir(worktree) |
| Agent skills | $DATA/synwire/skills/ | StorageLayout::skills_dir() |
The new paths include the product name and (for per-worktree data) a WorktreeId key.
Shell migration
The index cache and graph cache are regenerable — they can be deleted and will be rebuilt on next use. Skills are durable and should be migrated.
# Product name to migrate to (must match --product-name you will use)
PRODUCT="synwire"
# Determine your worktree key
WORKTREE_KEY=$(synwire-mcp-server --project . --product-name "$PRODUCT" --print-worktree-key 2>/dev/null || echo "unknown")
# New base paths (Linux/XDG)
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/$PRODUCT"
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/$PRODUCT"
# Migrate skills (durable — copy, verify, then remove old)
if [ -d "$HOME/.local/share/synwire/skills" ]; then
mkdir -p "$DATA_DIR/skills"
cp -r "$HOME/.local/share/synwire/skills/." "$DATA_DIR/skills/"
echo "Skills migrated to $DATA_DIR/skills/"
fi
# Index and graph caches are regenerable — safe to move or delete
# Option A: delete (will be rebuilt on next index)
rm -rf "$HOME/.cache/synwire/indices"
rm -rf "$HOME/.cache/synwire/graphs"
# Option B: move (avoids rebuild, but only works if worktree_key is known)
# mkdir -p "$CACHE_DIR/indices"
# mv "$HOME/.cache/synwire/indices" "$CACHE_DIR/indices/$WORKTREE_KEY"
The
--print-worktree-keyflag is not implemented in v0.1. Use option A (delete) unless you need to preserve index data, in which case buildsynwire-storageseparately to compute the key programmatically.
Programmatic key derivation
#![allow(unused)] fn main() { use synwire_storage::{StorageLayout, WorktreeId}; use std::path::Path; let layout = StorageLayout::new("synwire")?; let wid = WorktreeId::for_path(Path::new("."))?; println!("index cache: {}", layout.index_cache(&wid).display()); println!("graph dir: {}", layout.graph_dir(&wid).display()); }
Run this small program from your project root to get the exact destination paths for your machine.
Config file migration
If you previously used a custom SYNWIRE_CACHE_DIR or SYNWIRE_DATA_DIR environment variable, these still work in v0.1 and take the highest precedence. No changes are needed if you rely on them.
If you used a project-local config at $PROJECT/.synwire/config.json (pre-StorageLayout), rename it to .$PRODUCT/config.json where $PRODUCT is your product name:
# Rename .synwire to .myapp (if using product name 'myapp')
mv .synwire .myapp
See also
- synwire-storage explanation — layout architecture
- synwire-storage — configuration hierarchy
Architecture
Synwire uses a trait-based architecture where each concern is defined by a trait and implementations are swappable at runtime via trait objects.
Trait hierarchy
graph TD
RunnableCore --> BaseChatModel
RunnableCore --> Embeddings
RunnableCore --> VectorStore
RunnableCore --> Tool
RunnableCore --> OutputParser
BaseChatModel --> ChatOpenAI
BaseChatModel --> ChatOllama
BaseChatModel --> FakeChatModel
Embeddings --> OpenAIEmbeddings
Embeddings --> OllamaEmbeddings
Embeddings --> FakeEmbeddings
VectorStore --> InMemoryVectorStore
Tool --> StructuredTool
Core traits
BaseChatModel
Defines invoke, batch, stream, model_type, and bind_tools. All methods use BoxFuture for dyn-compatibility. The batch and bind_tools methods have default implementations.
RunnableCore
The universal composition primitive. Uses serde_json::Value as the I/O type for object safety and heterogeneous chaining. Heterogeneous chaining in a statically-typed language requires a common I/O boundary; serde_json::Value provides that boundary without sacrificing Send + Sync or object safety.
Embeddings
Simple trait with embed_documents (batch) and embed_query (single text). Some providers use different models for queries versus documents.
VectorStore
Manages document storage and similarity search. Methods accept an &dyn Embeddings to decouple storage from embedding computation.
Tool
Defines name, description, schema, and invoke. Tools are Send + Sync for use across async tasks.
Design principles
Object safety via BoxFuture
Rust traits with async fn methods are not dyn-compatible. Synwire uses manual BoxFuture desugaring:
fn invoke<'a>(
&'a self,
input: &'a [Message],
config: Option<&'a RunnableConfig>,
) -> BoxFuture<'a, Result<ChatResult, SynwireError>>;
This enables Box<dyn BaseChatModel> and &dyn BaseChatModel usage.
serde_json::Value as universal I/O
RunnableCore uses Value rather than generics because:
- Generic trait parameters prevent
Vec<Box<dyn RunnableCore>>for heterogeneous chains - Any runnable can compose with any other without explicit type conversions
- The trade-off (runtime vs compile-time checking) matches the typical use case
Error hierarchy
SynwireError wraps domain-specific error types (ModelError, ToolError, ParseError, etc.) with #[from] conversions. SynwireErrorKind provides discriminant-based matching for retry and fallback logic without inspecting payloads.
Send + Sync everywhere
All traits require Send + Sync because the primary runtime is Tokio with multi-threaded executor. This enables shared ownership via Arc<dyn BaseChatModel> and spawning across task boundaries.
Layered crate architecture
graph BT
synwire-core --> synwire-orchestrator
synwire-core --> synwire-checkpoint
synwire-core --> synwire-llm-openai
synwire-core --> synwire-llm-ollama
synwire-checkpoint --> synwire-checkpoint-sqlite
synwire-core --> synwire
synwire-core --> synwire-derive
synwire-core --> synwire-test-utils
synwire-checkpoint --> synwire-test-utils
synwire-orchestrator --> synwire-test-utils
synwire-core has zero dependencies on other Synwire crates. Provider crates depend only on synwire-core. The orchestrator depends on synwire-core for trait definitions.
Pregel Execution Model
The synwire-orchestrator crate executes graphs using a Pregel-inspired superstep model.
What is Pregel?
Pregel is Google's model for large-scale graph processing. In Synwire, it is adapted for sequential state machine execution:
- Superstep: execute the current node's function with the current state
- Edge resolution: determine the next node (static or conditional)
- Repeat until
__end__is reached or the recursion limit is exceeded
Execution flow
sequenceDiagram
participant C as Client
participant G as CompiledGraph
participant N as Node Function
C->>G: invoke(state)
loop Each superstep
G->>N: node_fn(state)
N-->>G: updated state
G->>G: resolve next node
alt Reached __end__
G-->>C: final state
else Recursion limit
G-->>C: GraphError::RecursionLimit
end
end
State as serde_json::Value
Graph state is a serde_json::Value, typically a JSON object. Each node receives the full state, transforms it, and returns the updated state.
Edge resolution order
- Conditional edges are checked first. The condition function inspects the state and returns a key, which is looked up in a mapping to find the target node.
- Static edges are checked next if no conditional edge exists for the current node.
- If neither exists,
GraphError::CompileErroris returned.
Recursion limit
The default limit is defined in constants::DEFAULT_RECURSION_LIMIT. Override it per graph:
let compiled = graph.compile()?.with_recursion_limit(50);
This prevents infinite loops from conditional edges that never reach __end__.
Channels and supersteps
In the full Pregel model, channels accumulate values during a superstep and expose a reduced value for the next superstep. Synwire's BaseChannel trait supports this:
update: receive values during a superstepget: read the current valueconsume: take the value and reset
Different channel types implement different reduction strategies (last value, append, max, etc.).
Determinism
Given the same input state and the same graph topology, execution is deterministic. The Pregel loop is sequential (one node per superstep), so there are no concurrency-related non-determinism concerns within a single invocation.
See also
- Checkpointing Tutorial — how to snapshot and resume Pregel runs
- Checkpointing Explanation —
BaseCheckpointSaverand the serde protocol - Channel System — how channels accumulate state between supersteps
Channel System
Channels are the state management mechanism in synwire-orchestrator. Each channel manages a single key in the graph state.
Purpose
In the Pregel execution model, multiple nodes may write to the same state key during a superstep. Channels define how those writes are combined:
- LastValue: the most recent write wins (overwrite semantics)
- Topic: all writes are appended (accumulator semantics)
- AnyValue: accepts any single value
- BinaryOperator: combines values with a custom function
- NamedBarrier: synchronisation primitive for fan-in patterns
- Ephemeral: value is cleared after each read
BaseChannel trait
pub trait BaseChannel: Send + Sync {
fn key(&self) -> &str;
fn update(&mut self, values: Vec<serde_json::Value>) -> Result<(), GraphError>;
fn get(&self) -> Option<&serde_json::Value>;
fn checkpoint(&self) -> serde_json::Value;
fn restore_checkpoint(&mut self, value: serde_json::Value);
fn consume(&mut self) -> Option<serde_json::Value>;
fn is_available(&self) -> bool;
}
Channel selection guide
| Use case | Channel | Reason |
|---|---|---|
| Single current value | LastValue | Overwrites; always has the latest |
| Message history | Topic | Appends; preserves full history |
| Intermediate computation | Ephemeral | Cleared after read; no state accumulation |
| Custom reduction | BinaryOperator | User-defined combine function |
| Fan-in synchronisation | NamedBarrier | Waits for all writers |
Checkpointing interaction
Channels participate in checkpointing via checkpoint() and restore_checkpoint(). When a graph is paused and resumed, the channel state is serialised to JSON and restored.
Derive macro integration
The #[derive(State)] macro generates channel configuration from struct annotations:
#[derive(State)]
struct AgentState {
#[reducer(topic)] // -> Topic channel
messages: Vec<String>,
current: String, // -> LastValue channel (default)
}
Error handling
LastValue channels return GraphError::MultipleValues if they receive more than one value in a single update call. Topic channels accept any number of values.
StateGraph vs FsmStrategy: Choosing the Right Tool
Synwire provides two mechanisms for structured agent behaviour: StateGraph from synwire-orchestrator and FsmStrategy from synwire-agent. They solve different problems and are composable — a graph node can itself be an FSM-governed agent.
Background: AI Workflows vs AI Agents — the Prompt Engineering Guide explains the spectrum from deterministic pipelines to autonomous agents. Both
StateGraphandFsmStrategysit on this spectrum, just at different scopes.
StateGraph: Multi-node pipelines
Use StateGraph when your application has multiple distinct processing components that exchange state.
#![allow(unused)] fn main() { use synwire_derive::State; use synwire_orchestrator::graph::StateGraph; use synwire_orchestrator::constants::END; use synwire_orchestrator::func::sync_node; #[derive(State, Clone, Debug, Default, serde::Serialize, serde::Deserialize)] struct PipelineState { #[reducer(last_value)] query: String, #[reducer(topic)] retrieved_docs: Vec<String>, #[reducer(last_value)] answer: String, } // Three distinct nodes: retrieve → generate → format let mut graph = StateGraph::<PipelineState>::new(); graph.add_node("retrieve", sync_node(|mut s: PipelineState| { s.retrieved_docs.push("Doc about Rust ownership".to_string()); Ok(s) }))?; graph.add_node("generate", sync_node(|mut s: PipelineState| { s.answer = format!("Based on {} docs: …", s.retrieved_docs.len()); Ok(s) }))?; graph.add_node("format", sync_node(|mut s: PipelineState| { s.answer = format!("**Answer**: {}", s.answer); Ok(s) }))?; graph.set_entry_point("retrieve") .add_edge("retrieve", "generate") .add_edge("generate", "format") .add_edge("format", END); let compiled = graph.compile()?; }
What StateGraph gives you:
- A compiled, static topology — nodes and edges are fixed at compile time; the Pregel engine validates the graph before execution
- Channel-based state merging — each field in
Statehas an explicit merge rule (LastValue,Topic,Ephemeral, etc.); concurrent node writes are safe and deterministic - Conditional routing —
add_conditional_edgesroutes to different nodes based on state; enables branching and retry loops - Checkpointing —
with_checkpoint_saversnapshots state after each superstep; runs are resumable
FsmStrategy: One agent's internal turn logic
Use FsmStrategy when a single agent needs structured state machine semantics governing its own turn sequence.
#![allow(unused)] fn main() { use synwire_agent::strategies::{FsmStrategyBuilder, ClosureGuard}; use synwire_core::agents::directive::Directive; let strategy = FsmStrategyBuilder::new() .add_state("waiting") .add_state("executing") .add_state("reviewing") .set_initial_state("waiting") // Transition to executing when the agent issues a RunInstruction directive .add_transition( "waiting", "executing", ClosureGuard::new(|d| matches!(d, Directive::RunInstruction { .. })), ) // Transition to reviewing after execution completes .add_transition("executing", "reviewing", ClosureGuard::always()) // Return to waiting after review .add_transition("reviewing", "waiting", ClosureGuard::always()) .build()?; }
What FsmStrategy gives you:
- A runtime transition table — states and transitions are evaluated on every turn
- Guard conditions — closures over
Directivevalues decide whether a transition fires; guards can be priority-ordered - Approval gate integration — a guard can check
RiskLeveland block a transition until human approval arrives - No topology knowledge —
FsmStrategyoperates entirely within oneRunner's turn loop; it has no notion of other nodes or channels
Decision table
| Dimension | StateGraph | FsmStrategy |
|---|---|---|
| Scope | Multiple distinct system components (LLM, retriever, validator, formatter) | Single agent's internal turn logic |
| State sharing | Explicit channels; nodes exchange structured state | Agent's own State type; not shared across nodes |
| Routing | Conditional edges defined at graph build time | Guard conditions evaluated at runtime per directive |
| Checkpointing | First-class, per-superstep | Not built-in (session management handles persistence) |
| Concurrency | Parallel node execution within a superstep | Sequential turns within one Runner |
| When to choose | You have ≥ 2 distinct processing roles | You have 1 agent needing structured turn logic |
They compose
StateGraph and FsmStrategy are not mutually exclusive. A graph node can be a Runner backed by FsmStrategy:
#![allow(unused)] fn main() { use synwire_agent::runner::Runner; use synwire_agent::strategies::FsmStrategy; use synwire_orchestrator::graph::StateGraph; // An FSM-governed agent used as one node in a larger graph. // The graph handles multi-node orchestration; // the FsmStrategy handles the agent's internal turn structure. // let fsm_agent = Runner::builder() // .agent(my_agent_node) // .strategy(my_fsm_strategy) // .build()?; // // graph.add_node("agent_step", async move |state| { // let events = fsm_agent.run(state.input.clone(), Default::default()).await?; // // ... collect events, update state // Ok(state) // }); }
See also
- FSM Strategy Design — guard conditions, priority, transition semantics
- Pregel Execution Model — how
StateGraphexecutes supersteps - Execution Strategies Tutorial — hands-on with
DirectStrategyandFsmStrategy - Graph Agent Getting Started — your first
StateGraph
Crate Organisation
Synwire is organised as a Cargo workspace with focused, single-responsibility crates.
Workspace structure
crates/
synwire-core/ Core traits and types (zero Synwire deps)
synwire-orchestrator/ Graph execution engine (depends on core)
synwire-checkpoint/ Checkpoint traits + in-memory impl
synwire-checkpoint-sqlite/ SQLite checkpoint backend
synwire-llm-openai/ OpenAI provider
synwire-llm-ollama/ Ollama provider
synwire-derive/ Proc macros (#[tool], #[derive(State)])
synwire-test-utils/ Fake models, proptest strategies, fixtures
synwire/ Re-exports, caches, text splitters, prompts
synwire-agent/ Agent runtime (VFS, middleware, strategies, MCP, sessions)
synwire-chunker/ Tree-sitter AST-aware code chunking (14 languages)
synwire-embeddings-local/ Local embedding + reranking via fastembed-rs
synwire-vectorstore-lancedb/ LanceDB vector store
synwire-index/ Semantic indexing pipeline (walk→chunk→embed→store)
synwire-storage/ StorageLayout, RepoId/WorktreeId
synwire-agent-skills/ Agent skills (agentskills.io spec, Lua/Rhai/WASM)
synwire-lsp/ LSP client (language server integration)
synwire-dap/ DAP client (debug adapter integration)
synwire-sandbox/ Process sandboxing
synwire-mcp-server/ Standalone MCP server binary (stdio transport)
Design rationale
Why separate crates?
- Compile time: users only compile what they use. An Ollama-only project does not compile OpenAI code.
- Dependency isolation:
synwire-corehas minimal dependencies. Provider crates addreqwest,eventsource-stream, etc. - Feature flag surface: each crate has independent feature flags rather than one mega-crate with dozens of flags.
- Clear API boundaries: traits in
synwire-corecannot depend on implementations in provider crates.
Dependency graph
graph TD
core[synwire-core]
orch[synwire-orchestrator]
ckpt[synwire-checkpoint]
sqlite[synwire-checkpoint-sqlite]
openai[synwire-llm-openai]
ollama[synwire-llm-ollama]
derive[synwire-derive]
test[synwire-test-utils]
umbrella[synwire]
agent[synwire-agent]
chunker[synwire-chunker]
emb[synwire-embeddings-local]
lance[synwire-vectorstore-lancedb]
idx[synwire-index]
storage[synwire-storage]
skills[synwire-agent-skills]
lsp[synwire-lsp]
dap[synwire-dap]
sandbox[synwire-sandbox]
mcp[synwire-mcp-server]
core --> orch
core --> ckpt
core --> openai
core --> ollama
core --> derive
core --> umbrella
ckpt --> sqlite
core --> test
ckpt --> test
orch --> test
core --> agent
core --> chunker
core --> emb
core --> lance
chunker --> idx
emb --> idx
lance --> idx
storage --> idx
storage --> agent
storage --> mcp
agent --> mcp
idx --> mcp
skills --> mcp
lsp --> mcp
dap --> mcp
sandbox --> agent
synwire-core
The foundation crate. Defines all core traits (BaseChatModel, Embeddings, VectorStore, Tool, RunnableCore, OutputParser, CallbackHandler), error types, message types, and credentials. Has zero dependencies on other Synwire crates.
synwire-orchestrator
Graph-based orchestration. Depends on synwire-core for trait definitions. Contains StateGraph, CompiledGraph, channels, prebuilt agents (ReAct), and the Pregel execution engine.
synwire-checkpoint
Checkpoint abstraction layer. Defines BaseCheckpointSaver and BaseStore traits, plus an InMemoryCheckpointSaver for testing.
synwire-checkpoint-sqlite
Concrete checkpoint backend using SQLite via rusqlite + r2d2 connection pooling.
Provider crates
synwire-llm-openai and synwire-llm-ollama implement BaseChatModel and Embeddings for their respective APIs. They depend on synwire-core and HTTP-related crates.
synwire-derive
Procedural macro crate. Must be a separate crate due to Rust's proc-macro rules. Depends on syn, quote, proc-macro2.
synwire-test-utils
Shared test infrastructure: FakeChatModel (also in core for convenience), FakeEmbeddings, proptest strategies for all core types, and fixture builders.
synwire (umbrella)
Convenience crate that re-exports core and optionally includes provider crates via feature flags (openai, ollama). Also provides higher-level utilities: embedding cache, chat history, few-shot prompts, text splitters.
Choosing which crates to depend on
For most applications, depend on the umbrella synwire crate with the required feature flags:
[dependencies]
synwire = { version = "0.1", features = ["openai"] }
tokio = { version = "1", features = ["full"] }
This gives you a single import path covering the most commonly needed types:
use synwire::agent::prelude::*; use synwire_llm_openai::ChatOpenAI; #[tokio::main] async fn main() -> anyhow::Result<()> { let model = ChatOpenAI::builder() .model("gpt-4o") .api_key_env("OPENAI_API_KEY") .build()?; // Runner, AgentNode, Directive, AgentError etc. come from synwire::agent::prelude Ok(()) }
For publishable extension crates (custom backends, providers, or strategies), depend on synwire-core only. This avoids pulling in concrete implementations your users may not need:
[dependencies]
# Publishable extension crate: traits only, no implementations
synwire-core = "0.1"
#![allow(unused)] fn main() { use synwire_core::language_models::chat::BaseChatModel; // A custom provider that implements BaseChatModel from synwire-core. // Downstream applications can mix it with any backend or strategy // from synwire-agent without a version coupling. pub struct MyCustomChatModel { // model configuration } }
The rule of thumb: applications use synwire; libraries use synwire-core.
Hooks vs Callbacks
Synwire provides two mechanisms for observability: callbacks (the CallbackHandler trait) and tracing (OpenTelemetry integration). This document explains when to use each.
CallbackHandler (callbacks)
The CallbackHandler trait provides structured event hooks:
on_llm_start/on_llm_endon_tool_start/on_tool_end/on_tool_erroron_chain_start/on_chain_endon_retry
When to use callbacks
- Custom metrics collection (latency, token counts, cost tracking)
- Logging specific events (tool calls, retries)
- Audit trails (recording all model interactions)
- UI integration (progress indicators, streaming displays)
Characteristics
- All methods are async (
BoxFuture) - Default no-op implementations -- override only what you need
- Filtering via
ignore_tool()andignore_llm() - Attached per-invocation via
RunnableConfig
Tracing (OpenTelemetry)
Enabled via the tracing feature flag on synwire-core. Produces structured spans and events compatible with OpenTelemetry collectors.
When to use tracing
- Distributed tracing across services
- Integration with existing observability (Jaeger, Datadog, etc.)
- Performance profiling (span timing)
- Structured logging with context propagation
Characteristics
- Zero-cost when disabled (feature flag)
- Automatic span hierarchy
- Context propagation across async boundaries
- Standard ecosystem (
tracing+tracing-opentelemetry)
Decision tree
graph TD
A[Need observability?] -->|Yes| B{What kind?}
B -->|Per-event hooks| C[Use CallbackHandler]
B -->|Distributed tracing| D[Use tracing feature]
B -->|Both| E[Use both]
A -->|No| F[Neither needed]
C --> G[Attach via RunnableConfig]
D --> H[Enable tracing feature flag]
E --> G
E --> H
Using both together
Callbacks and tracing are complementary. Use tracing for distributed observability and callbacks for application-specific logic:
// Enable tracing feature in Cargo.toml
// synwire-core = { version = "0.1", features = ["tracing"] }
// Custom callback for business logic
struct CostTracker;
impl CallbackHandler for CostTracker {
fn on_llm_end<'a>(
&'a self,
response: &'a serde_json::Value,
) -> BoxFuture<'a, ()> {
Box::pin(async move {
// Track cost from usage metadata
})
}
}
synwire: The Umbrella Crate
synwire is Synwire's convenience re-export crate. It aggregates commonly used types from across the workspace into a single dependency, so application authors can write synwire = "0.1" in their Cargo.toml and get started without tracking individual crate versions.
When to use synwire
Use synwire when you are building an end-user application --- a CLI agent, a web service that calls LLMs, a RAG pipeline. It re-exports synwire-core and provides ready-made implementations for patterns that most applications need.
If you are writing a library crate (a custom vector store, an LLM provider, an embedding backend), depend on synwire-core instead. This keeps your dependency footprint minimal and avoids pulling in implementations your users may not need.
What it provides
Beyond re-exporting synwire_core as core, the crate ships several modules of its own:
| Module | Purpose |
|---|---|
agent::prelude | Glob-importable set of agent types: Agent, AgentNode, Runner, Directive, Session, AgentEvent, Usage |
cache | Moka-backed embedding cache --- wraps any Embeddings impl and deduplicates repeated queries |
chat_history | Chat message history traits and implementations for managing conversation context windows |
prompts | Few-shot prompt templates and example selectors |
text_splitters | Text splitter implementations for chunking documents before embedding |
output_parsers | Additional output parsers beyond those in synwire-core |
Conditional modules
Several heavyweight integrations are gated behind feature flags so they impose zero cost when unused:
| Module | Feature | Re-exports |
|---|---|---|
sandbox | sandbox | synwire-sandbox --- process isolation, SandboxedAgent::with_sandbox(), ProcessPlugin |
lsp | lsp | synwire-lsp --- LspPlugin, LanguageServerRegistry, go-to-definition, hover, diagnostics |
dap | dap | synwire-dap --- DapPlugin, DebugAdapterRegistry, breakpoints, stepping, variable inspection |
Agent prelude
The agent::prelude module is designed for glob import:
#![allow(unused)] fn main() { use synwire::agent::prelude::*; // Now you have Agent, AgentNode, Runner, Directive, DirectiveResult, // AgentError, Session, SessionManager, AgentEvent, Usage, etc. }
This avoids long import lists when writing agent application code while keeping the individual types traceable (they all originate in synwire-core::agents).
Feature flags
| Flag | Enables |
|---|---|
openai | synwire-llm-openai (not re-exported as a module, but available as a dependency) |
ollama | synwire-llm-ollama |
sandbox | synwire-sandbox + the sandbox module |
lsp | synwire-lsp + the lsp module |
dap | synwire-dap + the dap module |
No features are enabled by default. A minimal synwire dependency pulls in only synwire-core, moka, serde, serde_json, regex, and tokio.
Dependencies
| Crate | Role |
|---|---|
synwire-core | Always present --- trait definitions and shared types |
moka | Concurrent cache for the embedding cache module |
serde / serde_json | Serialization for prompt templates and output parsers |
regex | Text splitter pattern matching |
tokio | Async runtime |
tracing | Observability |
Ecosystem position
Application code
|
v
synwire (re-exports + utilities)
|
+-- synwire-core (traits, shared types)
+-- synwire-llm-openai (optional)
+-- synwire-llm-ollama (optional)
+-- synwire-sandbox (optional)
+-- synwire-lsp (optional)
+-- synwire-dap (optional)
synwire sits at the top of the dependency graph. It is the recommended entry point for applications but is never depended on by other workspace crates.
See also
- synwire-core: Trait Contract Layer --- the trait definitions that
synwirere-exports - synwire-agent: Agent Runtime --- concrete agent runtime implementations
- Feature Flags --- full feature flag reference
synwire-core: The Trait Contract Layer
synwire-core is Synwire's foundational crate. It defines every public trait and shared type but ships zero concrete implementations. If you are writing something that others plug into Synwire, this is the only crate you need.
📖 Rust note: A trait is Rust's equivalent of an interface — it defines a set of methods a type must implement.
impl Traitin a function argument accepts any type that satisfies it;dyn Traitallows holding different implementing types behind a pointer at runtime.
When to depend on synwire-core directly
Depend on synwire-core (not on synwire or provider crates) when:
- You are publishing a third-party extension crate — a custom
VectorStore, a bespokeSessionManager, a new LLM provider — and you want users to be able to depend on your crate without pulling insynwire-agentor any provider - You want to write application code that is provider-agnostic — store the model as
Box<dyn BaseChatModel>and swap implementations in tests vs production - You are writing integration tests that should compile without any concrete provider dependencies
If you are building an end-user application, use synwire (the umbrella crate with re-exports) or a provider crate directly — you rarely need to import synwire-core explicitly.
Trait hierarchy
#![allow(unused)] fn main() { use synwire_core::language_models::chat::BaseChatModel; use synwire_core::embeddings::Embeddings; use synwire_core::tools::Tool; use synwire_core::agents::{AgentNode, ExecutionStrategy, Vfs, Middleware, Plugin, SessionManager}; // Store any chat model implementation behind a trait object: fn build_pipeline(model: Box<dyn BaseChatModel>) { // model.invoke(...), model.stream(...), model.bind_tools(...) } }
| Trait | Purpose | Implemented in |
|---|---|---|
BaseChatModel | Chat completions: invoke, batch, stream, model_type, bind_tools | synwire-llm-openai, synwire-llm-ollama, FakeChatModel |
BaseLLM | Text-completion (string in, string out) | Provider crates |
Embeddings | embed_documents, embed_query | synwire-llm-openai, synwire-llm-ollama |
VectorStore | Document storage with similarity search | User-implemented or third-party |
Tool / StructuredTool | Callable tools with JSON Schema | Any #[tool] function, user-implemented |
RunnableCore | Universal composition via serde_json::Value I/O | All runnables |
OutputParser | Typed output parsing from model responses | synwire umbrella crate |
DocumentLoader | Async document ingestion | User-implemented or third-party |
AgentNode | Agent turn logic returning DirectiveResult | User-implemented |
ExecutionStrategy | Controls how the runner sequences turns | DirectStrategy, FsmStrategy in synwire-agent |
Vfs | File, shell, HTTP, and process operations as effects | All backends in synwire-agent |
Middleware | Applied before each agent turn | All middleware in synwire-agent |
Plugin | Stateful component with lifecycle hooks | User-implemented |
SessionManager | Session CRUD: create, get, update, delete, list, fork, rewind, tag | InMemorySessionManager in synwire-agent |
McpTransport | MCP protocol transport | stdio/HTTP/in-process variants in synwire-agent |
Key shared types
BoxFuture<'a, T>—Pin<Box<dyn Future<Output = T> + Send + 'a>>; used by all async trait methods
📖 Rust note:
BoxFuture<'_, T>is shorthand forPin<Box<dyn Future<Output = T> + Send + '_>>. Trait methods can't useasync fndirectly and remain object-safe, so Synwire returns heap-allocated, pinned futures instead. You interact with these via.awaitexactly like any other future.
BoxStream<'a, T>—Pin<Box<dyn Stream<Item = T> + Send + 'a>>; used by streaming methodsMessage/MessageContent/ContentBlock— chat message typesChatResult/ChatChunk— invoke and stream response typesToolSchema/ToolOutput/ToolCall— tool interface typesDocument— a text chunk with metadata, used by loaders and vector storesSynwireError— top-level library error; all public APIs returnResult<T, SynwireError>Directive— an intended effect returned byAgentNode::processDirectiveResult<S>—Result<AgentEvent, AgentError>
Implementing a custom BaseChatModel
#![allow(unused)] fn main() { use synwire_core::language_models::chat::{BaseChatModel, ChatResult, ChatChunk}; use synwire_core::{BoxFuture, BoxStream, SynwireError}; struct MyModel; impl BaseChatModel for MyModel { fn model_type(&self) -> &str { "my-model" } fn invoke<'a>(&'a self, input: &'a str) -> BoxFuture<'a, Result<ChatResult, SynwireError>> { Box::pin(async move { Ok(ChatResult { content: format!("Echo: {input}"), ..Default::default() }) }) } fn stream<'a>(&'a self, input: &'a str) -> BoxFuture<'a, Result<BoxStream<'a, Result<ChatChunk, SynwireError>>, SynwireError>> { // ... stream implementation todo!() } } }
Feature flags
| Flag | Enables |
|---|---|
retry | reqwest-retry middleware for automatic retries on transient HTTP errors |
http | reqwest HTTP client (needed by provider crates) |
tracing | tracing spans on all async operations |
event-bus | Internal event bus for cross-component messaging |
batch-api | Batch request support for providers that offer it |
See also
- Architecture — trait hierarchy in context
- Crate Architecture and Layer Boundaries — where each trait lives
- Agent Runtime (
synwire-agent) — concrete implementations of these traits
synwire-orchestrator: Graph-Based Multi-Node Workflows
synwire-orchestrator provides StateGraph<S> — a stateful, compiled graph that runs using a Pregel superstep execution model. Use it when your application has multiple distinct processing components that exchange state.
Background: AI Workflows vs AI Agents — the Prompt Engineering Guide explains when structured workflows outperform autonomous agents.
StateGraphimplements the workflow end of this spectrum.
When to use StateGraph
Use StateGraph when you have ≥ 2 distinct roles in your application that process and pass data to each other:
- LLM call → tool execution → validator → response formatter
- Query classifier → retriever → re-ranker → answer generator
- Draft generator → critic → rewriter → publisher
If you have a single agent with complex internal turn logic, use FsmStrategy in synwire-agent instead. See StateGraph vs FsmStrategy.
Building a graph
#![allow(unused)] fn main() { use synwire_derive::State; use synwire_orchestrator::graph::StateGraph; use synwire_orchestrator::constants::END; use synwire_orchestrator::func::sync_node; use serde::{Serialize, Deserialize}; #[derive(State, Clone, Debug, Default, Serialize, Deserialize)] struct RagState { #[reducer(last_value)] query: String, #[reducer(topic)] context_docs: Vec<String>, #[reducer(last_value)] answer: String, } let mut graph = StateGraph::<RagState>::new(); graph.add_node("retrieve", sync_node(|mut s: RagState| { // fetch documents matching s.query s.context_docs.push("Rust ownership means one owner at a time.".to_string()); Ok(s) }))?; graph.add_node("generate", sync_node(|mut s: RagState| { s.answer = format!("Given: {:?}\nAnswer: …", s.context_docs); Ok(s) }))?; graph.set_entry_point("retrieve") .add_edge("retrieve", "generate") .add_edge("generate", END); let compiled = graph.compile()?; let result = compiled.invoke(RagState { query: "ownership".into(), ..Default::default() }, None).await?; println!("{}", result.answer); }
Conditional routing
add_conditional_edges routes to different nodes based on the current state:
#![allow(unused)] fn main() { use synwire_orchestrator::constants::{END}; // After "classify", route to "tool_node" or directly to END: // graph.add_conditional_edges( // "classify", // |state: &MyState| -> &str { // if state.needs_tool { "tool_node" } else { END } // }, // vec!["tool_node", END], // ); }
Channels: controlling state merging
Each field in a State struct has a channel type that determines how writes from concurrent nodes are merged:
📖 Rust note: The
#[derive(State)]macro (fromsynwire-derive) reads the#[reducer(...)]attribute on each field and generates theStatetrait implementation, includingchannels()which returns the channel type for each field.
| Channel | Attribute | Behaviour | Use when |
|---|---|---|---|
LastValue | #[reducer(last_value)] or omitted | Overwrites on each write | Current node name, flags, scalars |
Topic | #[reducer(topic)] | Appends; accumulates across steps | Message history, event logs |
Ephemeral | #[reducer(ephemeral)] | Cleared after each superstep | Per-step scratch data |
BinaryOperator | manual impl State | Custom reducer function | Counters, set union, custom merges |
NamedBarrier | manual impl State | Fan-in: waits for all named producers | Synchronising parallel branches |
AnyValue | N/A | Accepts any JSON value | Dynamic / schema-less fields |
Checkpointing
Wire a checkpoint saver to make runs resumable:
#![allow(unused)] fn main() { use synwire_checkpoint::InMemoryCheckpointSaver; use synwire_checkpoint::CheckpointConfig; use std::sync::Arc; let saver = Arc::new(InMemoryCheckpointSaver::new()); let graph = compiled.with_checkpoint_saver(saver); // First run — thread "session-1" is snapshotted after each superstep let config = CheckpointConfig::new("session-1"); graph.invoke(RagState::default(), Some(config.clone())).await?; // Resume from the last checkpoint — same config, new invoke graph.invoke(RagState::default(), Some(config)).await?; }
For persistence across process restarts, swap in SqliteSaver. See Checkpointing Tutorial and synwire-checkpoint-sqlite.
Schema-less state with ValueState
If you don't want a typed state struct, use ValueState — a wrapper around serde_json::Value:
#![allow(unused)] fn main() { use synwire_orchestrator::graph::{StateGraph, ValueState}; let mut graph = StateGraph::<ValueState>::new(); // nodes receive and return ValueState; access fields via .0["field_name"] }
See also
- StateGraph vs FsmStrategy — when to use which
- Pregel Execution Model — superstep mechanics
- Channel System — channel types in depth
- Checkpointing Explanation
- Graph Agent Getting Started
synwire-checkpoint: Persistence and Resumability
synwire-checkpoint provides two persistence mechanisms:
BaseCheckpointSaver— snapshotsStateGraphruns so they can be resumed, forked, or rewoundBaseStore— general key-value storage for agent state that must outlive a single turn
BaseCheckpointSaver — graph snapshots
Every time a CompiledGraph completes a superstep, it can save a Checkpoint containing the full channel state. The next invoke call with the same thread_id loads the latest checkpoint and resumes from there.
#![allow(unused)] fn main() { use synwire_checkpoint::{BaseCheckpointSaver, InMemoryCheckpointSaver, CheckpointConfig}; use std::sync::Arc; // In-memory — zero config, process-lifetime only let saver: Arc<dyn BaseCheckpointSaver> = Arc::new(InMemoryCheckpointSaver::new()); // Namespace runs by thread_id let config = CheckpointConfig::new("user-session-42"); // Run 1 — state is saved after each superstep // compiled.with_checkpoint_saver(saver.clone()).invoke(state, Some(config.clone())).await?; // Run 2 — resumes from the last saved step // compiled.with_checkpoint_saver(saver).invoke(state, Some(config)).await?; }
What a checkpoint contains
CheckpointConfig—thread_id(namespace) + optionalcheckpoint_id(specific snapshot)Checkpoint—id,channel_values(full state),format_versionCheckpointMetadata—source(how it was created),step(superstep number),writes(what changed),parents(for forking)CheckpointTuple— checkpoint + config + metadata + parent config
Forking and rewinding
CheckpointMetadata.parents links each snapshot to its predecessor, forming a tree. To fork from a past checkpoint, pass its checkpoint_id in CheckpointConfig. The graph resumes from that snapshot, creating a new branch.
BaseStore — general K-V persistence
BaseStore is a simpler interface for ad-hoc key-value storage — useful for caching tool results, storing agent memory, or persisting data between sessions without the overhead of full graph snapshotting.
#![allow(unused)] fn main() { use synwire_checkpoint::BaseStore; // Assume `store` implements BaseStore: // store.put("agent:memory:user-42", serde_json::json!({ "name": "Alice" }))?; // let mem = store.get("agent:memory:user-42")?; }
Choosing a saver
| Saver | Crate | Persistence | Use when |
|---|---|---|---|
InMemoryCheckpointSaver | synwire-checkpoint | Process-lifetime | Tests, short workflows |
SqliteSaver | synwire-checkpoint-sqlite | Disk, survives restarts | Single-process production |
Custom BaseCheckpointSaver | Your crate | PostgreSQL, Redis, S3, … | Distributed or multi-process |
Serde protocol
Checkpoints serialise channel values to JSON using JsonPlusSerde. Any custom type stored in a channel must implement serde::Serialize and serde::Deserialize.
#![allow(unused)] fn main() { use serde::{Serialize, Deserialize}; // Any type stored in a StateGraph channel needs these derives: #[derive(Serialize, Deserialize, Clone, Debug)] struct MyChannelValue { content: String, step: u32, } }
When NOT to checkpoint
Checkpointing has a cost: each superstep writes serialised state to storage. Avoid it for:
- Stateless request/response — single-turn LLM calls with no need to resume
- Short-lived workflows — complete in one process lifetime with no user-visible progress state
- High-frequency loops — hundreds of supersteps per second where checkpoint I/O would dominate latency
See also
synwire-checkpoint-sqlite: SQLite Checkpoint Backend
synwire-checkpoint-sqlite provides SqliteSaver, a production-ready BaseCheckpointSaver implementation backed by SQLite. It persists graph execution checkpoints to a local database file with security-conscious defaults and configurable size limits.
Why SQLite?
Checkpointing needs durable, ordered storage with transactional guarantees. SQLite provides all of this in a single file with no external daemon, no network round-trips, and no configuration beyond a file path. For local agent workloads --- where checkpoints are written by one process and read back by the same process or a restart of it --- SQLite is the simplest backend that is also correct.
WAL (Write-Ahead Logging) mode is used for concurrency. Multiple readers can proceed in parallel with a single writer, which matches the typical checkpoint access pattern: frequent reads during graph traversal, occasional writes at step boundaries.
Key type: SqliteSaver
#![allow(unused)] fn main() { use std::path::Path; use synwire_checkpoint_sqlite::saver::SqliteSaver; // Default: 16 MiB max checkpoint size let saver = SqliteSaver::new(Path::new("/tmp/checkpoints.db"))?; // Custom size limit let saver = SqliteSaver::with_max_size(Path::new("/tmp/checkpoints.db"), 4 * 1024 * 1024)?; }
SqliteSaver implements BaseCheckpointSaver with three operations:
| Method | Behaviour |
|---|---|
put | Serializes the checkpoint to JSON, enforces max_checkpoint_size, records the parent chain, and inserts via INSERT OR REPLACE |
get_tuple | Retrieves the latest checkpoint for a thread (or a specific checkpoint by ID) |
list | Returns checkpoints for a thread in reverse chronological order, with optional limit |
Security: file permissions
On Unix systems, SqliteSaver::new creates the database file with mode 0600 (owner read/write only) before handing it to SQLite. This prevents other users on a shared machine from reading checkpoint data, which may contain agent conversation history, tool call results, or application state.
Size limits
The max_checkpoint_size parameter (default 16 MiB) is enforced on every put. If a serialized checkpoint exceeds the limit, put returns CheckpointError::StateTooLarge without writing to the database. This prevents runaway state growth from consuming disk space, which can happen when an agent accumulates large tool outputs across many steps.
Connection pooling
SqliteSaver uses r2d2 with r2d2_sqlite for connection pooling. The pool is configured with a maximum of 4 connections, which is sufficient for the single-writer/multiple-reader pattern. The pool is wrapped in an Arc so SqliteSaver is cheaply cloneable.
Schema
The database has a single table:
CREATE TABLE IF NOT EXISTS checkpoints (
thread_id TEXT NOT NULL,
checkpoint_id TEXT NOT NULL,
data BLOB NOT NULL,
metadata TEXT NOT NULL,
parent_checkpoint_id TEXT,
PRIMARY KEY (thread_id, checkpoint_id)
);
Checkpoints are stored as JSON-serialized blobs. Metadata is stored as a separate JSON text column to allow querying without deserializing the full checkpoint.
Dependencies
| Crate | Role |
|---|---|
synwire-checkpoint | BaseCheckpointSaver trait and checkpoint types |
synwire-core | BoxFuture for async trait methods |
rusqlite | SQLite bindings |
r2d2 / r2d2_sqlite | Connection pooling |
serde_json | Checkpoint serialization |
thiserror | Error types |
Ecosystem position
SqliteSaver is the recommended checkpoint backend for single-machine deployments. For distributed systems where multiple processes need to share checkpoint state, a networked backend (Redis, PostgreSQL) would be implemented as a separate crate, using the same BaseCheckpointSaver trait and validated by the same conformance suite.
synwire-checkpoint (trait)
|
+-- synwire-checkpoint-sqlite (this crate: SqliteSaver)
+-- synwire-checkpoint-conformance (test suite)
See also
- synwire-checkpoint: Persistence --- the trait definition
- synwire-checkpoint-conformance --- the conformance test suite
- Add Checkpointing --- how-to guide
- Checkpointing --- tutorial
synwire-checkpoint-conformance: Checkpoint Test Suite
synwire-checkpoint-conformance provides a reusable conformance test suite that validates any BaseCheckpointSaver implementation against the checkpoint protocol specification. If you are writing a custom checkpoint backend --- Redis, PostgreSQL, S3, or anything else --- this crate tells you whether your implementation is correct.
Why a separate crate?
Checkpoint backends are expected to come from both the Synwire workspace and third-party authors. The conformance tests encode the contract that BaseCheckpointSaver must satisfy: ordering guarantees, parent-chain integrity, metadata round-tripping, size limit enforcement, and idempotency. Shipping these tests as a standalone crate means:
- Third-party backends can add
synwire-checkpoint-conformanceas a dev-dependency and run the same test suite that the built-in SQLite backend uses. - The contract is executable. Instead of relying on prose documentation to define correct behaviour, the conformance suite is the specification.
- Regressions are caught early. Any change to checkpoint semantics that breaks the conformance suite is visible across all backends, not just the ones in the workspace.
How to use it
Add the crate as a dev-dependency in your backend crate:
[dev-dependencies]
synwire-checkpoint-conformance = { path = "../synwire-checkpoint-conformance" }
tokio = { version = "1", features = ["full"] }
Then call the conformance runner from a test:
#![allow(unused)] fn main() { use synwire_checkpoint_conformance::run_conformance_tests; #[tokio::test] async fn my_backend_conforms() { let saver = MyCustomSaver::new(/* ... */); run_conformance_tests(&saver).await; } }
The suite exercises all BaseCheckpointSaver methods --- put, get_tuple, list --- with a variety of inputs and validates that the results match the expected protocol behaviour.
What the suite tests
The conformance tests cover the following areas:
| Area | What is validated |
|---|---|
| Basic CRUD | put stores a checkpoint, get_tuple retrieves it, list returns all checkpoints for a thread |
| Ordering | get_tuple with no checkpoint ID returns the most recent checkpoint; list returns checkpoints in reverse chronological order |
| Parent chain | Each put records the previous checkpoint as its parent; parent_config is correctly populated |
| Specific retrieval | get_tuple with a specific checkpoint ID returns exactly that checkpoint |
| Missing data | Querying a non-existent thread returns None, not an error |
| List limits | list with a limit parameter returns at most that many results |
| Metadata round-trip | CheckpointMetadata (source, step, writes, parents) survives serialization and deserialization |
Dependencies
| Crate | Role |
|---|---|
synwire-checkpoint | BaseCheckpointSaver trait and checkpoint types |
synwire-core | BoxFuture and shared error types |
tokio | Async test runtime |
This is a publish = false crate --- it exists only for testing within the workspace and by downstream backends that can reference it via path or git dependency.
Ecosystem position
synwire-checkpoint (trait: BaseCheckpointSaver)
|
+-- synwire-checkpoint-sqlite (impl: SqliteSaver)
| |
| +-- synwire-checkpoint-conformance (tests)
|
+-- your-custom-backend (impl: YourSaver)
|
+-- synwire-checkpoint-conformance (tests)
Every checkpoint backend in the ecosystem should run the conformance suite. The suite is the source of truth for what "correct" means.
See also
- synwire-checkpoint: Persistence --- the trait definition
- synwire-checkpoint-sqlite --- the built-in SQLite backend
- Add Checkpointing --- how-to guide
LLM Providers: Choosing and Swapping
Synwire has two built-in LLM providers: synwire-llm-openai (cloud) and synwire-llm-ollama (local). Both implement BaseChatModel and Embeddings, so you can swap them by changing one line.
Background: Introduction to Agents — the Prompt Engineering Guide covers how LLMs are used as the reasoning core of AI agents. Synwire's provider crates give you that reasoning core.
synwire-llm-openai
Use when:
- You need GPT-4o, o3, o1, or another OpenAI model
- You are using Azure OpenAI (
api_baseoverride) - You need OpenAI-compatible APIs (Groq, Together, Perplexity)
use synwire_llm_openai::ChatOpenAI; use synwire_core::language_models::chat::BaseChatModel; #[tokio::main] async fn main() -> anyhow::Result<()> { let model = ChatOpenAI::builder() .model("gpt-4o") .api_key_env("OPENAI_API_KEY") // reads OPENAI_API_KEY at runtime .max_tokens(1024u16) .temperature(0.7) .build()?; let result = model.invoke("Explain Rust lifetimes in two sentences.").await?; println!("{}", result.content); Ok(()) }
Builder methods: model, api_key, api_key_env, api_base, temperature, max_tokens, top_p, stop, timeout, max_retries, credential_provider
synwire-llm-ollama
Use when:
- All inference must stay on your machine — no data leaves the network boundary
- You are working air-gapped or in a privacy-sensitive environment
- You want zero API costs during development or testing
use synwire_llm_ollama::ChatOllama; use synwire_core::language_models::chat::BaseChatModel; #[tokio::main] async fn main() -> anyhow::Result<()> { // Prerequisites: `ollama pull llama3.2` let model = ChatOllama::builder() .model("llama3.2") .build()?; let result = model.invoke("What is the borrow checker?").await?; println!("{}", result.content); Ok(()) }
Builder methods: model, base_url (default: http://localhost:11434), temperature, top_k, top_p, num_predict, timeout
Swapping providers
Both providers implement BaseChatModel. Store the model as a trait object to swap by changing one line:
📖 Rust note:
Box<dyn Trait>heap-allocates a value and erases its concrete type, keeping only the trait interface. This is how Synwire stores different model implementations interchangeably.
use synwire_core::language_models::chat::BaseChatModel; use synwire_llm_openai::ChatOpenAI; use synwire_llm_ollama::ChatOllama; fn build_model(use_local: bool) -> Box<dyn BaseChatModel> { if use_local { Box::new(ChatOllama::builder().model("llama3.2").build().unwrap()) } else { Box::new(ChatOpenAI::builder().model("gpt-4o").api_key_env("OPENAI_API_KEY").build().unwrap()) } } #[tokio::main] async fn main() -> anyhow::Result<()> { let model = build_model(std::env::var("LOCAL").is_ok()); let result = model.invoke("Hello").await?; println!("{}", result.content); Ok(()) }
Embeddings
Both providers also implement Embeddings:
#![allow(unused)] fn main() { use synwire_core::embeddings::Embeddings; use synwire_llm_openai::OpenAIEmbeddings; use synwire_llm_ollama::OllamaEmbeddings; // OpenAI embeddings let openai_emb = OpenAIEmbeddings::builder() .model("text-embedding-3-small") .api_key_env("OPENAI_API_KEY") .build()?; // Ollama embeddings (local, no API key) let ollama_emb = OllamaEmbeddings::builder() .model("nomic-embed-text") .build()?; // Both implement the same trait let vectors = openai_emb.embed_query("Rust ownership").await?; }
Credential management
Never store API keys in plain String fields. Use api_key_env to read from the environment at runtime, or credential_provider for vault / secrets manager integration:
#![allow(unused)] fn main() { use synwire_llm_openai::ChatOpenAI; let model = ChatOpenAI::builder() .model("gpt-4o") .credential_provider(|| { // Read from HashiCorp Vault, AWS Secrets Manager, etc. std::env::var("OPENAI_API_KEY").map_err(Into::into) }) .build()?; }
Keys are wrapped in secrecy::Secret<String> internally — they are never printed in logs or debug output.
Implementing your own provider
Implement BaseChatModel in terms of synwire-core types only:
#![allow(unused)] fn main() { use synwire_core::language_models::chat::{BaseChatModel, ChatResult, ChatChunk}; use synwire_core::{BoxFuture, BoxStream, SynwireError}; struct MyProvider { api_url: String } impl BaseChatModel for MyProvider { fn model_type(&self) -> &str { "my-provider" } fn invoke<'a>(&'a self, input: &'a str) -> BoxFuture<'a, Result<ChatResult, SynwireError>> { Box::pin(async move { // call self.api_url, parse response Ok(ChatResult { content: "response".to_string(), ..Default::default() }) }) } fn stream<'a>(&'a self, _input: &'a str) -> BoxFuture<'a, Result<BoxStream<'a, Result<ChatChunk, SynwireError>>, SynwireError>> { todo!() } } }
In tests, use FakeChatModel from synwire-test-utils instead of a real provider — it is deterministic and requires no network.
See also
synwire-llm-openai: OpenAI LLM Provider
synwire-llm-openai provides ChatOpenAI, OpenAIEmbeddings, and OpenAIModerationMiddleware, connecting Synwire to the OpenAI API (and any OpenAI-compatible endpoint) for chat completions, embeddings, and content moderation.
For a comparison of all LLM providers, see LLM Providers. This document focuses specifically on the OpenAI implementation.
Architecture: BaseChatOpenAI
The crate is structured around a shared base type, BaseChatOpenAI, which holds all configuration common to OpenAI-compatible providers: model name, API base URL, API key, temperature, max tokens, timeout, retry settings, and HTTP client. ChatOpenAI wraps this base and adds the tools vector for function calling.
This separation exists because several third-party services (Azure OpenAI, Together AI, Groq, local vLLM) expose OpenAI-compatible APIs. By extracting the common configuration into BaseChatOpenAI, future provider crates can reuse it without duplicating HTTP, retry, and credential logic.
ChatOpenAI
Implements BaseChatModel from synwire-core.
#![allow(unused)] fn main() { use synwire_llm_openai::ChatOpenAI; let model = ChatOpenAI::builder() .model("gpt-4o") .api_key("sk-...") .build() .unwrap(); }
Builder options
| Option | Default | Description |
|---|---|---|
model | "gpt-4o" | Model identifier |
api_key | "" | OpenAI API key (or set OPENAI_API_KEY env var) |
api_base | "https://api.openai.com/v1" | API base URL (override for compatible endpoints) |
temperature | None | Sampling temperature |
max_tokens | None | Maximum tokens to generate |
top_p | None | Nucleus sampling parameter |
stop | None | Stop sequences |
timeout | 30 seconds | Request timeout |
max_retries | 3 | Automatic retries on transient errors |
model_kwargs | {} | Additional JSON parameters passed through to the API |
credential_provider | None | Dynamic credential refresh for rotating keys |
Streaming
Streaming uses Server-Sent Events (SSE) via the eventsource-stream crate. Each SSE event is parsed into a ChatChunk and yielded through a BoxStream. The stream handles [DONE] sentinel events and partial tool call assembly across chunks.
Tool calling
When tools are bound via bind_tools, ChatOpenAI includes tools and tool_choice in the API request. Tool call responses are parsed from the tool_calls array in the response message, with ToolCallChunk types handling the streaming case where a single tool call spans multiple SSE events.
Retry middleware
The crate uses reqwest-middleware with reqwest-retry for automatic retries on transient HTTP errors (429, 500, 502, 503, 504). The retry policy respects Retry-After headers from the OpenAI API, which is important for rate limit compliance.
OpenAIEmbeddings
Implements Embeddings from synwire-core.
#![allow(unused)] fn main() { use synwire_llm_openai::OpenAIEmbeddings; let embeddings = OpenAIEmbeddings::builder() .model("text-embedding-3-small") .api_key("sk-...") .build() .unwrap(); }
Supports both embed_documents (batch) and embed_query (single). Batching is handled transparently by the OpenAI API.
OpenAIModerationMiddleware
A RunnableCore implementation that checks input text against the OpenAI Moderation API before passing it downstream. Rejects content flagged as harmful, preventing it from reaching the chat model.
#![allow(unused)] fn main() { use synwire_llm_openai::moderation::OpenAIModerationMiddleware; let middleware = OpenAIModerationMiddleware::new( "https://api.openai.com/v1", "sk-...", ); }
Error handling
OpenAIError covers HTTP errors, deserialization failures, rate limits, authentication failures, and configuration errors. It converts to SynwireError via From.
Dependencies
| Crate | Role |
|---|---|
synwire-core | BaseChatModel, Embeddings, RunnableCore traits (with http feature) |
reqwest | HTTP client (rustls backend) |
reqwest-middleware / reqwest-retry | Automatic retry on transient errors |
eventsource-stream | SSE parsing for streaming responses |
futures-core / futures-util | Stream processing |
serde / serde_json | Request/response serialization |
thiserror | Error type derivation |
Ecosystem position
synwire-llm-openai is a leaf crate. It implements traits from synwire-core and is optionally re-exported by the synwire umbrella crate behind the openai feature flag. The BaseChatOpenAI base type is designed for reuse by future OpenAI-compatible provider crates.
See also
- First Chat --- getting started with OpenAI
- LLM Providers --- comparison of all providers
- Credentials --- credential management
- Retry and Fallback --- retry configuration
synwire-llm-ollama: Ollama LLM Provider
synwire-llm-ollama provides ChatOllama and OllamaEmbeddings, connecting Synwire to a local (or remote) Ollama server for LLM inference and text embeddings without requiring cloud API keys.
For a comparison of all LLM providers, see LLM Providers. This document focuses specifically on the Ollama implementation.
Why Ollama?
Ollama wraps llama.cpp and other inference backends behind a simple HTTP API, handling model downloading, quantization selection, and GPU scheduling automatically. For Synwire users, this means:
- Zero cloud dependencies. Run agents entirely on local hardware.
- Model flexibility. Switch between Llama, Mistral, Gemma, Phi, and other model families by changing a string.
- Privacy. No data leaves the machine.
The trade-off is that local inference requires sufficient hardware (GPU recommended) and model quality depends on the chosen model and quantization level.
ChatOllama
Implements BaseChatModel from synwire-core. Communicates with Ollama's /api/chat endpoint.
#![allow(unused)] fn main() { use synwire_llm_ollama::ChatOllama; let model = ChatOllama::builder() .model("llama3.2") .base_url("http://localhost:11434") .temperature(0.7) .build() .unwrap(); }
Builder options
| Option | Default | Description |
|---|---|---|
model | "llama3.2" | Ollama model name |
base_url | "http://localhost:11434" | Ollama server URL |
temperature | None | Sampling temperature |
top_k | None | Top-k sampling parameter |
top_p | None | Nucleus sampling parameter |
num_predict | None | Maximum tokens to generate |
timeout | 120 seconds | Request timeout |
credential_provider | None | Dynamic credential refresh (for authenticated Ollama proxies) |
Streaming
ChatOllama supports both non-streaming (invoke) and streaming (stream) modes. Streaming uses Ollama's NDJSON (newline-delimited JSON) response format, where each line is a partial response object. The stream is parsed incrementally via futures-util.
Tool calling
When tools are bound via bind_tools, ChatOllama includes tool definitions in the request payload. Ollama models that support function calling (e.g. Llama 3.2, Mistral) return ToolCall objects in the response, which the agent runtime can dispatch to registered tools.
OllamaEmbeddings
Implements Embeddings from synwire-core. Communicates with Ollama's /api/embed endpoint.
#![allow(unused)] fn main() { use synwire_llm_ollama::OllamaEmbeddings; let embeddings = OllamaEmbeddings::builder() .model("nomic-embed-text") .build() .unwrap(); }
Supports both embed_documents (batch) and embed_query (single document). The embedding model must be pulled separately in Ollama (ollama pull nomic-embed-text).
Error handling
All errors are surfaced as OllamaError, which wraps:
- HTTP errors --- connection refused, timeout, non-2xx status
- Deserialization errors --- unexpected response format from the Ollama API
- Configuration errors --- invalid builder parameters
OllamaError converts to SynwireError via From, so it integrates cleanly with the rest of the Synwire error hierarchy.
Dependencies
| Crate | Role |
|---|---|
synwire-core | BaseChatModel, Embeddings traits (with http feature) |
reqwest | HTTP client (rustls backend) |
futures-core / futures-util | Stream processing for NDJSON responses |
serde / serde_json | Request/response serialization |
thiserror | Error type derivation |
Ecosystem position
synwire-llm-ollama is a leaf crate --- nothing else in the workspace depends on it. It implements traits from synwire-core and is optionally re-exported by the synwire umbrella crate behind the ollama feature flag.
See also
- Local Inference with Ollama --- getting started guide
- LLM Providers --- comparison of all providers
- Switch Provider --- how to swap between OpenAI and Ollama
synwire-derive: Proc-Macros and When to Use Them
synwire-derive provides two proc-macros that eliminate boilerplate for the most common patterns: #[tool] for defining tools and #[derive(State)] for typed graph state.
📖 Rust note: The
#[derive]attribute and attribute macros like#[tool]are procedural macros — Rust code that runs at compile time, reads your source code as input, and outputs new source code. They are zero-cost: the generated code is identical to what you would write by hand.
#[tool]: Defining tools from async functions
Apply #[tool] to an async fn to generate a StructuredTool with an automatically-derived JSON Schema.
#![allow(unused)] fn main() { use synwire_derive::tool; use schemars::JsonSchema; use serde::Deserialize; /// Calculate the area of a rectangle. /// The tool description is taken from this doc comment. #[tool] async fn rectangle_area(input: RectangleInput) -> anyhow::Result<String> { let area = input.width * input.height; Ok(format!("{area} square units")) } #[derive(Deserialize, JsonSchema)] struct RectangleInput { /// Width of the rectangle in units. width: f64, /// Height of the rectangle in units. height: f64, /// Unit label (optional, defaults to "m"). unit: Option<String>, } // The macro generates rectangle_area_tool(): // let tool = rectangle_area_tool()?; // let result = tool.call(serde_json::json!({ "width": 5.0, "height": 3.0 })).await?; // assert_eq!(result.text(), "15 square units"); }
How the schema is derived
The macro calls schemars::JsonSchema on the input type. This means:
String/&str→"string"- Integer types →
"integer" - Float types →
"number" bool→"boolean"Vec<T>→"array"Option<T>→ field is marked not required- Structs →
"object"with properties #[schemars(description = "...")]attribute → field description in schema#[serde(rename = "...")]attribute → renamed key in schema
#[derive(State)]: Typed graph state
Apply #[derive(State)] to a struct to generate the State trait implementation for StateGraph.
#![allow(unused)] fn main() { use synwire_derive::State; use serde::{Serialize, Deserialize}; #[derive(State, Clone, Debug, Default, Serialize, Deserialize)] struct ConversationState { /// Message history — Topic channel appends each new message. #[reducer(topic)] messages: Vec<String>, /// Current processing step — LastValue channel overwrites each update. #[reducer(last_value)] current_step: String, /// Fields with no attribute default to LastValue. response_count: u32, } }
📖 Rust note: Generic type parameters like
<S>inStateGraph<S>let the graph work with anyState-implementing type while retaining type safety. The#[derive(State)]macro generates the implementation for your specific struct.
Field attributes and their channels
| Attribute | Channel | Behaviour |
|---|---|---|
#[reducer(topic)] | Topic | Appends; accumulates each update (message history, event logs) |
#[reducer(last_value)] | LastValue | Overwrites on each write (default; use for current node, flags) |
| (none) | LastValue | Defaults to LastValue |
When to use macros vs manual implementation
| Use macros | Use manual impl |
|---|---|
| Tool parameters map cleanly to a Rust struct | Tool schema is dynamic or variadic |
State fields have clear LastValue or Topic semantics | State needs BinaryOperator or NamedBarrier channels |
| Proc-macro error messages are clear enough | You need better diagnostics during early development |
| 90% of cases | Complex edge cases |
Dependency requirement
Your parameter types must implement schemars::JsonSchema. Add to Cargo.toml:
[dependencies]
schemars = { version = "0.8", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
See also
synwire-test-utils: Testing Synwire Applications
synwire-test-utils provides everything you need to test Synwire-based code without network access, real LLM costs, or side effects.
Important: Always add this crate to
[dev-dependencies], never[dependencies].
FakeChatModel: deterministic responses
The simplest way to test any code that takes a BaseChatModel:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use synwire_test_utils::FakeChatModel; use synwire_core::language_models::chat::BaseChatModel; #[tokio::test] async fn greeting_pipeline_formats_response() { // Responses are returned in order; last one repeats if the list is exhausted let model = FakeChatModel::new(vec![ "Hello, Alice!".to_string(), "Hello, Bob!".to_string(), ]); let r1 = model.invoke("greet Alice").await.unwrap(); assert_eq!(r1.content, "Hello, Alice!"); let r2 = model.invoke("greet Bob").await.unwrap(); assert_eq!(r2.content, "Hello, Bob!"); } } }
Inject errors at specific positions:
#![allow(unused)] fn main() { use synwire_test_utils::FakeChatModel; use synwire_core::SynwireError; let model = FakeChatModel::new(vec!["ok".to_string()]) .with_error_at(0, SynwireError::RateLimit("retry after 1s".to_string())); // model.invoke(...) on call 0 → Err(RateLimit) }
RecordingExecutor: assert agent intent
RecordingExecutor captures Directive values without executing them. This is the canonical way to test the directive/effect pattern:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use synwire_test_utils::executors::RecordingExecutor; use synwire_core::agents::directive::Directive; #[tokio::test] async fn planner_node_emits_run_instruction() { let executor = RecordingExecutor::new(); // run your AgentNode::process with executor as the DirectiveExecutor // ... let directives = executor.recorded(); assert!( directives.iter().any(|d| matches!(d, Directive::RunInstruction { .. })), "planner must emit at least one RunInstruction" ); } } }
This test validates intent (the directive type emitted) without triggering filesystem operations, HTTP calls, or any side effect. Tests remain deterministic regardless of environment.
Proptest strategies
Write property-based tests over Synwire types:
📖 Rust note: Property-based testing generates random inputs from a strategy and checks that a property holds for all of them.
proptest!is a macro that runs many randomised trials automatically.
#![allow(unused)] fn main() { use synwire_test_utils::strategies::arb_message; use proptest::prelude::*; proptest! { #[test] fn message_serialises_and_deserialises(msg in arb_message()) { let json = serde_json::to_string(&msg).unwrap(); let recovered: synwire_core::messages::Message = serde_json::from_str(&json).unwrap(); prop_assert_eq!(msg.role, recovered.role); } } }
Available strategies (all in synwire_test_utils::strategies):
| Strategy | Produces |
|---|---|
arb_message() | Message |
arb_tool_schema() | ToolSchema |
arb_directive() | Directive |
arb_document() | Document |
arb_checkpoint() | Checkpoint |
Fixture builders
Concisely construct test data without filling in every field:
#![allow(unused)] fn main() { use synwire_test_utils::builders::{MessageBuilder, DocumentBuilder}; let msg = MessageBuilder::new() .role("user") .content("What is ownership?") .build(); let doc = DocumentBuilder::new() .content("Ownership is Rust's memory management strategy.") .metadata("source", "rust-book") .build(); }
Backend conformance suite
If you implement Vfs, run the conformance suite to verify correctness:
#![allow(unused)] fn main() { #[tokio::test] async fn my_backend_satisfies_contract() { let backend = MyCustomBackend::new("/tmp/test-root"); synwire_test_utils::conformance::run_vfs_conformance(backend).await; } #[tokio::test] async fn my_session_manager_satisfies_contract() { let mgr = MySessionManager::new(); synwire_test_utils::conformance::run_session_conformance(mgr).await; } }
The conformance suite exercises the full Vfs / SessionManager API surface and asserts correctness at each step.
When to use mockall instead
Use mockall's #[automock] when you need:
- Call count assertions — "this method must be called exactly twice"
- Argument matching — "the second argument must equal X"
- Complex call sequences — ordering guarantees across multiple methods
FakeChatModel is simpler but less powerful than a full mock. For most agent tests, FakeChatModel + RecordingExecutor is sufficient.
See also
synwire-agent: The Agent Runtime
synwire-agent is the concrete implementation layer for the Synwire agent runtime. It depends only on synwire-core and provides everything you need to build and run an agentic application: the Runner, execution strategies, backends, middleware, MCP transports, session management, and permissions.
Background: Agent Components — the Prompt Engineering Guide covers memory, tools, and planning as the three components of an agent.
synwire-agentimplements all three: session management (memory),Vfs(tools), andExecutionStrategy(planning).
The Runner: entry point
Runner drives the agent turn loop. It ties together an AgentNode (the decision logic), an ExecutionStrategy (the turn-sequencing logic), a middleware stack, a backend, and a session.
#![allow(unused)] fn main() { use synwire_agent::runner::Runner; use synwire_agent::strategies::DirectStrategy; use synwire_agent::vfs::MemoryProvider; use futures_util::StreamExt; // Assume my_agent implements AgentNode // let runner = Runner::builder() // .agent(my_agent) // .strategy(DirectStrategy::new()) // .backend(MemoryProvider::new()) // .build()?; // // let mut events = runner.run("Hello!".to_string(), Default::default()).await?; // while let Some(event) = events.next().await { // match event? { // AgentEvent::Text { content } => print!("{content}"), // AgentEvent::Done { usage } => println!("\nTokens: {:?}", usage), // _ => {} // } // } }
Execution strategies
Two built-in strategies:
DirectStrategy
The model decides everything. No state machine constrains which directives the agent may emit. Use for open-ended assistants where you want maximum flexibility.
#![allow(unused)] fn main() { use synwire_agent::strategies::DirectStrategy; let strategy = DirectStrategy::new(); }
FsmStrategy
A finite state machine controls the turn sequence. Named states and typed guard conditions — closures over Directive values — determine which transitions fire.
#![allow(unused)] fn main() { use synwire_agent::strategies::{FsmStrategyBuilder, ClosureGuard}; use synwire_core::agents::directive::Directive; let strategy = FsmStrategyBuilder::new() .add_state("idle") .add_state("executing") .set_initial_state("idle") .add_transition( "idle", "executing", ClosureGuard::new(|d| matches!(d, Directive::RunInstruction { .. })), ) .add_transition("executing", "idle", ClosureGuard::always()) .build()?; }
For the design rationale, see FSM Strategy Design. For when to choose FsmStrategy vs StateGraph, see StateGraph vs FsmStrategy.
Backends
All backends implement Vfs. The backend you choose determines the scope and risk of the agent's operations:
#![allow(unused)] fn main() { use synwire_agent::vfs::{ MemoryProvider, // ephemeral in-memory LocalProvider, // scoped to a root path GitBackend, // within a git repo HttpBackend, // external HTTP calls Shell, // sandboxed shell execution }; // Ephemeral backend — safe for testing and sandboxed agents let safe = MemoryProvider::new(); // Filesystem backend — path traversal is blocked by normalize_path() let fs = LocalProvider::new("/workspace"); // Git backend — all operations stay within the repo let git = GitBackend::new("/workspace/.git"); }
| Backend | Scope | Risk level |
|---|---|---|
MemoryProvider | None (ephemeral) | None |
LocalProvider | Rooted path | Low (scoped) |
GitBackend | Git repo boundary | Low (version-controlled) |
HttpBackend | External network | Medium |
Shell | Sandboxed working dir | Medium |
ProcessManager | Any process | High |
CompositeProvider | Mount table | Depends on mounts |
Middleware
Middleware runs before each turn in declaration order. Use middleware for: injecting tools, adding system prompts, rate limiting, or audit logging.
#![allow(unused)] fn main() { use synwire_agent::middleware::{ SystemPromptMiddleware, ToolInjectionMiddleware, AuditLogMiddleware, RateLimitMiddleware, }; // let runner = Runner::builder() // .middleware(SystemPromptMiddleware::new("You are a helpful assistant.")) // .middleware(ToolInjectionMiddleware::new(vec![my_tool])) // .middleware(RateLimitMiddleware::tokens_per_minute(60_000)) // .middleware(AuditLogMiddleware::new(std::io::stderr())) // .build()?; }
Middleware cannot do post-processing — there is no reverse pass. For post-turn processing, use a Plugin with after_run lifecycle hooks.
Permissions and approval gates
Configure what the agent is allowed to do:
#![allow(unused)] fn main() { use synwire_agent::permissions::{PermissionMode, PermissionRule, PermissionBehavior}; use synwire_agent::gates::{ThresholdGate, RiskLevel}; // Preset: block all tool calls by default // let mode = PermissionMode::Restricted; // Custom: require approval for shell commands // let mode = PermissionMode::Custom(vec![ // PermissionRule { // tool_pattern: "shell_*".to_string(), // behavior: PermissionBehavior::Ask, // }, // ]); // Approval gate: trigger human review for HIGH+ risk directives // let gate = ThresholdGate::new(RiskLevel::High, |req| { // println!("Approve directive {:?}? (y/n)", req.directive); // // read stdin, return ApprovalDecision::Allow or Deny // ApprovalDecision::Allow // }); }
MCP integration
Connect external tool servers via the Model Context Protocol:
#![allow(unused)] fn main() { use synwire_agent::mcp::{StdioMcpTransport, McpLifecycleManager}; // Spawn a subprocess MCP server and manage its lifecycle: // let transport = StdioMcpTransport::new("npx", &["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]); // let manager = McpLifecycleManager::new(transport); // manager.connect().await?; // let tools = manager.list_tools().await?; }
Session management
#![allow(unused)] fn main() { use synwire_agent::sessions::InMemorySessionManager; use synwire_core::agents::session::SessionManager; let mgr = InMemorySessionManager::new(); let session = mgr.create("my-agent").await?; // session.id, session.metadata.created_at, session.messages }
See also
- Your First Agent
- Execution Strategies Tutorial
- StateGraph vs FsmStrategy
- Middleware Execution Model
- Plugin State Isolation
- Three-Tier Signal Routing
The Directive/Effect Architecture
Synwire's agent nodes do not execute side effects directly. Instead, they return a description of the effects they want to happen. This pattern — borrowed from the concept of algebraic effects in functional programming — separates the decision of what should happen from the execution of making it happen. Understanding why this separation exists is central to understanding the design of the runtime.
The Problem: Side Effects Make Agents Hard to Test
Consider an agent node that, in response to a user message, needs to spawn a child agent, emit a notification event, and schedule a follow-up action in thirty seconds. The naive implementation calls the relevant APIs directly inside the node function. This works, but it creates a tight coupling between the agent's reasoning logic and the infrastructure that carries out its requests.
Testing such a node requires mocking or stubbing the spawning API, the event bus, and the scheduler. Any test that exercises the decision logic also exercises the infrastructure. In frameworks where effects are expressed as untyped strings or opaque dicts, test code cannot verify that the right kind of effect was requested without fragile string inspection.
The Solution: DirectiveResult<S>
Agent nodes in Synwire return a DirectiveResult<S>, which is a plain data structure containing two fields: the updated agent state and a Vec<Directive>. The node does not call any external API. It describes its intentions as Directive enum values and hands them back to the runtime.
#![allow(unused)] fn main() { pub struct DirectiveResult<S: State> { pub state: S, pub directives: Vec<Directive>, } }
The Directive enum covers the full vocabulary of effects the agent core supports: emitting events, spawning or stopping child agents, scheduling delayed or recurring actions, running instructions, spawning background tasks, and requesting a clean stop. Because this is a Rust enum with named variants, the compiler enforces exhaustive handling. Every match on a Directive must account for every known variant (or explicitly opt into a catch-all). No variant can be silently ignored.
This stands in contrast to Python agent frameworks where action types are strings. A typo in a string-dispatched action silently produces no effect; a missing arm in a Rust match is a compile error.
The Execution Boundary: DirectiveExecutor
The runtime side of this contract is the DirectiveExecutor trait:
#![allow(unused)] fn main() { pub trait DirectiveExecutor: Send + Sync { fn execute_directive( &self, directive: &Directive, ) -> BoxFuture<'_, Result<Option<Value>, DirectiveError>>; } }
DirectiveExecutor implementations decide what to do with a directive. The canonical test implementation is NoOpExecutor, which records that a directive arrived and immediately returns Ok(None). A production implementation might talk to a process supervisor to spawn agents, post to an event bus to emit events, or call a cron service to schedule recurring actions. The agent node itself has no knowledge of which executor is in use.
The flow through the execution boundary looks like this:
sequenceDiagram
participant Runner
participant AgentNode
participant DirectiveFilter
participant DirectiveExecutor
Runner->>AgentNode: call node with state
AgentNode-->>Runner: DirectiveResult { state, directives }
Runner->>Runner: apply updated state
loop for each directive
Runner->>DirectiveFilter: filter(directive)
alt passes filter
DirectiveFilter-->>Runner: Some(directive)
Runner->>DirectiveExecutor: execute_directive(directive)
DirectiveExecutor-->>Runner: Ok(Option<Value>)
else suppressed
DirectiveFilter-->>Runner: None
end
end
Why This Matters for Testing
Because agent nodes return pure data, a unit test for a node looks like this: construct the input state, call the node, inspect the returned DirectiveResult. No mock infrastructure is needed. The test can assert that the directives are exactly what was expected — by variant, by field value, by count. If the node logic changes in a way that produces the wrong directive, the test fails on the assertion, not on an unexpected API call.
The Directive enum derives Serialize and Deserialize, which also means directive lists can be serialised to JSON and stored alongside checkpoints. Replaying an agent run means replaying a sequence of serialised DirectiveResult values — the execution infrastructure can be swapped out without altering the stored record.
The DirectiveFilter: A Safety Layer
Between the node and the executor sits the FilterChain. Each DirectiveFilter receives a directive and returns either Some(directive) (possibly modified) or None (suppressed). Filters are applied in order; if any filter suppresses a directive, it never reaches the executor.
This makes it straightforward to implement safety policies. A filter that suppresses Directive::SpawnAgent when the agent is running in a sandboxed context does not require the agent node to know anything about sandboxing. The filter intercepts at the boundary. Similarly, a filter that transforms a Directive::Schedule to add an audit log entry, or that rejects directives from agents that have exceeded their budget, can be applied globally without touching any node logic.
Extending the Vocabulary: Directive::Custom
The built-in Directive variants cover the common vocabulary. For domain-specific effects that do not belong in the core enum, the Custom variant carries a boxed DirectivePayload:
#![allow(unused)] fn main() { Custom { payload: Box<dyn DirectivePayload>, }, }
The DirectivePayload trait uses typetag::serde for serialisation, which allows custom payload types to round-trip through JSON without the core enum having any knowledge of them. Registering a custom payload type requires implementing DirectivePayload and annotating the implementation with #[typetag::serde]. This is the extension point for application-specific effects.
Trade-offs
The indirection introduced by this pattern has a cost. Rather than calling an API directly, the node constructs a value, the runtime interprets it, and the executor carries it out. For simple agents that always run in the same environment this is additional ceremony. The benefit — testability, replayability, composability — is most visible in complex multi-agent systems where the same node logic runs in production, in tests with NoOpExecutor, and in replay harnesses with a RecordingExecutor.
The other cost is that directives are a fixed vocabulary. An effect that cannot be expressed as a Directive variant or a Custom payload cannot be requested declaratively. This is intentional: it creates a documented, auditable contract between agent logic and the runtime.
See also: For how to implement a custom DirectiveExecutor, see the executor how-to guide. For how DirectiveFilter interacts with sandboxing, see the sandbox reference.
Plugin State Isolation via the Type System
Plugins in Synwire extend the agent runtime with additional capabilities — they can react to user messages, contribute signal routes, and emit directives before and after each run loop iteration. Each plugin naturally needs to maintain its own state across these lifecycle calls. The design question is how multiple plugins share a single state container without accidentally reading or modifying each other's data.
The Problem: Shared State Maps Lead to Collisions
The obvious implementation of a plugin state container is a HashMap<String, Box<dyn Any>>. Any plugin can insert and read by string key. This is simple to implement, but it creates an implicit contract: every plugin must choose a unique key, and the runtime has no way to enforce this. Two plugins that happen to share a key will silently overwrite each other's state. A plugin that reads the wrong key will get back a value that fails the downcast, producing a runtime panic or a silent None.
Beyond correctness, there is an ergonomics problem. Every state access requires a string key and an explicit type downcast. The type system cannot help: a plugin that mistakenly reads another plugin's state with the wrong type will only fail at runtime.
The Solution: PluginStateKey
Synwire sidesteps both problems with the PluginStateKey trait:
#![allow(unused)] fn main() { pub trait PluginStateKey: Send + Sync + 'static { type State: Send + Sync + 'static; const KEY: &'static str; } }
Each plugin defines a zero-sized key type and implements PluginStateKey on it. The associated State type is the plugin's data. The const KEY string is used for serialisation purposes only — it does not govern runtime access.
The key insight is that PluginStateMap stores entries keyed by TypeId, not by the KEY string. TypeId::of::<P>() is globally unique for each distinct Rust type — the compiler guarantees this. Two plugins cannot have colliding TypeId values unless they literally share the same key type, which is a logic error detectable in tests via the register function returning Err.
Type-Safe Access via PluginHandle<P>
When a plugin's state is registered, PluginStateMap::register returns a PluginHandle<P>:
#![allow(unused)] fn main() { pub struct PluginHandle<P: PluginStateKey> { _marker: PhantomData<P>, } }
This is a zero-sized proof token. Holding a PluginHandle<P> proves, at the type level, that the state for plugin P is registered. The handle itself carries no runtime data — PhantomData<P> is erased at compile time.
Access to plugin state is mediated through the map, not through the handle directly. PluginStateMap::get::<P>() returns Option<&P::State> — the type parameter P binds the return type, so reading state with the wrong type is a compile error, not a runtime panic. There is no explicit downcast at the call site; the downcast is encapsulated inside PluginStateMap.
How PluginStateMap Works Internally
The map stores PluginStateMeta values keyed by TypeId:
#![allow(unused)] fn main() { struct PluginStateMeta { value: Box<dyn Any + Send + Sync>, serialize: fn(&dyn Any) -> Option<Value>, key: &'static str, } }
The value field holds the actual plugin state as a type-erased Box<dyn Any>. The serialize function is a monomorphised function pointer generated at registration time, capturing the concrete type P::State. This allows PluginStateMap::serialize_all() to produce a JSON object keyed by the KEY strings — useful for checkpointing and debugging — without the map itself knowing the concrete types at serialisation time.
graph TD
subgraph "PluginStateMap (internal)"
Entry1["TypeId::of::<CounterKey>()<br/>→ PluginStateMeta { value: CounterState, key: 'counter' }"]
Entry2["TypeId::of::<FlagKey>()<br/>→ PluginStateMeta { value: FlagState, key: 'flag' }"]
end
subgraph "Access (type-safe)"
GetCounter["map.get::<CounterKey>() → &CounterState"]
GetFlag["map.get::<FlagKey>() → &FlagState"]
end
Entry1 --> GetCounter
Entry2 --> GetFlag
The TypeId key means that even if CounterKey::KEY and FlagKey::KEY were the same string (a logic error worth fixing in tests), the runtime entries would still be distinct. The KEY string only appears in the serialised output.
PluginInput: Structured Context for Lifecycle Hooks
Each plugin lifecycle hook receives a PluginInput alongside the PluginStateMap:
#![allow(unused)] fn main() { pub struct PluginInput { pub turn: u32, pub message: Option<String>, } }
The PluginInput carries cross-cutting context — which conversation turn this is, and the user message if one is present. The PluginStateMap is passed as a shared reference, so a plugin can read its own state (and, by explicit downcast, inspect other plugins' state as &dyn Any) but the normal path through map.get::<P>() is scoped to the plugin's own type.
The lifecycle hooks return Vec<Directive> — plugins participate in the directive/effect system rather than having their own side-effect channel.
Why Not a Generic Parameter on Agent?
An alternative design would make the set of plugins a compile-time type parameter on Agent, using an HList or tuple-based approach to encode a heterogeneous list of plugins statically. This would give zero-cost access with no type erasure at all.
The practical problem is composability. An Agent<(LoggingPlugin, RateLimitPlugin, AuditPlugin)> is a different type from Agent<(LoggingPlugin, RateLimitPlugin)>, making it difficult to write generic code that works with any agent regardless of its plugin set. Builder patterns that add plugins would produce a new type at each step, making the builder API verbose and the resulting types unnameable.
The string-keyed map with type-parameterised accessors achieves the same safety guarantee at the access site — wrong type is a compile error — while keeping Agent and PluginStateMap as concrete, nameable types. The cost is a TypeId hash map lookup per access, which is negligible compared to the async operations happening around it.
See also: For implementing a plugin with lifecycle hooks, see the plugin how-to guide. For how plugin signal routes compose with agent and strategy routes, see the three-tier signal routing explanation.
Three-Tier Signal Routing
An agent at runtime receives inputs from multiple sources: a user sends a message, a tool returns a result, a timer fires, another agent sends an interrupt. Each of these is a Signal. The routing system decides what the agent should do in response. The design question is not just what to do with a signal, but who gets to decide — and in what order of authority.
What Is a Signal?
A Signal carries a SignalKind and a JSON payload:
#![allow(unused)] fn main() { pub struct Signal { pub kind: SignalKind, pub payload: Value, } }
SignalKind identifies the category of signal — UserMessage, ToolResult, Stop, Timer, or a Custom(String) for application-defined event types. The payload carries the signal-specific data.
The routing question is: given this signal, what Action should the agent take? Action is an enum: Continue, GracefulStop, ForceStop, Transition(String), or Custom(String).
Why Three Tiers?
A flat route table — a single Vec<SignalRoute> searched in priority order — seems simpler. The problem is that different parts of the system have different authorities over routing decisions, and a flat table conflates them.
Consider an agent with an FSM execution strategy. The FSM is currently in a state where accepting new user input would violate a business invariant — the agent is mid-transaction, and interruption would leave state inconsistent. The FSM strategy knows this. It needs to gate the UserMessage signal, converting it to a Reject or a GracefulStop regardless of what the agent's default routing says.
If routing were flat, the FSM strategy would need to register routes with a high priority number and hope no other component registered a conflicting route at an even higher number. This creates an implicit ordering contract between independently authored components, which breaks as soon as someone adds a new high-priority route without knowing about the FSM's requirements.
The three-tier model makes authority explicit by structure, not by number:
- Strategy tier: The FSM or direct execution strategy. Has unconditional precedence. If it registers a route for a signal, that route wins regardless of priority values in lower tiers.
- Agent tier: The agent's own default routing, registered at build time. Second in authority.
- Plugin tier: Routes contributed by plugins via
Plugin::signal_routes(). These represent plugin-level defaults that can be overridden by the agent.
Within each tier, routes are ordered by their priority: i32 field. The route with the highest priority value within a tier wins. Strategy tier routes always beat agent tier routes, regardless of priority numbers.
ComposedRouter and the First-Match Rule
ComposedRouter holds three separate Vec<SignalRoute>:
#![allow(unused)] fn main() { pub struct ComposedRouter { strategy_routes: Vec<SignalRoute>, agent_routes: Vec<SignalRoute>, plugin_routes: Vec<SignalRoute>, } }
The route method searches strategy routes first, then agent routes, then plugin routes. Within each tier it selects the route with the highest priority value among those whose kind matches and whose optional predicate passes:
flowchart TD
Signal([Signal arrives]) --> S{Strategy routes match?}
S -->|Yes, highest priority| SA[Return strategy action]
S -->|No match| A{Agent routes match?}
A -->|Yes, highest priority| AA[Return agent action]
A -->|No match| P{Plugin routes match?}
P -->|Yes, highest priority| PA[Return plugin action]
P -->|No match| N[Return None — unrouted]
A strategy-tier route with priority: 0 beats an agent-tier route with priority: 100. The tier boundary is absolute.
SignalRoute Fields
#![allow(unused)] fn main() { pub struct SignalRoute { pub kind: SignalKind, pub predicate: Option<fn(&Signal) -> bool>, pub action: Action, pub priority: i32, } }
kind provides coarse filtering — only routes whose kind matches the signal's kind are considered. predicate is an optional function pointer for fine-grained filtering within a kind. Using a function pointer rather than a closure keeps SignalRoute Clone + Send + Sync without requiring Arc wrapping.
The absence of a predicate field means the route matches all signals of the given kind. A route with a predicate matches only those signals that pass it. This allows "high-confidence" routes — where the payload tells you exactly which action to take — to coexist with "catch-all" routes at a lower priority.
The predicate approach in practice: an agent might register two UserMessage routes — one with a predicate that checks for a specific command in the payload and returns Transition("command-mode"), and a catch-all at lower priority that returns Continue. The predicate-bearing route wins when the condition is met; the catch-all handles everything else.
How Strategies Contribute Routes
ExecutionStrategy::signal_routes() returns the strategy's routes, which the runtime uses to populate the strategy tier of ComposedRouter. For FsmStrategyWithRoutes, these are routes explicitly registered via FsmStrategyBuilder::route(). The builder allows strategy authors to declare, for example, that a Stop signal always triggers ForceStop in any FSM state, or that a Timer signal triggers Transition("check") from the idle state.
Plugins contribute routes via Plugin::signal_routes(), which defaults to an empty Vec. A plugin that manages rate limiting might register a UserMessage route that forwards to GracefulStop when the rate counter is exhausted.
Trade-offs
The three-tier model adds conceptual overhead. A developer writing their first agent has to understand that there are three separate collections of routes, not one. The benefit emerges in systems with non-trivial strategies: the FSM strategy's veto power over routing cannot be accidentally bypassed by a plugin that registered a high-priority route.
For agents without a strategy (using the direct execution path) or without plugins, the strategy and plugin tiers are empty vectors. ComposedRouter degenerates to a flat list of agent routes with no overhead beyond a couple of empty-slice checks.
See also: For how FSM strategies register their own routes via the builder, see the FSM strategy design explanation. For how plugin routes are collected and assembled into the composed router, see the plugin system explanation.
The Middleware Execution Model
Before an agent node processes a turn, there is work to do that is not specific to the agent's purpose: injecting the right tools into the execution context, transforming the conversation history, adding context to the system prompt, and potentially short-circuiting the run if preconditions are not met. Middleware is the mechanism for this work.
The term "middleware" is borrowed from HTTP server frameworks where it describes code that runs on every request before the handler. Synwire's middleware serves the same role in the agent turn loop.
The MiddlewareStack and Execution Order
Middleware components are registered on the Agent builder via .middleware(mw) calls and stored in a MiddlewareStack. The stack executes components in registration order — the first middleware registered runs first, the last runs last.
Each middleware component implements the Middleware trait:
#![allow(unused)] fn main() { pub trait Middleware: Send + Sync { fn name(&self) -> &str; fn process(&self, input: MiddlewareInput) -> BoxFuture<'_, Result<MiddlewareResult, AgentError>>; fn tools(&self) -> Vec<Box<dyn Tool>> { Vec::new() } fn system_prompt_additions(&self) -> Vec<String> { Vec::new() } } }
process is the primary method. It receives MiddlewareInput (the current messages and context) and returns MiddlewareResult.
MiddlewareResult: Continue or Terminate
MiddlewareResult has two variants:
#![allow(unused)] fn main() { pub enum MiddlewareResult { Continue(MiddlewareInput), Terminate(String), } }
Continue passes the (potentially modified) input to the next middleware in the stack. Terminate causes the stack to stop immediately, returning the termination message to the runner without executing any subsequent middleware and without invoking the agent node at all.
The execution model is straightforward to follow:
flowchart LR
Input([MiddlewareInput]) --> M1[Middleware A]
M1 -->|Continue| M2[Middleware B]
M2 -->|Continue| M3[Middleware C]
M3 -->|Continue| Node[Agent Node]
Node --> Result([AgentResult])
M2 -->|Terminate| Short([Short-circuit<br/>return Terminate])
If middleware B terminates, middleware C and the agent node never run. The runner receives Terminate and ends the turn.
The stack is frozen at build time. Components cannot be added or removed while the agent is running. This is intentional: if middleware could be modified during execution, the ordering guarantees would break. A component inserted mid-run might not have run for the first part of the conversation but would run for subsequent turns, creating inconsistent state. Immutability eliminates this class of bug.
What Middleware Is For
Middleware is the correct home for cross-cutting concerns that affect every turn.
Tool injection: Middleware that provides tools adds them via tools(). The stack's tools() method collects from all components in order. A FilesystemMiddleware might inject read/write tools scoped to a specific directory. A GitMiddleware might inject commit and diff tools. Tools contributed by middleware are merged with tools registered directly on the agent.
System prompt augmentation: Middleware that adds context to the system prompt implements system_prompt_additions(). Additions are concatenated in stack order. A PromptCachingMiddleware that marks the system prompt for provider-side caching, or middleware that appends the current working directory, uses this mechanism.
Context transformation: Middleware can rewrite the messages in MiddlewareInput before they reach the agent node. A SummarisationMiddleware that watches message count and replaces the conversation history with a summary when it exceeds a threshold works this way — it modifies the messages field and returns Continue with the modified input.
Input correction: Some model providers occasionally return malformed tool call arguments. A PatchToolCallsMiddleware can inspect and correct these before the agent node sees them, without the node needing to handle the malformed case.
Termination on precondition failure: Rate limiting, budget enforcement, or context validation can all terminate early by returning Terminate. This prevents the agent node from running at all, rather than the node running and discovering the constraint violation mid-execution.
Contrast with Hooks
The HookRegistry also provides callbacks at agent lifecycle points — before and after tool use, at session start and end, when subagents start or stop, around context compaction. Hooks and middleware serve different purposes:
Middleware transforms the execution path. It receives the messages, may modify them, and either passes them forward or stops execution. Middleware runs synchronously in the call stack of the turn loop, before the agent node.
Hooks observe events. They receive a context describing what happened and return HookResult::Continue or HookResult::Abort. Hooks do not modify the messages flowing through the system — they react to events that have already occurred or are about to occur. Hooks run with enforced timeouts; a hook that exceeds its timeout is skipped with a warning rather than failing the agent.
The distinction matters when choosing where to put new functionality. If the goal is to change what the agent receives or to conditionally prevent execution, middleware is the right place. If the goal is to audit, log, or react to events without affecting the execution path, hooks are the right place.
The Middleware Stack Is Not a Pipeline of Processors
It is worth dispelling a common mental model: middleware in Synwire is not a pipeline where each stage produces a result that feeds forward to all subsequent stages in a symmetric way. There is no "post-processing" phase where middleware runs again after the agent node in reverse order (as in the onion model used by some HTTP frameworks).
MiddlewareStack::run iterates forward through the components once. Each component either passes the input forward or terminates. After the agent node runs, the middleware stack is not re-entered. If post-execution observation is needed, hooks (specifically after_agent callbacks) serve that role.
See also: For how to implement a custom middleware component, see the middleware how-to guide. For how hooks complement middleware for lifecycle observation, see the hooks reference. For how tools injected by middleware are merged with agent tools, see the tool system reference.
FSM Execution Strategy Design
An ExecutionStrategy controls how an agent orchestrates its actions — which actions are valid at a given moment, what transitions they trigger, and what happens when an invalid action is attempted. The FsmStrategy implementation models this as a finite state machine: a set of states, a set of actions, and a table of transitions between them.
Understanding why the FSM is designed the way it is requires first understanding what problem it solves, and then examining the specific choices made in the implementation.
The Problem: Unconstrained Action Sequences
The alternative to an FSM is a direct execution strategy, where any action can be taken at any time. For simple, single-purpose agents this works fine. For agents with multi-phase workflows — authentication before access, planning before execution, confirmation before destructive operations — unconstrained action sequences create risk. An agent that can invoke a destructive operation at any point in its lifecycle, rather than only after an explicit confirmation step, is harder to reason about and harder to audit.
The FSM strategy makes the valid action sequences explicit. A transition table declares, for each combination of (state, action), what the next state will be. Any action not listed for the current state is rejected with an InvalidTransition error that includes the current state, the attempted action, and the list of valid actions from the current state.
The Transition Table Data Model
The transition table is a HashMap<(FsmStateId, ActionId), Vec<FsmTransition>>:
#![allow(unused)] fn main() { pub struct FsmStrategy { current: Mutex<FsmStateId>, transitions: HashMap<(FsmStateId, ActionId), Vec<FsmTransition>>, } }
The key is a (from-state, action) pair. The value is a sorted list of transitions. A single (from, action) pair can have multiple transitions — this is the guard mechanism.
FsmStateId and ActionId are newtypes around String. String-based identities were chosen deliberately; the alternative — a compile-time typestate FSM where states are distinct Rust types — is discussed below.
Priority-Based Guards
Each FsmTransition carries an optional guard and a priority:
#![allow(unused)] fn main() { pub struct FsmTransition { pub target: FsmStateId, pub guard: Option<Box<dyn GuardCondition>>, pub priority: i32, } }
When the FSM attempts to execute an action, it finds all transitions for the (current-state, action) key, iterates them in descending priority order, and takes the first transition whose guard evaluates to true (or the first transition with no guard at all).
This enables conditional branching without requiring separate action names for each branch. Consider a review action from the planning state. If the plan quality is high (determined by the guard), transition to executing. If quality is low, transition to revising. Both are registered as transitions for ("planning", "review") with different guards and different targets; the priority ordering controls which guard is evaluated first.
The concrete guard type shipped with synwire-core is ClosureGuard, which wraps an Fn(&Value) -> bool:
#![allow(unused)] fn main() { pub struct ClosureGuard { name: String, f: Box<dyn Fn(&Value) -> bool + Send + Sync>, } }
The name field is used in error messages: when a guard rejects a transition, the GuardRejected error is more useful if it can name which guard fired.
Thread Safety: Mutex<FsmStateId>
The current state is held behind a Mutex:
#![allow(unused)] fn main() { current: Mutex<FsmStateId>, }
This makes FsmStrategy usable from multiple async tasks concurrently — the execute method locks the mutex, reads the current state, finds the valid transition, updates the state, and releases the lock. The transition is atomic from the FSM's perspective.
In practice, the agent runner processes one turn at a time and rarely needs concurrent FSM access. The Mutex is cheap when uncontended and provides correctness guarantees that matter when the stop signal or an external control plane does interact with the FSM concurrently.
FsmStrategyWithRoutes: Strategy Plus Signal Routes
The FsmStrategyBuilder::build() method returns FsmStrategyWithRoutes, not FsmStrategy directly:
#![allow(unused)] fn main() { pub struct FsmStrategyWithRoutes { pub strategy: FsmStrategy, signal_routes: Vec<SignalRoute>, } }
This bundles the FSM with any signal routes the strategy author registered via FsmStrategyBuilder::route(). When the agent runtime assembles the ComposedRouter, it calls ExecutionStrategy::signal_routes() on the strategy, which returns these routes for placement in the strategy tier. The FSM strategy thus contributes both behaviour (transition logic) and routing (which signals it wants to gate).
An FSM that gates UserMessage signals while in the processing state would register a SignalRoute for SignalKind::UserMessage with Action::GracefulStop. That route appears in the strategy tier of ComposedRouter, where it unconditionally beats any agent or plugin routes for the same signal kind.
StrategySnapshot for Checkpointing
FsmStrategy::snapshot() returns a Box<dyn StrategySnapshot> that serialises to:
{ "type": "fsm", "current_state": "planning" }
This snapshot is intended for two uses: checkpointing (persisting the FSM state alongside the conversation so a resumed session starts in the right FSM state), and debugging (knowing which FSM state the agent is in when examining a log or trace). The snapshot is minimal by design — the transition table is static and does not need to be serialised.
stateDiagram-v2
[*] --> idle
idle --> running : start [no guard]
running --> done : finish [no guard]
running --> revising : review [quality_guard fails]
running --> done : review [quality_guard passes]
revising --> running : restart
The diagram above illustrates a typical FSM with a guarded transition. The snapshot at any point contains only the current state label.
Why Not a Compile-Time Typestate FSM?
The typestate pattern would encode each FSM state as a distinct Rust type parameter on the agent. Transitions would be methods that consume Agent<StateA> and produce Agent<StateB>, with invalid transitions simply absent from the API. This gives maximum compile-time safety — invalid action sequences are type errors.
The practical problem is composition. An Agent<PlanningState> and an Agent<ExecutingState> are different types, making it impossible to store them in the same Vec, pass them to the same function, or build generic orchestration logic without encoding the complete state space in the type. The transition table would need to be encoded as generic bounds, producing type signatures that are difficult to read and essentially unnameable.
The runtime FSM accepts this trade-off: states are strings, the transition table is checked at runtime, and the InvalidTransition error provides the diagnostic information that the type system would otherwise provide. For typical agent state machines — five to fifteen states with clear names — the error messages are just as useful as compiler errors, and the resulting code is dramatically simpler to write and read.
See also: For how to build and configure an FSM strategy, see the FSM strategy how-to guide. For how FSM signal routes interact with agent and plugin routes, see the three-tier signal routing explanation. For how strategy snapshots are stored in session checkpoints, see the session management reference.
See also
- StateGraph vs FsmStrategy — when to use the FSM vs a multi-node graph
- Execution Strategies Tutorial — hands-on with
FsmStrategy - Agent Runtime Explanation — how
Runner, strategy, middleware and backend compose
Crate Architecture and Layer Boundaries
Synwire is a workspace of cooperating crates. Understanding the boundaries between them — what belongs where and why — clarifies how to add new functionality, how to write tests that do not pull in unnecessary dependencies, and how third-party implementations of backends or strategies relate to the rest of the system.
The Layer Model
The workspace is structured in three functional layers:
graph TD
App["User Application"] --> synwire["synwire<br/>(re-exports)"]
synwire --> agent["synwire-agent<br/>(concrete implementations)"]
agent --> core["synwire-core<br/>(traits and types)"]
testutils["synwire-test-utils<br/>(test helpers)"] --> agent
testutils --> core
derive["synwire-derive<br/>(proc macros)"] --> core
checkpoints["synwire-checkpoint<br/>synwire-checkpoint-sqlite"] --> core
llm["synwire-llm-openai<br/>synwire-llm-ollama"] --> core
The arrows represent dependency direction. synwire-core has no dependencies on other synwire crates. synwire-agent depends on synwire-core. synwire re-exports from both. Nothing in the lower layers knows about the layers above it.
synwire-core: Traits and Types Only
synwire-core defines the foundational trait hierarchy and data types that the rest of the system is built on. It contains:
AgentNode: The trait for a runnable agent. Any type implementingAgentNodecan be used as a node in an orchestration graph.ExecutionStrategy: The trait for controlling which actions an agent may take and in what order.FsmStrategyandDirectStrategyare not here.Middleware,Plugin,DirectiveExecutor,DirectiveFilter: The extension point traits for cross-cutting concerns.SignalRouter,ComposedRouter,SignalRoute: The signal routing types.SessionManager: The trait for session persistence, with no concrete implementation.Vfs: The trait for file, shell, and process backends.McpTransport: The trait for Model Context Protocol transport adapters.AgentError,ModelError,VfsError,StrategyError: The error type hierarchy.Directive,DirectiveResult<S>: The algebraic effects types.PluginStateKey,PluginStateMap,PluginHandle<P>: The plugin state isolation types.
synwire-core is annotated #![forbid(unsafe_code)]. It compiles with zero unsafe. All public types are Send + Sync. All fallible operations return Result. This crate is suitable as a direct dependency for anyone writing a third-party backend, strategy, or middleware — they get the abstractions without the implementations.
synwire-agent: Concrete Implementations
synwire-agent provides the concrete implementations of the traits defined in synwire-core:
- Strategies:
FsmStrategy,FsmStrategyBuilder,FsmStrategyWithRoutes,DirectStrategy. - Backends:
LocalProvider,GitBackend,HttpBackend,ProcessManager,ArchiveManager,Shell,CompositeProvider,StoreProvider,PipelineBackend,ThresholdGate. - Middleware:
FilesystemMiddleware,GitMiddleware,HttpMiddleware,ProcessMiddleware,ArchiveMiddleware,EnvironmentMiddleware,PatchToolCallsMiddleware,PromptCachingMiddleware,SummarisationMiddleware,PipelineMiddleware. - MCP transports:
StdioTransport,HttpTransport,InProcessTransport,McpLifecycleManager. - Session management:
InMemorySessionManager.
This boundary keeps synwire-core stable independently of implementation churn. The core crate can evolve its trait signatures deliberately while synwire-agent and third-party implementations evolve at their own pace. A third-party crate implementing a PostgreSQL-backed SessionManager only needs to depend on synwire-core, not on synwire-agent.
synwire-agent is also annotated #![forbid(unsafe_code)].
synwire: The Convenience Re-Export Crate
Most application code should depend on synwire, not on synwire-core or synwire-agent directly. The synwire::agent::prelude::* re-export provides everything a typical application needs: the builder, all the concrete strategies and middleware, the error types, and the test utilities interface.
The re-export crate exists primarily for ergonomics. An application that imports synwire does not need to know which crate in the workspace any given type comes from. It is a thin façade with no logic of its own.
synwire-test-utils: Test Infrastructure
synwire-test-utils depends on both synwire-core and synwire-agent. It provides:
RecordingExecutor: ADirectiveExecutorthat records all directives received, for assertion in unit tests.- Proptest strategies: Proptest generators for
Directive,Signal,AgentError, and other public types, enabling property-based testing of agent logic. executorsmodule: Conformance test suites that anyDirectiveExecutorimplementation can run to verify correct behaviour.
Because synwire-test-utils depends on synwire-agent, it is a dev-dependency in most crates. It should never appear as a production dependency.
synwire-derive: Proc Macros
synwire-derive provides the #[tool] attribute macro (for deriving Tool implementations from annotated struct methods) and the #[derive(State)] macro (for deriving the State trait on serialisable structs). Proc macro crates must be separate crates in Rust's compilation model; synwire-derive has no runtime code.
Checkpoint Crates
synwire-checkpoint defines the CheckpointStore trait and associated types. synwire-checkpoint-sqlite provides the SQLite-backed implementation using rusqlite. The checkpoint crates depend on synwire-core but not on synwire-agent, allowing them to be used independently by applications that manage their own agent implementations.
LLM Provider Crates
synwire-llm-openai and synwire-llm-ollama depend on synwire-core and provide LanguageModel trait implementations backed by the respective provider APIs. They use reqwest with rustls for HTTP, and are kept separate from synwire-agent so that applications can choose their model providers independently of the backend and middleware implementations.
The Key Design Principle
The layering constraint — lower layers do not know about higher layers — is what makes the system extensible without modification. A new backend implementation in a third-party crate depends only on synwire-core. A new LLM provider depends only on synwire-core. The agent builder in synwire-agent accepts anything that implements the relevant traits, whether those types come from synwire-agent itself, from synwire-llm-openai, or from the application's own code.
This is not just an architectural preference. The #![forbid(unsafe_code)] annotation on synwire-core and synwire-agent means that neither crate can accidentally pull in unsafe behaviour through a dependency on a higher layer. The constraint is enforced by the compiler, not by convention.
Example: Implementing a Third-Party Extension
The layering constraint is enforced by the dependency graph. A crate that implements a PostgreSQL-backed SessionManager only declares a dependency on synwire-core, not on synwire-agent:
# Cargo.toml for a third-party postgres-session crate
[dependencies]
synwire-core = "0.1"
tokio-postgres = "0.7"
uuid = { version = "1", features = ["v4"] }
The implementation depends only on the trait definition from synwire-core:
#![allow(unused)] fn main() { use synwire_core::agents::session::{SessionManager, Session, SessionMetadata}; use synwire_core::agents::error::AgentError; pub struct PostgresSessionManager { // postgres connection pool would go here } // Implementing SessionManager from synwire-core only — // no dependency on synwire-agent needed. impl SessionManager for PostgresSessionManager { async fn create(&self, metadata: SessionMetadata) -> Result<Session, AgentError> { // persist to postgres... todo!() } async fn get(&self, session_id: &str) -> Result<Option<Session>, AgentError> { // query postgres... todo!() } // ... remaining SessionManager methods } }
Because PostgresSessionManager implements SessionManager from synwire-core, it is accepted by Runner from synwire-agent without any change to either crate:
#![allow(unused)] fn main() { // In the application (depends on synwire-agent + postgres-session): use synwire_agent::runner::Runner; // use postgres_session::PostgresSessionManager; // hypothetical third-party crate // Runner accepts any SessionManager from synwire-core. // let runner = Runner::builder() // .session_manager(PostgresSessionManager::new(...)) // .build()?; }
The application calls Runner from synwire-agent, which calls SessionManager from synwire-core. The database implementation in the third-party crate never sees synwire-agent. Neither the trait definition nor the runtime need to change to accommodate the new backend.
See also: For the State trait and how it constrains DirectiveResult<S>, see the directive/effect architecture explanation. For how to write a conformance test for a custom DirectiveExecutor, see the synwire-test-utils reference.
Error Types and Taxonomy
Synwire's error design reflects a specific conviction: errors should carry enough context for the caller to take a meaningful action, and the structure of error types should reflect the structure of the system that produces them. This document explains the error hierarchy, the reasoning behind its shape, and the trade-offs involved.
The Hierarchy
The error types form a three-level hierarchy:
graph TD
AE["AgentError"] --> ME["ModelError"]
AE --> SE["StrategyError"]
AE --> BE["VfsError"]
AE --> DE["DirectiveError"]
AE --> TE["Tool / Session / Middleware<br/>(String-wrapped)"]
AE --> PanicE["Panic(String)"]
AE --> BudgetE["BudgetExceeded(f64)"]
AgentError is the top-level type returned by the runner and the AgentNode::run method. Every error that crosses the public agent boundary is wrapped in or converted to AgentError. Callers that only care about "did the agent succeed" match on AgentError without looking deeper. Callers that want to respond differently to different failure modes inspect the variant.
AgentError: The Public Boundary
#![allow(unused)] fn main() { pub enum AgentError { Model(ModelError), Tool(String), Strategy(String), Middleware(String), Directive(String), Backend(String), Session(String), Panic(String), BudgetExceeded(f64), } }
Note that Model, carries a structured ModelError, while Tool, Strategy, Middleware, Directive, Backend, and Session carry strings. This reflects which errors need structured handling at the runner level. The runner's retry logic calls ModelError::is_retryable() to decide whether to retry or fall back. No corresponding structured inspection is needed for tool or middleware errors in the runner loop — they are logged and propagated.
BudgetExceeded is an exceptional case: it carries the budget limit as a f64 rather than just a string, enabling callers to display the limit or make threshold-based decisions without parsing a string. The f64 is the budget in USD.
Panic captures payloads from catch_unwind in the runner loop. Library code uses #[forbid(unsafe_code)] and zero-panic conventions, but this variant exists for the case where a plugin, middleware, or callback written by application code panics. Wrapping the panic payload in AgentError::Panic ensures the error propagates cleanly through the async channel rather than aborting the tokio task.
All variants of AgentError are #[non_exhaustive]. Match arms must include a _ catch-all. This is not just defensive API design — it is an explicit commitment that new error conditions will be added as the system grows, and callers must not assume they have seen every possible failure mode.
ModelError: Structured Provider Failures
ModelError covers the failure modes specific to model API calls:
#![allow(unused)] fn main() { pub enum ModelError { Authentication(String), Billing(String), RateLimit(String), ServerError(String), InvalidRequest(String), MaxOutputTokens, } }
The key method is is_retryable():
#![allow(unused)] fn main() { pub const fn is_retryable(&self) -> bool { matches!(self, Self::RateLimit(_) | Self::ServerError(_)) } }
Authentication and Billing failures are not retryable — retrying will produce the same error. InvalidRequest is not retryable — the request itself is malformed. MaxOutputTokens is not retryable — the model cannot produce a longer response without changing the prompt.
RateLimit and ServerError are retryable because they represent transient conditions. The runner uses is_retryable() to decide whether to retry, switch to the fallback model, or abort. This logic is in the runner, not the error type — the error type only describes the condition, not the response to it.
The RateLimit variant carries a string rather than a Duration for retry-after because not all providers supply a retry-after header, and the format of those that do varies. Application code that wants to implement exponential backoff based on Retry-After headers would parse the string or use a provider-specific error type.
VfsError: File and Process Failures
VfsError is the error type for all backend operations — file system access, process execution, HTTP requests, and archive manipulation:
#![allow(unused)] fn main() { pub enum VfsError { NotFound(String), PermissionDenied(String), IsDirectory(String), PathTraversal { attempted: String, root: String }, ScopeViolation { path: String, scope: String }, ResourceLimit(String), Timeout(String), OperationDenied(String), Unsupported(String), Io(io::Error), } }
PathTraversal and ScopeViolation are the security-relevant variants. Both carry structured context rather than strings: PathTraversal includes the attempted path and the root that was violated; ScopeViolation includes the offending path and the allowed scope. This allows audit logging to capture the full context of a security event without parsing the error message. It also allows UI code to show the user the specific path that was rejected and the scope it violated, enabling them to understand why an operation failed.
Io(io::Error) wraps standard I/O errors via #[from]. Most callers treat this as an opaque infrastructure failure; callers that need to distinguish specific I/O conditions (such as file-not-found vs. permission-denied on the OS level) can inspect the wrapped io::Error.
StrategyError: FSM Transition Failures
StrategyError is the error type for execution strategy operations:
#![allow(unused)] fn main() { pub enum StrategyError { InvalidTransition { current_state: String, attempted_action: String, valid_actions: Vec<String>, }, GuardRejected(String), NoInitialState, Execution(String), } }
InvalidTransition is the most information-rich variant in the error hierarchy. It carries the current state, the attempted action, and — crucially — the list of valid actions from the current state. This is actionable context: a system that presents FSM state to users can display exactly which actions are available when the agent refuses to execute an invalid transition. Tests that verify FSM behaviour can assert on the valid_actions list.
GuardRejected carries the name of the guard that rejected the transition. Because ClosureGuard requires a name, this message identifies which guard fired, making it possible to diagnose why a transition was rejected in logs.
The #[non_exhaustive] Commitment
Every error enum in Synwire is #[non_exhaustive]. This is not just a defensive measure against breakage — it is an acknowledgement that the system is still evolving and that new failure modes will be discovered and named.
The cost is real: every match on a Synwire error type requires a _ arm. For callers that handle specific variants and want to propagate everything else, this means writing e => return Err(e.into()) or similar. This is a small but real ergonomic cost that is accepted in exchange for forward compatibility.
Actionable Context as a Design Principle
The error design follows a simple rule: each error variant should carry enough information for the caller to respond appropriately without parsing a string. InvalidTransition carries valid_actions so callers can present valid options. PathTraversal carries attempted and root so audit logs can record exact details. BudgetExceeded carries the limit so callers can display it.
String-wrapped errors (Tool(String), Session(String)) represent areas where the system does not yet need structured handling — the string is sufficient for logging and human reading, and the runtime does not inspect it programmatically. As the system matures and specific handling patterns emerge for these error types, they will be promoted to structured variants.
See also: For how the runner uses ModelError::is_retryable() in its retry loop, see the runner architecture reference. For how VfsError::PathTraversal and ScopeViolation are raised by the filesystem backend, see the backend how-to guide.
Semantic Search Architecture
Synwire's semantic search system lets LLM agents find code and documents by meaning rather than by exact text match. An agent can ask "where is the error handling logic?" and receive ranked results — even when the source never contains that literal phrase.
Background: Retrieval-Augmented Generation — the Prompt Engineering Guide explains how retrieval enhances LLM reasoning. Semantic search is the retrieval component of this pattern.
The problem
Text search (grep) matches character patterns. It works when you already know
what to look for — a function name, an error string, a config key. It fails when
you know what something does but not what it is called:
| Query | grep finds it? | Semantic search finds it? |
|---|---|---|
fn authenticate | Yes | Yes |
| "authentication flow" | No | Yes |
| "how are errors propagated?" | No | Yes |
ECONNREFUSED | Yes | Yes |
| "network connection failures" | No | Yes |
Semantic search complements grep — it does not replace it. Use grep for known patterns; use semantic search for conceptual queries.
Pipeline overview
The pipeline has four stages, each handled by a dedicated crate:
graph LR
A["Walk + Filter<br/><b>synwire-index</b>"] --> B["Chunk<br/><b>synwire-chunker</b>"]
B --> C["Embed<br/><b>synwire-embeddings-local</b>"]
C --> D["Store + Search<br/><b>synwire-vectorstore-lancedb</b>"]
- Walk:
synwire-indextraverses a directory, filtering by include/exclude globs and maximum file size. - Chunk:
synwire-chunkersplits each file into semantic units — AST definitions for code, overlapping text segments for prose. - Embed:
synwire-embeddings-localconverts each chunk into a 384-dimension float vector using BAAI/bge-small-en-v1.5 (ONNX, runs locally). - Store:
synwire-vectorstore-lancedbwrites vectors to a LanceDB table. At search time, it performs approximate nearest-neighbour lookup, and an optional reranker (BAAI/bge-reranker-base) re-scores the top results.
Chunking strategy
Code and prose require different splitting approaches:
AST chunking (code files): synwire-chunker uses tree-sitter to parse the
source into an abstract syntax tree. It extracts top-level definitions — functions,
structs, classes, traits, enums, interfaces — as individual chunks. Each chunk
carries metadata: the symbol name, language, and line range.
src/auth.rs
├── fn authenticate(...) → chunk 1, symbol="authenticate", lines 12-45
├── struct AuthConfig { ... } → chunk 2, symbol="AuthConfig", lines 47-62
└── impl AuthConfig { ... } → chunk 3, lines 64-120
Text chunking (non-code files): A recursive character splitter tries
progressively finer split points — paragraph boundaries (\n\n), newlines,
spaces, then individual characters — to keep chunks near a target size (default
1 500 bytes) with configurable overlap (default 200 bytes).
The Chunker facade dispatches automatically: if the file extension maps to a
supported language and tree-sitter produces definition nodes, AST chunking is
used. Otherwise, the text splitter handles it.
Supported languages
| Language | AST chunking | Language | AST chunking |
|---|---|---|---|
| Rust | Yes | Ruby | Yes |
| Python | Yes | Bash | Yes |
| JavaScript | Yes | JSON | Yes |
| TypeScript | Yes | YAML | Yes |
| Go | Yes | HTML | Yes |
| Java | Yes | CSS | Yes |
| C | Yes | TOML | Text only |
| C++ | Yes | Markdown | Text only |
| C# | Yes |
TOML and Markdown lack compatible tree-sitter grammars for version 0.24, so they always fall back to text chunking. JSON, YAML, HTML, and CSS have parsers but no meaningful "definition" nodes — these also fall back to the text splitter.
Embedding model
synwire-embeddings-local wraps fastembed-rs,
which bundles an ONNX Runtime for inference. The default model is
BAAI/bge-small-en-v1.5 — a 33M-parameter encoder producing 384-dimensional
vectors.
Key properties:
- Local: No API calls. Inference runs on the CPU via ONNX Runtime.
- Lazy download: The model is downloaded from Hugging Face Hub on first use and cached in fastembed's default cache directory.
- Thread-safe: The model is wrapped in
Arc<TextEmbedding>and all inference runs on Tokio's blocking thread pool viaspawn_blocking, keeping the async runtime unblocked.
Vector storage
synwire-vectorstore-lancedb implements the VectorStore trait on top of
LanceDB, a serverless vector database
backed by Apache Arrow columnar storage.
Schema:
| Column | Arrow type | Purpose |
|---|---|---|
id | Utf8 | UUID per chunk |
text | Utf8 | Chunk content |
vector | FixedSizeList(Float32, 384) | Embedding vector |
metadata | Utf8 | JSON-encoded metadata |
LanceDB stores data as Lance files on disk. It supports approximate nearest-neighbour (ANN) search using IVF-PQ indices, but for small-to-medium codebases the brute-force flat scan is fast enough. No separate server process is required.
Reranking
After the initial vector search returns candidate chunks, an optional cross-encoder reranker (BAAI/bge-reranker-base, also via fastembed) re-scores each candidate against the original query. Cross-encoders are more accurate than bi-encoders for relevance scoring because they attend to the query–document pair jointly, but they are slower — hence the two-stage pipeline:
- Retrieval (fast, bi-encoder): embed the query, find top-k nearest vectors
- Reranking (accurate, cross-encoder): re-score the top-k candidates
Reranking is enabled by default and can be disabled via SemanticSearchOptions { rerank: Some(false), .. }.
Caching and incremental updates
synwire-index caches each indexed directory under the OS cache directory:
$XDG_CACHE_HOME/synwire/indices/<sha256(canonical_path)>/
├── lance/ ← LanceDB data files
├── meta.json ← { path, indexed_at, files_indexed, chunks_produced, version }
└── hashes.json ← { file_path → xxh128 hex hash }
On subsequent index() calls with force: false, the cache is checked. If
meta.json exists and is recent, the index is reused without re-walking.
Content-based deduplication
Both the initial pipeline and the file watcher use xxHash128 content hashing
to skip files that have not changed. Before chunking and embedding a file, the
pipeline computes the xxh128 hash of its content and compares it against the
stored hash in hashes.json. If the hashes match, the file is skipped entirely
— no chunking, no embedding, no vector store writes. This avoids duplicate
effort when re-indexing after minor changes or when the watcher fires for a file
that was saved without modification (e.g. by an editor auto-save).
xxHash128 was chosen over SHA-256 or other cryptographic hashes because:
- Speed: xxh128 hashes at ~30 GB/s on modern CPUs, orders of magnitude faster than SHA-256 (~500 MB/s). For indexing thousands of source files, hashing overhead is negligible.
- Collision resistance: 128 bits provides sufficient collision resistance for content deduplication (not a security context).
- No
unsafe: Thexxhash-rustcrate is pure Rust with no unsafe code, matching Synwire's#![forbid(unsafe_code)]policy.
File watcher
After the initial index completes, a background file watcher (via the notify
crate) monitors the directory for changes:
- Created/modified files: content hash is checked first; only files with changed content are re-chunked, re-embedded, and inserted into the vector store.
- Debouncing: events within a 300 ms window are coalesced to avoid redundant re-indexing during active editing.
The watcher uses the platform-native mechanism: inotify on Linux, FSEvents
on macOS, ReadDirectoryChangesW on Windows.
VFS integration
The semantic search pipeline is exposed to LLM agents through the VFS (Virtual Filesystem) trait:
| VFS method | Purpose |
|---|---|
index(path, opts) | Start indexing a directory; returns immediately with an IndexHandle |
index_status(id) | Poll progress: Pending → Indexing → Ready/Failed |
semantic_search(query, opts) | Search indexed content by meaning |
These methods are gated behind the semantic-search feature flag on
synwire-agent. When the feature is disabled, the VFS reports that INDEX and
SEMANTIC_SEARCH capabilities are unavailable, and the corresponding tools are
not offered to the LLM.
LocalProvider lazily initialises the full pipeline — LocalEmbeddings,
LocalReranker, LanceDbVectorStore factory, and SemanticIndex — on the
first index() call.
Agentic ignore files
LocalProvider discovers and respects agentic ignore files by searching
upward from the provider's root directory to the filesystem root. These files
use gitignore syntax and control which paths the agent can see:
| File name | Tool / ecosystem |
|---|---|
.gitignore | Git (universal baseline) |
.cursorignore | Cursor |
.aiignore | Emerging cross-tool standard |
.claudeignore | Claude Code |
.aiderignore | Aider |
.copilotignore | GitHub Copilot |
.codeiumignore | Codeium |
.tabbyignore | Tabby |
The upward traversal is important because a monorepo may have ignore files in a
parent directory that should still apply to nested workspaces. For example, a
.cursorignore at ~/projects/ containing **/secrets/ applies to every
project under that directory — even if LocalProvider is rooted at
~/projects/myapp/.
Ignore rules are applied to:
ls— hidden entries are omitted from directory listingsgrep— hidden files are skipped during content searchglob— hidden files are excluded from pattern matches- Semantic indexing — hidden files are not walked, chunked, or embedded
Patterns from files closer to the root directory take precedence over patterns
from ancestor directories, matching standard gitignore semantics. Negation
(! prefix) and directory-only patterns (trailing /) are fully supported.
Safety constraints
- Root denial:
index("/", ..)is rejected withVfsError::IndexDeniedto prevent accidentally indexing the entire filesystem. - Path traversal protection:
LocalProvidercanonicalises paths and rejects any that escape the configured root directory. - Agentic ignore: Files matching patterns in
.cursorignore,.aiignore, and similar files are excluded from all VFS operations. - Graceful degradation: Individual file failures during indexing are logged and skipped; the pipeline continues with remaining files.
See also
- synwire-chunker — AST and text chunking in depth
- synwire-embeddings-local — local embedding models
- synwire-vectorstore-lancedb — vector storage details
- synwire-index — indexing pipeline lifecycle
- Semantic Search Tutorial — hands-on walkthrough
- Semantic Search How-To — task-focused recipes
synwire-chunker: AST-Aware Code Chunking
synwire-chunker splits source files into semantically meaningful chunks for
embedding and retrieval. It combines tree-sitter AST parsing for code with a
recursive character splitter for prose, producing [Document] values annotated
with file path, line range, language, and symbol name metadata.
Why AST chunking matters
Naive text splitting (every n characters) breaks code at arbitrary points — splitting a function in half, separating a struct from its impl block, or cutting a docstring from the function it documents. These broken chunks embed poorly because the vector captures a fragment rather than a concept.
AST chunking extracts whole definitions:
Naive (500-char chunks) AST chunking
┌──────────────────┐ ┌──────────────────┐
│ /// Authenticates │ │ /// Authenticates │
│ /// a user with │ │ /// a user with │
│ /// the given │ │ /// credentials. │
│ /// credentials. │ │ fn authenticate( │
│ fn authenticate( │ │ user: &str, │
│ user: &str, │ │ pass: &str, │
├──────────────────┤ ← split here │ ) -> Result<Token> │
│ pass: &str, │ │ { │
│ ) -> Result<Token> │ │ // full body │
│ { │ │ } │
│ // body... │ └──────────────────┘
│ } │ one complete unit
│ │
│ struct AuthConfig │
│ { │
├──────────────────┤ ← split here
│ timeout: u64, │
│ } │
└──────────────────┘
Each AST chunk represents one concept — a function, a struct, a trait — which produces a focused embedding vector that matches conceptual queries.
Architecture
graph TD
A["Chunker::chunk_file(path, content)"] --> B{detect_language}
B -->|Known language| C["chunk_ast(path, content, lang)"]
B -->|Unknown extension| D["chunk_text(path, content, size, overlap)"]
C -->|Definitions found| E["Vec<Document> with symbol metadata"]
C -->|No definitions / parse failure| D
D --> F["Vec<Document> with chunk_index metadata"]
The Chunker facade:
- Detects the language from the file extension via
detect_language(path). - Attempts AST chunking with tree-sitter.
- Falls back to the text splitter if: the language is unrecognised, no tree-sitter grammar is available, parsing fails, or no definition-level nodes are found.
Tree-sitter integration
synwire-chunker bundles 15 tree-sitter grammar crates. For each language, it
defines which AST node kinds represent top-level definitions:
| Language | Definition node kinds |
|---|---|
| Rust | function_item, impl_item, struct_item, enum_item, trait_item, type_alias |
| Python | function_definition, class_definition |
| JavaScript | function_declaration, class_declaration, method_definition, arrow_function |
| TypeScript | function_declaration, class_declaration, method_definition, interface_declaration, type_alias_declaration |
| Go | function_declaration, method_declaration, type_declaration |
| Java | method_declaration, class_declaration, interface_declaration, constructor_declaration |
| C | function_definition, struct_specifier |
| C++ | function_definition, struct_specifier, class_specifier, namespace_definition |
| C# | method_declaration, class_declaration, interface_declaration, property_declaration |
| Ruby | method, singleton_method, class, module |
| Bash | function_definition |
The walker is intentionally shallow — it collects only immediate children of the root node. Nested definitions (helper functions inside a class, closures inside a function) are captured within their parent definition, not split out separately. This keeps each chunk self-contained.
Symbol extraction
For each definition node, the chunker attempts to extract a symbol name by
scanning direct children for identifier, name, field_identifier, or
type_identifier nodes. The symbol name is stored in the chunk's metadata under
the "symbol" key.
Text splitter
The recursive character splitter handles non-code files and fallback cases. It tries split points in order of decreasing granularity:
- Paragraph boundary (
\n\n) — preserves paragraph structure - Newline (
\n) — preserves line structure - Space (
) — preserves word boundaries - Character boundary — last resort, splits at any character
At each level, it finds the last occurrence of the separator that keeps the chunk within the target size. If no separator fits, it falls through to the next level.
Overlap: Consecutive chunks share overlap bytes of context (default 200),
so a concept split between chunks appears in both. This helps retrieval when the
relevant content straddles a split point.
Metadata
Every chunk carries a HashMap<String, serde_json::Value> metadata map:
| Key | AST chunks | Text chunks | Type | Description |
|---|---|---|---|---|
file | Yes | Yes | String | Source file path |
language | Yes | No | String | Lowercase language name |
symbol | When found | No | String | Definition name (e.g. add) |
line_start | Yes | Yes | Number | 1-indexed first line |
line_end | Yes | Yes | Number | 1-indexed last line |
chunk_index | No | Yes | Number | 0-based sequential position |
Configuration
The ChunkOptions struct controls the text splitter parameters:
use synwire_chunker::ChunkOptions;
let opts = ChunkOptions {
chunk_size: 2000, // target bytes per chunk (default: 1500)
overlap: 300, // overlap bytes between consecutive chunks (default: 200)
};
let chunker = synwire_chunker::Chunker::with_options(opts);
AST chunking ignores these options — each definition is one chunk regardless of size. If a function is 5 000 bytes, it becomes a single 5 000-byte chunk.
See also
- Semantic Search Architecture — how chunking fits into the pipeline
- synwire-embeddings-local — what happens after chunking
- Semantic Search Tutorial — hands-on walkthrough
synwire-embeddings-local: Local Embedding and Reranking Models
synwire-embeddings-local provides CPU-based text embedding and cross-encoder
reranking, backed by fastembed-rs
and ONNX Runtime. No API keys, no network calls at inference time, no data
leaves the machine.
Models
| Component | Model | Parameters | Output | Purpose |
|---|---|---|---|---|
LocalEmbeddings | BAAI/bge-small-en-v1.5 | 33M | 384-dim f32 vector | Bi-encoder: fast similarity search |
LocalReranker | BAAI/bge-reranker-base | 110M | Relevance score | Cross-encoder: accurate re-scoring |
Both models are downloaded from Hugging Face Hub on first use and cached locally by fastembed. Subsequent constructions load from cache with no network access.
Bi-encoder vs cross-encoder
The two models serve complementary roles in a two-stage retrieval pipeline:
graph LR
Q["Query"] --> E1["Embed query<br/>(bi-encoder)"]
E1 --> S["Vector similarity<br/>top-k candidates"]
S --> R["Rerank<br/>(cross-encoder)"]
R --> F["Final results"]
Bi-encoder (LocalEmbeddings): Encodes query and documents independently
into fixed-size vectors. Similarity is computed via cosine distance. This is fast
(embeddings are precomputed for documents) but less accurate because the model
never sees query and document together.
Cross-encoder (LocalReranker): Takes a (query, document) pair as input and
produces a single relevance score. This is more accurate because the model
attends to both texts jointly, but slower because it must run inference for every
candidate. Hence it is used only on the top-k results from the bi-encoder.
Thread safety and async integration
fastembed's inference is synchronous and CPU-bound. To avoid blocking the Tokio
async runtime, both LocalEmbeddings and LocalReranker:
- Wrap the underlying model in
Arc<T>, making it safely shareable across tasks. - Run all inference on Tokio's blocking thread pool via
tokio::task::spawn_blocking.
// Simplified view of the embed_query implementation:
let model = Arc::clone(&self.model);
let owned = text.to_owned();
tokio::task::spawn_blocking(move || model.embed(vec![owned], None)).await
This pattern keeps the async event loop responsive even during heavy inference workloads.
Implementing the core traits
LocalEmbeddings implements synwire_core::embeddings::Embeddings:
| Method | Input | Output |
|---|---|---|
embed_documents | &[String] | Vec<Vec<f32>> (batch) |
embed_query | &str | Vec<f32> (single vector) |
LocalReranker implements synwire_core::rerankers::Reranker:
| Method | Input | Output |
|---|---|---|
rerank | query, &[Document], top_n | Vec<Document> (re-ordered) |
Both return Result<T, SynwireError> — embedding failures are mapped to
SynwireError::Embedding(EmbeddingError::Failed { message }).
Error handling
| Error type | Cause |
|---|---|
LocalEmbeddingsError::Init | Model download failure or ONNX load error |
LocalRerankerError::Init | Same, for the reranker model |
EmbeddingError::Failed | Inference panicked or returned no results |
Construction errors (::new()) are separate from runtime errors. Construction
may fail due to network issues (first download) or corrupted cache files.
Runtime errors indicate ONNX inference failures or task panics.
Performance characteristics
| Operation | Typical latency (CPU) | Notes |
|---|---|---|
| Model construction | 50–200 ms (cached) | First-ever: download ~30 MB |
embed_query | 1–5 ms per query | Single text, 384-dim output |
embed_documents | ~2 ms per document (batch) | Batching amortises overhead |
rerank | 5–20 ms per candidate | Cross-encoder is heavier |
These are order-of-magnitude figures on a modern x86 CPU. Actual performance depends on text length, CPU architecture, and available cores.
See also
- Semantic Search Architecture — where embedding fits in the pipeline
- synwire-chunker — what produces the text that gets embedded
- synwire-vectorstore-lancedb — where vectors are stored
- synwire-core: Trait Contract Layer — the
EmbeddingsandRerankertraits
synwire-vectorstore-lancedb: Vector Storage with LanceDB
synwire-vectorstore-lancedb implements the VectorStore trait on top of
LanceDB, a serverless columnar vector
database built on Apache Arrow. It stores document embeddings on disk and
supports similarity search with no external server process.
Why LanceDB
Synwire's semantic search targets single-machine, offline-capable use cases — a developer's laptop indexing a codebase. This rules out hosted vector databases and favours embedded solutions:
| Requirement | LanceDB | Alternatives (Qdrant, Milvus, etc.) |
|---|---|---|
| No server process | Yes (embedded library) | Requires separate server |
| Disk-backed persistence | Yes (Lance format files) | Yes |
| No network dependency | Yes | Typically client-server |
| Apache Arrow native | Yes (zero-copy data access) | Varies |
| Rust-first | Yes | Often Python-first |
| Approximate nearest-neighbour | IVF-PQ (optional) | Yes |
LanceDB writes data as Lance files — a columnar format derived from Arrow IPC with built-in versioning and fast random access. For small-to-medium codebases (thousands to tens of thousands of chunks), the default flat scan is fast enough. IVF-PQ indexing is available for larger collections.
Schema
Each vector store table uses a fixed Arrow schema:
| Column | Arrow type | Description |
|---|---|---|
id | Utf8 | UUID v4 per chunk |
text | Utf8 | Chunk content (source code or prose) |
vector | FixedSizeList(Float32, dims) | Embedding vector (384 dims default) |
metadata | Utf8 | JSON-serialised metadata map |
The dims parameter is set at construction and must match the embedding model's
output dimensionality. LanceDbVectorStore::open() validates this on every
add_documents call — a dimension mismatch returns LanceDbError::DimensionMismatch.
Operations
LanceDbVectorStore implements synwire_core::vectorstore::VectorStore:
| Method | Description |
|---|---|
add_documents(docs, embeddings) | Embed documents, assign UUIDs, insert as Arrow record batch |
similarity_search(query, k, embeddings) | Embed query, find k nearest vectors |
similarity_search_with_score(query, k, embeddings) | Same, but returns (Document, f32) pairs |
delete(ids) | Remove documents by ID using SQL IN predicate |
Add flow
graph LR
A["Vec<Document>"] --> B["Embed texts<br/>(Embeddings trait)"]
B --> C["Build Arrow<br/>RecordBatch"]
C --> D["LanceDB<br/>table.add()"]
D --> E["Return Vec<String><br/>(assigned IDs)"]
Documents with an existing id in their metadata retain it; documents without
one receive a new UUID v4. This allows idempotent re-indexing of the same file.
Search flow
graph LR
Q["Query string"] --> E["Embed query"]
E --> S["LanceDB vector_search(k)"]
S --> R["Deserialise Arrow → (Document, score)"]
R --> F["Sort by score descending"]
Scores are L2 distances — lower is more similar. The results are sorted so the most relevant document comes first.
Error types
| Error | Cause |
|---|---|
LanceDbError::Lance(String) | LanceDB operation failed (I/O, corruption) |
LanceDbError::Embedding(String) | Embedding call failed during add/search |
LanceDbError::DimensionMismatch | Vector dims do not match table schema |
LanceDbError::NoTable | Table not found (internal state error) |
All errors are mapped to SynwireError::VectorStore(VectorStoreError::Failed { message })
when accessed through the VectorStore trait.
Disk layout
LanceDB stores data at the path provided to LanceDbVectorStore::open():
<store_path>/
├── <table_name>.lance/
│ ├── data/ ← Lance data files (columnar Arrow)
│ ├── _versions/ ← version metadata
│ └── _indices/ ← optional ANN index files
In the context of synwire-index, the store path is
$XDG_CACHE_HOME/synwire/indices/<sha256(path)>/lance/.
See also
- Semantic Search Architecture — how vector storage fits in the pipeline
- synwire-embeddings-local — the embedding model producing vectors
- synwire-index — the indexing pipeline that manages the store
- synwire-core: Trait Contract Layer — the
VectorStoretrait
synwire-index: Semantic Indexing Pipeline
synwire-index orchestrates the full indexing lifecycle: directory walking,
chunking dispatch, embedding, vector storage, cache management, and background
file watching. It is the single entry point consumed by VFS providers — they
delegate to SemanticIndex rather than assembling the pipeline themselves.
Responsibilities
| Concern | Handled by |
|---|---|
| Directory traversal | walker module (walkdir + globset) |
| File → chunks | synwire-chunker (AST + text) |
| Chunks → vectors | synwire-embeddings-local |
| Vectors → storage | synwire-vectorstore-lancedb |
| Cache location + metadata | cache module |
| Background file watching | watcher module (notify crate) |
| Status tracking + events | SemanticIndex state machine |
Lifecycle
stateDiagram-v2
[*] --> Pending: index() called
Pending --> Indexing: background task starts
Indexing --> Ready: pipeline completes
Indexing --> Failed: error during walk/chunk/embed/store
Ready --> [*]
Failed --> [*]
Ready --> Indexing: force re-index or watcher event
1. Index request
SemanticIndex::index(path, opts) is called by the VFS provider. It:
- Canonicalises the path and rejects
/withVfsError::IndexDenied. - Checks the cache (unless
opts.forceis true). Ifmeta.jsonexists and indicates a recent index, the status transitions directly toReadywithwas_cached: true. - Generates a UUID
index_idand returns anIndexHandleimmediately. - Spawns a background
tokio::taskfor the actual pipeline work.
2. Pipeline execution
The background task runs the pipeline module:
graph TD
W["walk(path, opts)"] -->|Vec of file paths| C["Chunker::chunk_file<br/>per file"]
C -->|Vec<Document>| E["Embeddings::embed_documents"]
E -->|Vec<Vec<f32>>| S["VectorStore::add_documents"]
S --> M["write meta.json"]
- Walk: Collects files matching include/exclude globs, under the max file
size limit (default 1 MiB). Uses
walkdirfor recursive traversal andglobsetfor pattern matching. - Hash check: Each file's content is hashed with xxHash128 and compared
against the stored hash in
hashes.json. Files with unchanged content are skipped entirely — no chunking, no embedding, no vector store writes. - Chunk: Changed files are passed to
Chunker::chunk_file, which dispatches to AST or text chunking based on the detected language. - Embed + Store: Document texts are batch-embedded and inserted into the vector store. Individual file failures are logged and skipped.
- Cache: On completion,
meta.jsonis written with file/chunk counts and a timestamp, andhashes.jsonis updated with current content hashes.
Progress is reported via the IndexStatus::Indexing { progress } state, where
progress is a f32 between 0.0 and 1.0 based on files processed.
3. File watcher
After a successful index, synwire-index starts a background file watcher
using the notify crate:
- Platform-native:
inotify(Linux),FSEvents(macOS),ReadDirectoryChangesW(Windows). - Debouncing: Events within a 300 ms window are coalesced. A rename-then-write sequence (common in editors) produces a single re-index.
- Content hash check: The watcher computes the xxh128 hash of the changed file and compares against its in-memory hash table. Files saved without actual content changes (e.g. editor auto-save) are skipped.
- Incremental update: Changed files are re-chunked and re-embedded. New chunks replace old ones in the vector store.
The watcher runs until SemanticIndex::unwatch(path) is called or the
SemanticIndex is dropped.
4. Search
SemanticIndex::search(path, query, opts) performs:
- Validation: Checks that the path has a ready index (either from a
completed
index()call or from cache). - Vector search: Embeds the query and calls
similarity_search_with_scoreon the vector store. - Reranking (optional, default on): Passes the top candidates through the cross-encoder reranker for more accurate scoring.
- Filtering: Applies
min_scorethreshold andfile_filterglob patterns. - Result mapping: Converts
(Document, f32)pairs intoSemanticSearchResultstructs with file, line range, content, score, symbol, and language.
Dependency injection
SemanticIndex accepts its components via constructor injection:
use synwire_index::{SemanticIndex, IndexConfig};
use synwire_embeddings_local::{LocalEmbeddings, LocalReranker};
use synwire_vectorstore_lancedb::LanceDbVectorStore;
let embeddings = Arc::new(LocalEmbeddings::new()?);
let reranker = Some(Arc::new(LocalReranker::new()?));
let store_factory = Box::new(|path: &Path| {
let rt = tokio::runtime::Handle::current();
rt.block_on(LanceDbVectorStore::open(
path.join("lance").to_string_lossy(),
"chunks",
384,
))
});
let index = SemanticIndex::new(
Chunker::new(),
embeddings,
reranker,
store_factory,
IndexConfig::default(),
None, // optional event sender
);
This design allows testing with mock embeddings and in-memory stores, and
supports swapping to different embedding models or vector backends without
changing synwire-index.
Configuration
IndexConfig controls pipeline behaviour:
| Field | Type | Default | Purpose |
|---|---|---|---|
cache_base | Option<PathBuf> | OS cache dir | Override cache location |
chunk_size | usize | 1500 | Target bytes for text chunks |
chunk_overlap | usize | 200 | Overlap bytes between text chunks |
Event notifications
An optional tokio::sync::mpsc::Sender<IndexEvent> can be provided at
construction. The pipeline emits events that the VFS provider or agent runner
can forward to the LLM:
| Event | When |
|---|---|
IndexEvent::Progress | During pipeline execution (periodic) |
IndexEvent::Complete | Pipeline finished successfully |
IndexEvent::Failed | Pipeline encountered a fatal error |
IndexEvent::FileChanged | Watcher detected a file change |
See also
- Semantic Search Architecture — pipeline overview
- synwire-chunker — chunking strategies
- synwire-embeddings-local — embedding models
- synwire-vectorstore-lancedb — vector storage
- Semantic Search Tutorial — hands-on walkthrough
- Semantic Search How-To — task-focused recipes
synwire-sandbox: Process Isolation
synwire-sandbox provides platform-specific process isolation, resource accounting, and LLM-accessible process management tools for Synwire agents. It is the crate that makes it safe for an agent to run shell commands by bounding the blast radius of agent actions.
For the design philosophy and detailed rationale behind the sandbox architecture (why not Docker, the two-tier OCI runtime model, PTY integration, cgroup v2 accounting), see Sandbox Architecture Methodology. This document focuses on the crate's API and structure.
Platform support
| Platform | Light isolation | Strong isolation |
|---|---|---|
| Linux | cgroup v2 + AppArmor | Namespace container (runc/crun) |
| macOS | sandbox-exec Seatbelt | Podman / Apple Container / Docker Desktop / Colima |
| Other | None (fallback) | None |
Crate structure
platform
Platform-specific backends:
linux::namespace--- OCI runtime spec generation, container lifecycle (create, start, wait, kill),--console-socketPTY handofflinux::cgroup---CgroupV2Managerfor per-agent resource limits (CPU, memory, PIDs) with cleanup-on-drop viacgroup.killmacos::seatbelt--- Sandbox Profile Language (SBPL) generation fromSandboxConfigmacos::container--- Container runtime detection and delegation (Apple Container, Docker Desktop, Podman, Colima)
plugin
Agent integration layer:
ProcessPlugin--- contributes five management tools:list_processes,kill_process,process_stats,wait_for_process,read_process_outputCommandPlugin(viacommand_tools) --- contributes four execution tools:run_command,open_shell,shell_write,shell_readSandboxContext--- shared state holding the process registry, sandbox configuration, and output capture settingsexpect_engine--- PTY automation viaexpectrlfor interactive commands (terraform apply, ssh host key prompts, gpg passphrase entry)
process_registry
In-memory registry tracking all spawned processes:
#![allow(unused)] fn main() { use synwire_sandbox::{ProcessRegistry, ProcessRecord, ProcessStatus}; let registry = ProcessRegistry::new(); // Processes are registered when spawned, queried by tools, // and cleaned up when the agent session ends. }
Each ProcessRecord tracks the process ID, status, spawn time, resource usage, and captured output reference.
output
Output capture infrastructure:
OutputMode--- enum controlling how process output is captured (file-backed, memory, or discarded)ProcessCapture--- manages file-backed output capture that survives process kills (cgroup OOM or timeout)CapturedOutput--- the result: stdout, stderr, and exit code
File-backed capture is the default because when an agent exceeds its resource budget and the cgroup kills its processes, in-memory pipe buffers are lost. File-backed capture ensures partial output is still recoverable.
visibility
ProcessVisibilityScope controls which processes a tool can see:
Own--- only processes spawned by this agent sessionAll--- all processes tracked by the registry (for admin tools)
error
SandboxError covers container runtime failures, cgroup operations, permission errors, PTY setup failures, and timeout conditions.
Safety
The crate uses #![deny(unsafe_code)] with a single scoped exception: receiving a PTY controller file descriptor from the OCI runtime via SCM_RIGHTS requires converting a kernel-provided raw fd to an OwnedFd. This is the minimum unsafe surface required for PTY support.
Dependencies
| Crate | Role |
|---|---|
synwire-core | Tool traits for plugin tools |
expectrl | PTY pattern matching (goexpect equivalent) |
oci-spec | OCI runtime spec generation (Linux only) |
nix | Unix system calls (Linux only) |
uuid | Process record identifiers |
chrono | Timestamps for process records |
which | Runtime binary detection |
tempfile | Temporary directories for OCI bundles |
Ecosystem position
synwire (umbrella, feature = "sandbox")
|
+-- synwire-sandbox (this crate)
|
+-- synwire-core (tool traits)
+-- synwire-agent-skills (optional: sandboxed skill execution)
synwire-sandbox is used directly by the synwire umbrella crate (behind the sandbox feature flag) and by synwire-agent-skills (behind its sandboxed feature flag).
See also
- Sandbox Architecture Methodology --- design philosophy and rationale
- Process Sandboxing --- how-to guide
- Sandboxed Command Execution --- tutorial
Sandbox Architecture Methodology
This document explains why synwire's process sandbox is designed the way it is. For setup instructions, see the Process Sandboxing how-to guide. For a hands-on walkthrough, see the Sandboxed Command Execution tutorial.
Design philosophy
An AI agent that can run shell commands is powerful but dangerous. A misguided
rm -rf / or a prompt-injection attack that exfiltrates credentials can cause
real damage. Synwire's sandbox exists to bound the blast radius of agent
actions, but it must do so without becoming an obstacle to agent performance
or developer ergonomics.
Five principles guide the design:
-
Lightweight and rootless. The sandbox must work without
sudo, without a system daemon, and without asking the user to reconfigure their machine beyond what a standard Linux desktop already provides. An unprivileged user account is the only requirement. -
Sub-second startup. Every tool call in an agent loop spawns a container. If container creation takes seconds, the agent's end-to-end latency becomes dominated by sandbox overhead rather than LLM inference. We target under 50ms for the common case.
-
Ephemeral by default. Containers are created, used for a single command, and destroyed. There is no persistent container state, no image registry, no layer cache. The host filesystem is bind-mounted directly.
-
PTY support for human-in-the-loop. Many real-world CLI tools require interactive confirmation:
terraform apply,sshhost key prompts,gpgpassphrase entry. The sandbox must provide a pseudo-terminal so that anexpect-style automation layer can drive these interactions. -
Output survives process kills. When an agent exceeds its resource budget and the cgroup kills its processes, the partial output must still be recoverable. File-backed capture (rather than in-memory pipe buffers) ensures this.
Why not Docker?
Docker is the most widely-known container runtime, so the question "why not just use Docker?" deserves a thorough answer. There are six reasons, each sufficient on its own but collectively decisive.
Docker requires a daemon
dockerd runs as root (or, in rootless mode, as a user daemon with its own
network namespace stack). An agent framework should not depend on a system
service being up and correctly configured. If dockerd crashes, restarts, or
is not installed, every agent in the system stops working. Synwire's approach
--- invoking an OCI runtime binary directly --- has no daemon dependency. The
runtime binary (runc or runsc) is a standalone executable with no
long-running state.
Docker socket access is root-equivalent
The common pattern of mounting /var/run/docker.sock into a container grants
that container full control over the Docker daemon, which runs as root. This
means any container with socket access can create privileged containers, mount
the host filesystem, and effectively become root on the host. This is not a
theoretical concern --- it is a well-documented container escape vector. For
an AI agent sandbox, where the entire purpose is to constrain what the agent
can do, granting Docker socket access defeats the goal entirely.
Docker startup latency is too high
Creating a Docker container involves multiple steps: the client sends a request
to dockerd, which communicates with containerd, which calls the OCI
runtime. Image layers may need to be pulled, unpacked, or verified. Even with
a warm image cache, container creation typically takes 1--5 seconds. By
contrast, runc run with a pre-built OCI bundle on a local filesystem
completes in roughly 50ms. Over a 20-step agent loop, this difference is
between 1 second and nearly 2 minutes of pure sandbox overhead.
Docker is designed for services, not ephemeral commands
Docker's architecture --- images, layers, registries, build caches, named volumes, networks --- is optimised for long-running services. An agent sandbox needs none of this. We bind-mount the host working directory into a minimal rootfs, run a single command, collect the output, and tear everything down. The image/layer abstraction adds complexity (and latency) without providing value in this use case.
Rootless Docker exists but is fragile
Docker does offer a rootless mode (dockerd-rootless-setuptool.sh), but it
introduces its own stack of dependencies: slirp4netns or pasta for
networking, fuse-overlayfs for the storage driver, and a user-space
dockerd that must be running. Filesystem permission edge cases are common
(files created inside the container may have unexpected ownership on the
host). The rootless stack is less tested than the standard root-mode stack
and has historically been a source of hard-to-debug issues.
We need the OCI runtime, not the orchestrator
Docker internally delegates to containerd, which delegates to runc (or
another OCI-compliant runtime). The Docker daemon and containerd provide
orchestration features --- image management, networking, health checks,
restart policies --- that an ephemeral agent sandbox does not need. By
calling the OCI runtime directly, we cut out two layers of indirection and
their associated latency, complexity, and failure modes.
Linux: runc and gVisor two-tier model
On Linux, synwire supports two OCI runtimes via the OciRuntime enum, each
representing a different point on the isolation/performance trade-off curve.
runc: namespace isolation
runc is the reference OCI runtime, maintained by the Open Container Initiative. It uses Linux kernel namespaces (PID, mount, UTS, IPC, network, user) combined with seccomp BPF filters and a minimal capability set to isolate the container process.
The key characteristic of runc is that container processes share the host kernel. Isolation relies on the kernel correctly enforcing namespace boundaries. This is well-understood and battle-tested --- it is the same mechanism that Docker, Podman, and Kubernetes use --- but it means that a kernel vulnerability in namespace handling could theoretically allow escape.
For agent workloads, this trade-off is usually acceptable. The threat model is typically accidental damage (the LLM generates a destructive command) rather than adversarial kernel exploitation. runc provides strong boundaries against accidental damage with minimal overhead: container startup takes roughly 50ms.
gVisor: user-space kernel
gVisor (runsc) provides a fundamentally different isolation model. Instead
of sharing the host kernel, gVisor interposes a user-space kernel called the
Sentry between the container and the host. Every syscall from the
containerised process is intercepted and re-implemented by the Sentry in Go.
The Sentry itself runs with a minimal set of host syscalls, so even if the
containerised process triggers a bug in syscall handling, the blast radius is
confined to the Sentry process rather than the host kernel.
This provides substantially stronger isolation: kernel exploits that would escape a namespace-based container are blocked because the container never interacts with the real kernel. The trade-off is higher startup latency (roughly 200ms) and some syscall compatibility limitations (the Sentry does not implement every Linux syscall). For untrusted code execution or multi-tenant scenarios, this trade-off is worthwhile.
Systrap vs ptrace
gVisor supports two mechanisms for intercepting syscalls from container processes:
-
Systrap patches syscall instruction sites in memory at runtime, replacing
syscallinstructions with traps that the Sentry handles directly. This is faster (roughly 10% overhead vs native) but requiresCAP_SYS_PTRACEin the sandbox's ambient capability set. -
Ptrace uses Linux's
PTRACE_SYSEMUandCLONE_PTRACEto intercept syscalls. It is slower but universally compatible, requiring no special capabilities.
Synwire probes systrap on first use by running a trivial container
(/bin/true). If it succeeds, systrap is used for all subsequent containers
in the process. If it fails --- commonly because rootless mode with host
networking does not propagate CAP_SYS_PTRACE --- synwire falls back to
ptrace and caches the decision. There are no repeated probes and no user
configuration required.
Cgroup v2 resource accounting
Independent of which OCI runtime is selected, synwire tracks resource
consumption via cgroup v2. The CgroupV2Manager creates per-agent cgroups
as siblings of the synwire process's own cgroup:
user@1000.service/
app.slice/
code.scope/ <-- synwire process lives here
synwire/
agents/<uuid>/ <-- agent cgroups go here
Placing agent cgroups under the process cgroup's parent (rather than as children) avoids the cgroup v2 "no internal processes" constraint: a cgroup that enables subtree controllers must not have processes of its own. Nesting under the parent keeps the hierarchy close to the synwire process while remaining legal under cgroup v2 rules.
Resource limits (CPU, memory, PIDs) are written to the agent cgroup's control
files. When the agent terminates, the CgroupV2Manager's Drop
implementation writes 1 to cgroup.kill (Linux 5.14+) for immediate
cleanup, falling back to per-PID SIGKILL on older kernels.
macOS: Seatbelt and container runtimes
macOS has no equivalent of Linux namespaces. The kernel does not support PID, mount, or network namespaces, so the Linux approach of calling an OCI runtime directly is not possible. Synwire provides two isolation tiers on macOS, each using platform-native mechanisms.
Seatbelt: policy enforcement
Apple's Seatbelt framework (sandbox-exec) provides a deny-by-default policy
enforcement mechanism. Synwire generates Sandbox Profile Language (SBPL)
profiles from SandboxConfig at runtime, then spawns the agent command via
sandbox-exec -p <profile> -- <command>.
Seatbelt is lightweight --- effectively zero overhead beyond the policy
check on each syscall --- and requires no additional software. Its limitations
are significant, though: there is no process namespace (the sandboxed process
can see all host processes via ps), no network namespace, and no resource
limits. Seatbelt constrains what a process can access but not how much
CPU or memory it can consume.
Additionally, Apple has deprecated the public sandbox-exec interface. It
continues to work on current macOS versions and is widely used by build
systems (Nix, Bazel), but Apple has not provided a public replacement. Synwire
will migrate if one becomes available.
Strong isolation via container runtimes
For stronger isolation on macOS, a Linux kernel is required --- which means
a virtual machine. Synwire supports four container runtimes, detected in
priority order by detect_container_runtime():
- Apple Container (preferred)
- Docker Desktop (widely installed)
- Podman (fallback)
- Colima (last resort)
Apple Container: lightweight Linux VMs via Virtualization.framework
Apple Container is Apple's first-party tool for running Linux containers as lightweight virtual machines. It uses macOS Virtualization.framework directly, avoiding the overhead of a general-purpose VM manager. Containers start as minimal Linux VMs with automatic file sharing and port forwarding, similar in spirit to Podman Machine but with tighter system integration and lower overhead since the hypervisor layer is built into macOS itself.
Apple Container requires macOS 26 (Tahoe) or later and Apple Silicon. When these requirements are met, it is the preferred runtime because:
- It is maintained by Apple and uses the same Virtualization.framework that powers macOS's own virtualisation features.
- No third-party daemon or VM manager is needed ---
containeris a single standalone binary. - Startup latency is lower than Podman Machine because Virtualization.framework VMs are purpose-built for lightweight workloads.
Synwire translates SandboxConfig into container run flags (volumes,
resource limits, network policy) in the same way it does for Podman.
Docker Desktop: daemon-based containers for macOS
Docker Desktop has the largest install base of any container runtime on macOS. While synwire does not use Docker on Linux (see Why not Docker? above --- the daemon model, root-equivalence concerns, and startup latency make it unsuitable for direct OCI runtime use), the macOS situation is different: every macOS container runtime already runs a Linux VM, so the daemon-in-a-VM architecture does not add an extra layer of indirection the way it does on Linux.
Synwire detects Docker Desktop by running docker version (not
docker --version). The --version flag only checks the CLI binary; docker version queries the daemon and fails if the Docker Desktop VM is not running.
This avoids false positives where the CLI is installed but the backend is
stopped.
Docker Desktop, Podman, and Colima share identical CLI flag semantics
(docker run / podman run), so synwire translates SandboxConfig into the
same set of flags for all three: --volume, --network, --memory, --cpus,
--user, and --security-opt no-new-privileges.
Note on Linux: Docker Desktop is not supported on Linux. On Linux, synwire calls OCI runtimes (
runc,runsc) directly, bypassing any daemon. Docker Desktop is only used on macOS where VM-based isolation is already the norm.
Podman: OCI containers in a managed Linux VM
Podman (podman machine) runs a lightweight Linux VM and manages OCI
containers inside it. Synwire translates SandboxConfig into
podman run --rm flags (volumes, network, memory, CPU limits).
Podman is the fallback when neither Apple Container nor Docker Desktop is available --- either because the Mac is running macOS 25 or earlier without Docker, or because it has an Intel processor without Docker Desktop installed. Podman supports both Apple Silicon and Intel Macs and has no macOS version restriction beyond what Homebrew requires.
Colima: lightweight Docker-compatible VM
Colima wraps Lima to provide a Docker-compatible environment with minimal
configuration. Unlike bare Lima (which uses limactl shell to run commands
inside a VM), Colima exposes a Docker socket so that the standard docker run
CLI works transparently.
Synwire detects Colima by running colima status to check that the Colima VM
is running, then delegates to docker run for container execution. Because
Colima surfaces a Docker-compatible socket, it shares the same CLI flag
semantics as Docker Desktop and Podman.
Colima is the last-resort runtime, used only when Apple Container, Docker Desktop, and Podman are all unavailable. It provides a functional but less integrated experience compared to the other options.
The OCI runtime spec as the unifying abstraction
Synwire does not generate runtime-specific command-line flags. Instead, it
produces an OCI runtime specification --- a JSON document
(config.json) placed in a bundle directory --- and hands that bundle to
whichever runtime is selected.
This design provides several benefits:
-
Runtime portability. The same spec works with runc, gVisor (
runsc), crun, youki, and any future OCI-compliant runtime. Switching runtimes is a configuration change, not a code change. -
Compile-time correctness. The
oci-specRust crate provides typed builders (SpecBuilder,ProcessBuilder,LinuxBuilder, etc.) that catch field-name mistakes and missing required fields at compile time. A typo in a namespace type or a missing mount option is a compilation error, not a runtimeEINVAL. -
Inspectability. The spec is a JSON file on disk. When debugging container issues, developers can read the generated
config.jsondirectly, modify it, and re-run the container manually withrunc run --bundle /tmp/synwire-xxx test-id. No opaque API calls to reverse-engineer.
The internal pipeline is:
SandboxConfig --> ContainerConfig --> oci_spec::runtime::Spec --> config.json
SandboxConfig is synwire's user-facing configuration type (security presets,
filesystem paths, resource limits). ContainerConfig is the platform-specific
intermediate representation. The build_oci_spec function in the Linux
namespace module transforms ContainerConfig into a typed Spec via the
oci-spec builders, which is then serialised to JSON in the bundle directory.
PTY and expect integration
Many CLI tools that agents need to drive require interactive input: terraform apply asks for confirmation, ssh prompts for host key verification, gpg
requests a passphrase. Piping "yes" to stdin is fragile and tool-specific.
A pseudo-terminal (PTY) with pattern-matching automation is the robust
solution.
The OCI runtime spec supports a terminal: true flag that tells the runtime
to allocate a PTY inside the container. The runtime delivers the PTY
controller file descriptor to the caller via a Unix domain socket (the
--console-socket mechanism), using SCM_RIGHTS ancillary data to pass the
fd across process boundaries.
Synwire wraps the received fd in an expectrl session, which
provides goexpect-equivalent pattern matching: wait for a regex, send a
response, set timeouts. This works cross-platform --- expectrl handles the
differences between Linux and macOS PTY allocation internally.
The file-descriptor handoff is the key design choice. Rather than running
docker exec -it (which requires a running container and a daemon), the
--console-socket mechanism gives synwire direct ownership of the PTY
controller fd immediately after container creation. There is no intermediary
process, no daemon RPC, and no risk of the PTY being torn down by a
container lifecycle event.
Comparison
| Docker (Linux) | runc (synwire) | gVisor (synwire) | macOS Seatbelt | Apple Container | Docker Desktop (macOS) | Podman (macOS) | Colima (macOS) | |
|---|---|---|---|---|---|---|---|---|
| Requires daemon | Yes | No | No | No | No | Yes (VM) | Yes (VM) | Yes (VM) |
| Requires root | Yes* | No | No | No | No | No | No | No |
| Startup latency | 1--5s | ~50ms | ~200ms | ~10ms | ~100ms | ~500ms | ~500ms | ~500ms |
| Kernel isolation | Namespaces | Namespaces | User-space kernel | Policy enforcement | VM (Virtualization.framework) | VM + namespaces | VM + namespaces | VM + namespaces |
| PTY support | docker exec -it | --console-socket | --console-socket | Native | Native | docker exec -it | podman exec -it | docker exec -it |
| Syscall filtering | Seccomp | Seccomp | Sentry kernel | SBPL | Full Linux kernel | Seccomp | Seccomp | Seccomp |
| Resource limits | cgroups | cgroups v2 | cgroups v2 | None | VM-level | cgroups v2 (in VM) | cgroups v2 (in VM) | cgroups v2 (in VM) |
| macOS requirement | N/A | N/A | N/A | Any macOS | macOS 26+, Apple Silicon | Any macOS | Any macOS | Any macOS |
* Rootless Docker exists but introduces significant complexity; see Why not Docker? above.
synwire-lsp: Language Server Integration
An AI agent that can read files and run grep has access to the textual content of a codebase. It does not, however, have access to the meaning of that content. It cannot answer "what type does this variable have?" without parsing the source code, resolving imports, and evaluating type inference. Language servers exist precisely to provide this semantic intelligence. The synwire-lsp crate bridges the Language Server Protocol into the synwire agent runtime, giving agents structured access to type information, diagnostics, symbol navigation, and refactoring operations.
The Problem: Text Search Is Not Code Understanding
Consider an agent asked to rename a function across a codebase. With text search alone (grep or VFS grep), the agent can find occurrences of the function name. But the function name might also appear in comments, string literals, or as a substring of a longer identifier. The agent has no way to distinguish a call site from a coincidental match. It cannot know whether two identically-named functions in different modules refer to the same symbol or to distinct definitions.
A language server resolves these ambiguities. It maintains a semantic model of the codebase: the parse tree, the type environment, the symbol table, the import graph. When asked for "references to function foo in module bar", it returns exactly the call sites, method references, and re-exports that resolve to that definition. When asked for diagnostics, it returns the compiler's own assessment of errors and warnings — not a heuristic, but the same analysis the compiler would perform.
The challenge is that the Language Server Protocol is a stateful, bidirectional, asynchronous protocol. It was designed for IDEs, not for AI agents. Integrating it into synwire requires solving several problems: lifecycle management, capability negotiation, document synchronisation, notification bridging, and crash recovery.
Architecture
The crate is structured as a plugin that owns an LSP client, which in turn communicates with an external language server process:
graph LR
subgraph "Synwire Agent Runtime"
Agent["Agent Runner"]
Plugin["LspPlugin"]
Client["LspClient"]
Tools["lsp_tools()"]
end
subgraph "External Process"
ML["async-lsp MainLoop"]
LS["Language Server<br/>(rust-analyzer, gopls, etc.)"]
end
Agent -->|"uses tools"| Tools
Tools -->|"sends requests"| Client
Plugin -->|"owns"| Client
Client -->|"JSON-RPC over stdio"| ML
ML -->|"stdio transport"| LS
The notification path flows in the opposite direction:
graph RL
LS["Language Server"] -->|"notification"| ML["MainLoop"]
ML -->|"dispatch"| LCH["LanguageClient handler"]
LCH -->|"NotificationContext"| HR["HookRegistry"]
HR -->|"AgentEvent::TaskNotification"| Agent["Agent Runner"]
Agent -->|"SignalKind::Custom"| CR["ComposedRouter"]
LspPlugin implements the Plugin trait. It registers signal routes, contributes tools via Plugin::tools(), and bridges notifications through the HookRegistry. LspClient is the typed wrapper around the async-lsp concurrency machinery — it sends JSON-RPC requests and receives responses, while the MainLoop handles the transport-level framing and multiplexing.
LanguageClient Notification Bridging
The LSP protocol is not purely request-response. The server sends unsolicited notifications to the client: textDocument/publishDiagnostics when errors change, window/showMessage for user-facing messages, window/logMessage for debug output. In an IDE, these drive UI updates. In synwire, they must be routed into the agent's event and signal systems.
LspPlugin implements the LanguageClient trait from async-lsp, which provides callback methods for each notification type. The implementation of each callback does two things:
-
Fires a
NotificationContexthook via theHookRegistry. This allows any registered notification hook to observe the event. A diagnostic notification, for example, produces aNotificationContextwithlevel: "diagnostics"and a message containing the file URI and diagnostic summary. -
Emits an
AgentEvent::TaskNotificationwith aCustomkind string. For diagnostics, this is"lsp_diagnostics_changed"; for server crashes,"lsp_server_crashed". The event carries a JSON payload with the full notification data.
The TaskNotification event is then available to the signal routing system. An agent that has registered a route for SignalKind::Custom("lsp_diagnostics_changed".into()) will receive the signal and can react — for example, by invoking the lsp_diagnostics tool to read the current diagnostic set and attempting fixes.
This two-path delivery — hooks for observation, signals for reactive behaviour — follows the same pattern used throughout synwire. Hooks are for logging, auditing, and side-effect-free reactions. Signals are for directing the agent's next action.
Capability-Conditional Tool Generation
Not every language server supports every LSP feature. A minimal server might support textDocument/completion and textDocument/diagnostics but not textDocument/rename or textDocument/formatting. Exposing a lsp_rename tool to the agent when the server does not support renaming would produce confusing errors at runtime.
The lsp_tools() function follows the same pattern as vfs_tools() in synwire-core. It takes the ServerCapabilities returned by the server's initialize response and generates only those tools that the server has declared support for:
pub fn lsp_tools(
client: Arc<LspClient>,
capabilities: &ServerCapabilities,
) -> Vec<Arc<dyn Tool>> {
let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
// Always available — diagnostics come from notifications, not a capability
tools.push(Arc::new(LspDiagnosticsTool::new(Arc::clone(&client))));
if capabilities.hover_provider.is_some() {
tools.push(Arc::new(LspHoverTool::new(Arc::clone(&client))));
}
if capabilities.definition_provider.is_some() {
tools.push(Arc::new(LspGotoDefinitionTool::new(Arc::clone(&client))));
}
if capabilities.references_provider.is_some() {
tools.push(Arc::new(LspReferencesTool::new(Arc::clone(&client))));
}
if capabilities.rename_provider.is_some() {
tools.push(Arc::new(LspRenameTool::new(Arc::clone(&client))));
}
// ... further capability checks for formatting, code actions, etc.
tools
}
This approach ensures the agent's tool set accurately reflects what the language server can do. The agent never sees a tool it cannot use. If the server is upgraded to support additional capabilities, the tools appear automatically on the next connection without any configuration change.
The VfsCapabilities bitflag pattern from the VFS module serves as the conceptual precedent. There, each VFS operation has a corresponding capability flag, and vfs_tools() only generates tools for capabilities the provider declares. The LSP case is structurally identical, but the capabilities come from the ServerCapabilities struct defined by the LSP specification rather than from a custom bitflag.
Document Synchronisation
The LSP protocol requires the client to notify the server when documents are opened, modified, or closed. In an IDE, keystrokes trigger textDocument/didChange. In synwire, file mutations happen through VFS tools — vfs_write, vfs_edit, vfs_append. The language server must be informed of these changes so that its semantic model stays current.
The synchronisation is driven by a PostToolUse hook registered by LspPlugin:
sequenceDiagram
participant Agent
participant VfsTool as vfs_edit
participant Hook as LspPlugin PostToolUse
participant Client as LspClient
participant LS as Language Server
Agent->>VfsTool: invoke("vfs_edit", args)
VfsTool-->>Agent: ToolOutput (success)
Agent->>Hook: PostToolUseContext { tool_name: "vfs_edit", ... }
Hook->>Hook: Extract file path from arguments
Hook->>Client: did_open(uri, content) or did_change(uri, content)
Client->>LS: textDocument/didOpen or textDocument/didChange
LS-->>LS: Re-analyse
LS->>Client: textDocument/publishDiagnostics
Client->>Agent: NotificationContext + TaskNotification
The hook inspects the PostToolUseContext to determine which file was affected. If the file has not been opened with the language server yet, it sends textDocument/didOpen with the full file content. If it was already open, it sends textDocument/didChange.
The initial implementation uses full-document synchronisation (TextDocumentSyncKind::Full). Each change notification includes the entire file content. This is simpler and more robust than incremental synchronisation, which requires the client to compute precise text edits (line/character offsets, insertion ranges) that exactly match the server's expected document state. A mismatch in incremental sync causes the server's model to diverge from the actual file content — a class of bug that is difficult to detect and produces misleading diagnostics.
Full-document sync is measurably less efficient for large files. A single-character edit in a 10,000-line file transmits 10,000 lines over the stdio pipe. For the typical agent workflow — where file edits are infrequent relative to LLM inference time — this overhead is negligible. If profiling reveals it to be a bottleneck, incremental sync can be added later without changing the plugin's external interface.
When a VFS tool deletes a file (vfs_rm), the hook sends textDocument/didClose. The server can then release memory associated with the file's analysis state.
Auto-Start and Crash Recovery
Language servers are external processes. They can fail to start (binary not found), crash during operation (segfault, OOM), or hang (deadlock). The agent should not fail permanently when the language server is unavailable — it should degrade gracefully to text-based tools and attempt recovery.
Server startup follows this sequence:
-
Registry lookup: The
LspPluginconfiguration specifies a language identifier (e.g.,"rust","go","python"). The language server registry maps this to a server binary name and default arguments. -
Binary discovery:
which::which()locates the binary on$PATH. If the binary is not found, the plugin logs a warning and enters a dormant state. No LSP tools are registered. The agent operates without language intelligence. -
Process spawn: The binary is launched as a child process with stdio transport. The
async-lspMainLoopmanages the stdin/stdout pipes. -
Initialisation handshake: The plugin sends
initializewith the workspace root and client capabilities. The server responds with itsServerCapabilities. Tool generation happens at this point.
Crash recovery uses exponential backoff:
stateDiagram-v2
[*] --> Discovering: Plugin initialisation
Discovering --> Starting: Binary found
Discovering --> Dormant: Binary not found
Starting --> Ready: Initialize response received
Starting --> Restarting: Spawn or init failed
Ready --> Restarting: Server process exited
Ready --> Dormant: Shutdown requested
Restarting --> Starting: Backoff elapsed
Restarting --> Dormant: Max retries exceeded
Dormant --> [*]
When the server process exits unexpectedly, the plugin emits a SignalKind::Custom("lsp_server_crashed".into()) signal and enters the Restarting state. The backoff sequence is 1s, 2s, 4s, 8s, 16s, capped at 30s. After five consecutive failures without a successful initialize, the plugin enters Dormant and logs a permanent warning. LSP tools are removed from the agent's tool set.
During the restart window, LSP tool invocations return a structured error message explaining that the language server is restarting. This allows the agent to fall back to VFS-based text search rather than treating the failure as a hard error.
Language Server Registry
The registry provides a mapping from language identifiers to server binaries and default arguments. It ships with built-in entries derived from langserver.org:
| Language | Binary | Default Arguments |
|---|---|---|
rust | rust-analyzer | (none) |
go | gopls | serve |
python | pylsp | (none) |
typescript | typescript-language-server | --stdio |
c | clangd | (none) |
The registry is extensible via configuration. An agent builder can add custom entries or override built-in ones:
let plugin = LspPlugin::builder()
.language("rust")
.server_binary("rust-analyzer")
.server_args(vec!["--log-file=/tmp/ra.log".into()])
.registry_override("haskell", "haskell-language-server-wrapper", vec!["--lsp".into()])
.build()?;
The registry stores entries as plain data — binary name, argument list, optional environment variables. It does not attempt to install missing servers. Installation is the responsibility of the environment (container image, Nix shell, system package manager). The agent's role is to use what is available, not to provision infrastructure.
Trade-offs
Stateful servers consume memory and startup time. A language server for a large Rust project may take 10-30 seconds to fully index the workspace and consume several hundred megabytes of RAM. For short-lived agent tasks — answering a single question about a codebase — this startup cost may exceed the cost of the task itself. The dormant/ready state machine mitigates this by deferring startup until the first LSP tool invocation, but the fundamental cost remains.
Synchronisation lag between VFS edits and LSP state. After a vfs_edit triggers didChange, the language server must re-analyse the affected files. For rust-analyzer, this can take seconds for a change that affects type inference across many modules. During this window, diagnostics and hover information reflect the pre-edit state. The agent has no reliable way to know when re-analysis is complete — LSP provides no "analysis finished" notification in the standard protocol.
Single-workspace limitation. The current design opens one language server per workspace root. An agent working across multiple repositories would need multiple LspPlugin instances, each with its own server process. Multi-root workspace support (available in some servers) is a possible future extension but adds configuration complexity.
Protocol version coupling. The async-lsp crate tracks a specific LSP protocol version. Servers that implement newer protocol features may not be fully utilised; servers that implement older versions may produce unexpected behaviour if the client sends requests they do not recognise. The capability-conditional tool generation partially mitigates this — the agent only sees tools for capabilities the server actually declared — but edge cases remain around optional fields and protocol extensions.
See also: For how to configure and use LSP tools in an agent, see the LSP Integration how-to guide. For how LSP notifications integrate with the hook and signal systems, see the LSP/DAP Event Integration explanation. For the VFS tool generation pattern that lsp_tools() follows, see the VFS Providers how-to guide. For the three-tier signal routing that handles lsp_diagnostics_changed signals, see the Three-Tier Signal Routing explanation.
synwire-dap: Debug Adapter Integration
When an agent writes code and the tests fail, the typical recovery strategy is to re-read the code, re-read the error message, and guess at a fix. This works for straightforward compilation errors. It works poorly for logic errors — the kind where the code compiles, the types check, and the output is simply wrong. A human developer in this situation reaches for a debugger. The synwire-dap crate gives agents the same capability, integrating the Debug Adapter Protocol into the synwire runtime so that agents can set breakpoints, step through execution, and inspect runtime values.
The Problem: Guessing at Runtime Behaviour
Consider a test that asserts a function returns 42 but the function returns 41. The agent can read the function's source code and attempt to trace the logic mentally. For simple functions this works. For functions that involve mutable state, loop counters, conditional branches based on runtime data, or interactions between multiple modules, mental simulation is unreliable — even for experienced human developers.
A debugger eliminates the guesswork. The agent sets a breakpoint at the start of the function, runs the test under the debugger, and observes the actual values of variables at each step. It can inspect the call stack to understand how the function was reached. It can evaluate expressions in the debuggee's context to test hypotheses. The information it obtains is ground truth, not inference.
The Debug Adapter Protocol (DAP) standardises this interaction. Originally developed by Microsoft for VS Code, it defines a JSON-based protocol between a client (the IDE, or in this case, the agent) and a debug adapter (a process that mediates between the client and the actual debugger). Debug adapters exist for most major languages: codelldb and cppvsdbg for C/C++/Rust, dlv-dap for Go, debugpy for Python, the Node.js built-in inspector for JavaScript.
Architecture
The crate is structured as a plugin with a transport layer that manages the DAP wire protocol:
graph LR
subgraph "Synwire Agent Runtime"
Agent["Agent Runner"]
Plugin["DapPlugin"]
Client["DapClient"]
Tools["dap_tools()"]
end
subgraph "Transport"
DT["DapTransport<br/>(ContentLengthCodec)"]
end
subgraph "External Processes"
DA["Debug Adapter<br/>(codelldb, dlv-dap, etc.)"]
Debuggee["Debuggee Process"]
end
Agent -->|"uses tools"| Tools
Tools -->|"sends requests"| Client
Plugin -->|"owns"| Client
Client -->|"DAP messages"| DT
DT -->|"stdio"| DA
DA -->|"debug protocol"| Debuggee
The event path flows back:
graph RL
Debuggee["Debuggee"] -->|"debug event"| DA["Debug Adapter"]
DA -->|"DAP event"| DT["DapTransport"]
DT -->|"dispatch"| Client["DapClient"]
Client -->|"NotificationContext"| HR["HookRegistry"]
HR -->|"AgentEvent::TaskNotification"| Agent["Agent Runner"]
Agent -->|"SignalKind::Custom"| CR["ComposedRouter"]
DapPlugin implements the Plugin trait, contributing tools and signal routes. DapClient manages the request-response correlation — DAP uses sequential integer seq numbers rather than opaque IDs, and the client maintains a map from seq to response channel. DapTransport handles the wire protocol framing.
DAP Session State Machine
A DAP session progresses through a well-defined sequence of states. The agent cannot set breakpoints before the adapter is configured, cannot inspect variables before the debuggee is stopped, and cannot continue execution after the debuggee has terminated. The state machine enforces these constraints:
stateDiagram-v2
[*] --> NotStarted: DapPlugin created
NotStarted --> Initializing: launch() or attach()
Initializing --> Configured: initialized event received
Configured --> Running: configurationDone response
Running --> Stopped: stopped event (breakpoint, step, exception)
Stopped --> Running: continue or step command
Stopped --> Running: evaluate (does not change state)
Running --> Terminated: terminated event or exited event
Stopped --> Terminated: disconnect command
Terminated --> NotStarted: Session can be restarted
Terminated --> [*]
Each state determines which tools are available to the agent:
- NotStarted: Only
debug.launchanddebug.attachare functional. Other tools return an error explaining the session has not started. - Initializing: No tools are functional. The plugin is waiting for the adapter to signal readiness.
- Configured: The plugin sends
configurationDone. This state is transient — it transitions toRunningimmediately. - Running:
debug.set_breakpointsanddebug.pauseare functional. Inspection tools (debug.stack_trace,debug.variables,debug.evaluate) return errors because no thread is stopped. - Stopped: All tools are functional. This is the state where the agent can inspect the debuggee: read the call stack, examine variables, evaluate expressions, then continue or step.
- Terminated: Only
debug.launch(to start a new session) is functional.
The state machine is encoded as an enum in DapClient, and each tool checks the current state before sending a request to the adapter. This prevents the agent from issuing protocol-invalid requests and receiving confusing error messages from the adapter.
ContentLengthCodec
DAP uses the same wire format as LSP: Content-Length: N\r\n\r\n{json}. Each message is a JSON object preceded by an HTTP-style header declaring its byte length. The ContentLengthCodec implements tokio_util::codec::Decoder and tokio_util::codec::Encoder for this format.
The implementation is straightforward: the decoder reads bytes until it finds the \r\n\r\n separator, parses the Content-Length value, then reads exactly that many bytes and deserialises the JSON. The encoder serialises the JSON, prepends the Content-Length header, and writes both to the output buffer.
A natural question is why this codec is not shared with the LSP integration. The async-lsp crate, which synwire-lsp depends on, includes its own transport implementation with the same framing. However, async-lsp is built around the LSP message schema — JSON-RPC 2.0 with method, id, params, result, error fields. DAP uses a different schema: messages have seq, type ("request", "response", "event"), command, body, and success fields. The framing is identical, but the message structure is not.
Rather than abstracting the framing away from both protocols (which would require a generic transport parameterised over message type, adding complexity for minimal benefit), each crate owns its transport. The ContentLengthCodec in synwire-dap is approximately 80 lines of code. Duplication at this scale is a reasonable trade-off against the coupling that a shared transport abstraction would introduce.
The correlation model also differs. LSP uses JSON-RPC id fields — the client assigns an ID to each request, and the server echoes it in the response. DAP uses seq and request_seq — each message has a monotonically increasing sequence number, and responses reference the seq of the request they answer. DapClient maintains a HashMap<i64, oneshot::Sender<DapResponse>> to route responses to the correct waiting future.
Event Bridging
Debug adapters emit events asynchronously: stopped when the debuggee hits a breakpoint, output when the debuggee writes to stdout or stderr, terminated when the debuggee exits. These events must reach the agent so it can react.
The bridging follows the same two-path pattern as synwire-lsp:
Notification hooks: Each DAP event fires a NotificationContext through the HookRegistry. The stopped event, for instance, produces:
NotificationContext {
message: format!(
"Debuggee stopped: reason={}, thread_id={}",
event.reason, event.thread_id
),
level: "dap_stopped".to_string(),
}
Task notifications and signals: The event is also emitted as an AgentEvent::TaskNotification with a Custom kind:
| DAP Event | Signal Kind | Payload |
|---|---|---|
stopped | dap_stopped | { "reason": "breakpoint", "thread_id": 1, "all_threads_stopped": true } |
output | dap_output | { "category": "stdout", "output": "test output line\n" } |
terminated | dap_terminated | { "restart": false } |
exited | dap_exited | { "exit_code": 0 } |
The dap_stopped signal is the most important for reactive agent behaviour. When the agent receives it, the debuggee is paused and all inspection tools are available. The agent can read the stack trace, examine variables in each frame, evaluate expressions, and decide whether to continue, step, or disconnect.
The dap_output signal delivers debuggee stdout/stderr to the agent. This is valuable for understanding program behaviour without setting breakpoints — the agent can observe log output in real time.
Security Considerations
The debug.evaluate tool deserves special attention. It sends an evaluate request to the debug adapter, which executes an arbitrary expression in the debuggee's runtime context. In Python, this means arbitrary Python code execution. In Go, it means expression evaluation with access to all in-scope variables and functions. In Rust (via codelldb), it can call functions with side effects.
This makes debug.evaluate the most powerful — and most dangerous — tool in the DAP tool set. It is marked with metadata that the permission system uses to gate access:
ToolSchema {
name: "debug.evaluate".into(),
description: "Evaluate an expression in the debuggee's context. \
WARNING: can execute arbitrary code.".into(),
parameters: json!({
"type": "object",
"properties": {
"expression": { "type": "string" },
"frame_id": { "type": "integer" },
"context": {
"type": "string",
"enum": ["watch", "repl", "hover"]
}
},
"required": ["expression"]
}),
}
The tool's ToolSchema metadata marks it as destructive: true and open_world: true. The destructive flag indicates that the tool can modify external state (the debuggee's memory, files, network connections). The open_world flag indicates that the set of possible effects is unbounded — the expression is arbitrary, so the tool cannot enumerate what it might do.
These flags interact with the PermissionMode system. In PermissionMode::Default, the approval gate will prompt the user before executing debug.evaluate. In PermissionMode::PlanOnly, it is blocked entirely. In PermissionMode::BypassAll, it executes without confirmation. The DenyUnauthorized mode blocks it unless an explicit PermissionRule with behavior: Allow matches the debug.evaluate tool pattern.
Other DAP tools — debug.set_breakpoints, debug.stack_trace, debug.variables, debug.continue, debug.step_over — are observation and control tools. They do not execute arbitrary code in the debuggee and are not marked as destructive.
Trade-offs
Debug sessions are resource-intensive. Running a debuggee under a debug adapter is slower than running it natively. The adapter adds overhead for breakpoint management, memory inspection, and protocol marshalling. For large test suites, running under the debugger may be impractical. The agent should use debugging selectively — for specific failing tests, not as a general test runner.
Adapter availability varies by language. Rust debugging via codelldb is mature. Go debugging via dlv-dap is solid. Python debugging via debugpy is well-established. But some languages lack a DAP-compatible adapter entirely, and some adapters have incomplete or buggy implementations of certain DAP features. The capability-conditional approach used for tool generation mitigates this somewhat, but there is no equivalent of ServerCapabilities in DAP — the adapter does not declare which optional features it supports. Discovery is empirical.
The agent must understand debugging concepts. Setting a breakpoint requires knowing a file path and line number. Navigating a stack trace requires understanding call frames. Evaluating expressions requires knowledge of the debuggee's language syntax. The system prompt for an agent using DAP tools must include guidance on debugging strategies — this is not a tool that works well when the agent has no prior knowledge of debugging workflows.
Session state complicates checkpointing. A debug session includes the state of the debuggee process, which cannot be serialised into a synwire checkpoint. If the agent is checkpointed and resumed, the debug session is lost. The resumed agent would need to re-launch the debugger and re-set breakpoints. This is an inherent limitation of integrating with external stateful processes.
See also: For how to configure and use DAP tools in an agent, see the DAP Integration how-to guide. For how DAP events integrate with hooks and signals alongside LSP notifications, see the LSP/DAP Event Integration explanation. For how the approval gate system handles debug.evaluate, see the Approval Gates how-to guide. For permission modes that affect destructive tools, see the Permission Modes how-to guide.
LSP/DAP Event Bus Integration
The Language Server Protocol and the Debug Adapter Protocol are both asynchronous, event-driven protocols. A language server may emit diagnostic notifications at any time — not in response to a request, but because it finished re-analysing a file that changed minutes ago. A debug adapter emits stopped events when the debuggee hits a breakpoint, which may happen while the agent is in the middle of processing an unrelated tool result. These events must reach the agent and influence its behaviour without disrupting the turn loop's sequential execution model.
This document explains how LSP notifications and DAP events flow through synwire's hook, event, and signal systems — and how agents can define reactive behaviour in response.
The Event Flow
The complete path from an external protocol event to an agent reaction traverses six boundaries:
flowchart TB
LS["Language Server / Debug Adapter"] -->|"notification / event"| T["Transport Layer<br/>(async-lsp MainLoop / DapTransport)"]
T -->|"deserialized message"| H["Notification Handler<br/>(LanguageClient impl / event dispatch)"]
H -->|"NotificationContext"| HR["HookRegistry<br/>(observation callbacks)"]
H -->|"AgentEvent::TaskNotification"| EB["Event Bus<br/>(tokio broadcast channel)"]
EB -->|"event received"| SR["Signal Emission<br/>SignalKind::Custom"]
SR -->|"signal"| CR["ComposedRouter<br/>(three-tier routing)"]
CR -->|"action"| Agent["Agent Runner<br/>(executes action)"]
Each boundary serves a distinct purpose:
-
Transport layer handles wire-protocol framing —
Content-Lengthheaders, JSON serialisation, multiplexing requests and notifications on a single stdio pipe. It produces typed Rust structs from raw bytes. -
Notification handler is the protocol-specific dispatch logic. For LSP, the
LanguageClienttrait provides per-notification-type methods (publish_diagnostics,show_message,log_message). For DAP, a match on the event'seventfield routes to handler functions. The handler converts protocol-specific types into synwire's generic representations. -
HookRegistry provides synchronous observation. Hooks see the event but do not alter the agent's execution path. They run with enforced timeouts — a hook that exceeds its timeout is skipped with a warning. Hooks are the right place for logging, metrics, and audit trails.
-
Event bus delivers
AgentEvent::TaskNotificationto any listener. The event carries atask_id(the plugin name), aTaskEventKind, and a JSON payload with the full event data. -
Signal emission converts the event into a
SignalwithSignalKind::Custom(name). This is the boundary where protocol events enter the agent's decision system. -
ComposedRouter applies the three-tier routing logic (strategy > agent > plugin) to determine the
Actionthe agent should take.
Hook Integration
The HookRegistry provides several hook types. LSP and DAP plugins use a subset of them:
| Hook Type | Used By | Purpose |
|---|---|---|
PreToolUse | Neither | LSP/DAP do not intercept before tool execution |
PostToolUse | LSP | Detects VFS file mutations to send didChange/didOpen/didClose |
PostToolUseFailure | Neither | LSP/DAP do not react to tool failures |
Notification | Both | Routes diagnostics, messages, debug events to observation layer |
SessionStart | LSP | Triggers language server startup when session begins |
SessionEnd | Both | Triggers server/adapter shutdown for clean resource release |
The PostToolUse hook for LSP document synchronisation is worth examining in detail. It uses the HookMatcher with a glob pattern to match only VFS mutation tools:
hooks.on_post_tool_use(
HookMatcher {
tool_name_pattern: Some("vfs_*".to_string()),
timeout: Duration::from_secs(5),
},
move |ctx| {
let client = Arc::clone(&lsp_client);
Box::pin(async move {
if is_mutation_tool(&ctx.tool_name) {
if let Some(path) = extract_path(&ctx.arguments) {
let _ = client.sync_document(&path).await;
}
}
HookResult::Continue
})
},
);
The hook matches vfs_write, vfs_edit, vfs_append, vfs_rm, and similar tools. It extracts the file path from the tool arguments and synchronises the document with the language server. The HookResult::Continue return means the hook never aborts the operation — document synchronisation is best-effort. If the language server is restarting or the sync fails, the hook logs a warning and continues.
This stands in contrast to a PreToolUse hook, which could abort the tool invocation by returning HookResult::Abort. LSP synchronisation is strictly post-hoc — the VFS mutation has already succeeded, and the hook is informing the language server of the new state.
Signal Routing
When a protocol event is converted to a signal, it enters the ComposedRouter. The signal's SignalKind is Custom(name), where name identifies the event type. The following custom signal names are used:
| Signal Name | Source | Meaning |
|---|---|---|
lsp_diagnostics_changed | LSP textDocument/publishDiagnostics | Diagnostics for a file have been updated |
lsp_server_crashed | LSP server process exit | The language server exited unexpectedly |
lsp_server_ready | LSP initialize response received | The language server is ready for requests |
dap_stopped | DAP stopped event | The debuggee hit a breakpoint or completed a step |
dap_output | DAP output event | The debuggee produced stdout/stderr output |
dap_terminated | DAP terminated event | The debuggee finished execution |
dap_exited | DAP exited event | The debuggee process exited with an exit code |
Agents define how to react to these signals by registering routes. An agent that should auto-fix diagnostics might register:
agent.route(
SignalRoute::new(
SignalKind::Custom("lsp_diagnostics_changed".into()),
Action::Continue, // Re-enter the agent loop to process diagnostics
10,
),
);
An agent that should inspect the debuggee when it stops:
agent.route(
SignalRoute::new(
SignalKind::Custom("dap_stopped".into()),
Action::Continue, // Re-enter the loop; agent's instructions guide inspection
10,
),
);
The Action::Continue in both cases means "process this signal by re-entering the agent's main loop". The agent's system prompt and instructions guide what happens next — the agent sees the signal payload (which file had diagnostics, what thread stopped, at what breakpoint) and decides which tools to invoke.
For more complex routing, predicates allow filtering on signal payload:
fn is_error_diagnostic(signal: &Signal) -> bool {
signal.payload.get("diagnostics")
.and_then(|d| d.as_array())
.is_some_and(|arr| arr.iter().any(|d| d["severity"] == 1))
}
agent.route(
SignalRoute::with_predicate(
SignalKind::Custom("lsp_diagnostics_changed".into()),
is_error_diagnostic,
Action::Continue,
20, // Higher priority than the catch-all
),
);
// Lower-priority catch-all: ignore warnings-only diagnostic updates
agent.route(
SignalRoute::with_predicate(
SignalKind::Custom("lsp_diagnostics_changed".into()),
|_| true,
Action::Custom("ignore".into()),
0,
),
);
This configuration reacts to diagnostic updates that contain errors (severity 1 in the LSP specification) but ignores updates that contain only warnings or hints. The predicate function receives the signal and inspects the payload, which contains the full PublishDiagnosticsParams serialised as JSON.
Comparison with MCP
The Model Context Protocol (MCP) and the LSP/DAP integrations both contribute tools to the agent's tool set. The structural similarity ends there.
MCP is a request-response protocol. The agent calls an MCP tool; the MCP server processes the request and returns a result. There is no equivalent of an unsolicited notification from the MCP server to the agent. If an MCP server wants to inform the agent of a state change, it must wait until the agent calls a tool and piggyback the information on the response. The MCP specification (as represented by the McpTransport trait in synwire) defines connect, list_tools, call_tool, and disconnect — no event subscription, no notification channel.
LSP and DAP are fundamentally different. The server-to-client direction carries critical information that arrives independently of any request: diagnostics appear because the server finished analysis, not because the client asked for diagnostics; stopped events appear because the debuggee hit a breakpoint, not because the client polled for breakpoints.
This distinction drives the architectural difference between the MCP integration and the LSP/DAP integrations:
| Aspect | MCP | LSP/DAP |
|---|---|---|
| Tool contribution | Yes | Yes |
| Signal routes | No (request-response only) | Yes (event-driven) |
| Hook usage | Minimal (connection lifecycle) | Extensive (PostToolUse for sync, Notification for events) |
| Transport | McpTransport trait | async-lsp MainLoop / ContentLengthCodec |
| State machine | Connection state only | Full protocol lifecycle (initialize, configured, running, etc.) |
An MCP server is stateless from the agent's perspective — each call_tool is independent. An LSP server is deeply stateful — the server maintains a model of the workspace, and the agent must keep that model synchronised with the VFS. A DAP session is even more stateful — the debug session has a lifecycle that constrains which operations are valid at any point.
Reactive Agent Patterns
The combination of event bridging and signal routing enables reactive agent patterns that go beyond simple tool invocation.
Diagnostic auto-fix loop. The agent receives an lsp_diagnostics_changed signal after editing a file. The signal payload contains the file URI and diagnostic array. The agent invokes lsp_diagnostics to read the current diagnostics (the tool may aggregate diagnostics across multiple files), identifies the errors, reads the relevant source code via VFS, generates a fix, applies it with vfs_edit, and the cycle repeats. The loop terminates when the diagnostics are empty or when the agent exhausts its retry budget.
sequenceDiagram
participant Agent
participant LspPlugin
participant LS as Language Server
participant VFS
Agent->>VFS: vfs_edit(file, fix)
VFS-->>Agent: success
Note over LspPlugin: PostToolUse hook fires
LspPlugin->>LS: didChange(file, content)
LS-->>LspPlugin: publishDiagnostics(file, [])
LspPlugin->>Agent: Signal: lsp_diagnostics_changed
Agent->>Agent: Route: Action::Continue
Agent->>LspPlugin: lsp_diagnostics()
LspPlugin-->>Agent: [] (no errors)
Agent->>Agent: Diagnostics clear — task complete
Breakpoint investigation. The agent launches a failing test under the debugger, sets a breakpoint at the suspected fault location, and continues. When the debuggee stops, the agent receives a dap_stopped signal. It invokes debug.stack_trace to read the call stack, debug.variables to inspect local variables in the stopped frame, and debug.evaluate to test a hypothesis about the bug. Based on the findings, it either sets additional breakpoints and continues, or disconnects the debugger and applies a fix.
sequenceDiagram
participant Agent
participant DapPlugin
participant DA as Debug Adapter
participant Debuggee
Agent->>DapPlugin: debug.launch(test_binary, args)
DapPlugin->>DA: launch request
DA->>Debuggee: start process
Agent->>DapPlugin: debug.set_breakpoints(file, line)
DapPlugin->>DA: setBreakpoints
Agent->>DapPlugin: debug.continue()
DapPlugin->>DA: continue
Debuggee->>DA: hits breakpoint
DA->>DapPlugin: stopped event
DapPlugin->>Agent: Signal: dap_stopped
Agent->>DapPlugin: debug.stack_trace()
DapPlugin-->>Agent: [frame0, frame1, ...]
Agent->>DapPlugin: debug.variables(frame0)
DapPlugin-->>Agent: { x: 41, expected: 42 }
Agent->>Agent: Identified: off-by-one in x calculation
Agent->>DapPlugin: debug.disconnect()
Combined LSP + DAP workflow. The most powerful pattern uses both protocols together. The agent edits a file, receives diagnostics showing a type error, uses lsp_goto_definition to understand the type contract, fixes the type error, receives clean diagnostics, runs the test under the debugger to verify the runtime behaviour, and confirms the fix. This is the agent equivalent of a developer's edit-compile-debug cycle.
Unrouted Signals
Not all signals need to be routed. An agent that does not register a route for dap_output will simply never react to debuggee stdout — the signal arrives at the ComposedRouter, finds no matching route, and is discarded. The event is still delivered to any registered Notification hooks, so logging and auditing still work. The signal is simply not actionable for the agent.
This is intentional. An agent focused on code editing might use LSP for diagnostics but have no use for the debugger. It registers routes for lsp_diagnostics_changed but not for any dap_* signals. The DAP plugin, if installed, still receives events and logs them, but they do not influence the agent's behaviour.
The three-tier routing makes this composable. A plugin can register default routes for its own signals at the plugin tier. The agent can override them at the agent tier. The execution strategy can override both at the strategy tier. A plugin that provides a "reasonable default" reaction to its own events — for instance, auto-restarting the language server on crash — can do so without forcing the agent to handle the signal explicitly.
Ordering and Concurrency
Protocol events arrive on a background task (the MainLoop for LSP, the DapTransport reader for DAP). They are delivered to the hook registry and event bus asynchronously. The agent's main turn loop processes signals between turns — not mid-turn.
This means that if the agent is in the middle of a tool invocation when a diagnostic notification arrives, the notification is queued. The agent sees it after the current tool invocation completes and the turn loop checks for pending signals. This serialisation avoids the complexity of concurrent signal handling within a single turn — the agent's state is never modified by a signal while a tool is executing.
The ordering guarantee within a single protocol is preserved: if the language server sends two publishDiagnostics notifications in sequence, the agent sees them in that order. Ordering between protocols is not guaranteed: an LSP diagnostic notification and a DAP stopped event that arrive at the same wall-clock time may be processed in either order. In practice, the agent should not depend on cross-protocol ordering — the two protocols operate on independent timelines.
See also: For the three-tier signal routing system that processes these signals, see the Three-Tier Signal Routing explanation. For how hooks work — registration, matching, timeouts — see the Middleware Execution Model explanation (the "Contrast with Hooks" section). For the Plugin trait that LSP and DAP plugins implement, see the Plugin State Isolation explanation. For the AgentEvent::TaskNotification variant that carries these events, see the Public Types reference.
synwire-mcp-adapters: MCP Client Infrastructure
synwire-mcp-adapters provides the high-level client-side infrastructure for connecting to Model Context Protocol (MCP) servers. It handles multi-server aggregation, transport negotiation, tool conversion, argument validation, request interception, and session lifecycle.
Why a separate crate?
MCP client logic is substantial --- transport management, tool schema conversion, interceptor chains, pagination, and validation. Keeping it separate from synwire-core (which defines the McpTransport trait) and synwire-mcp-server (which implements the server side) allows agents to connect to external MCP servers without pulling in server code, and vice versa.
Key types
MultiServerMcpClient
The central type. Connects to N named MCP servers simultaneously and aggregates their tools under a unified interface. Each server is identified by a string name and configured with a Connection variant.
#![allow(unused)] fn main() { use synwire_mcp_adapters::{MultiServerMcpClient, Connection, MultiServerMcpClientConfig}; use std::collections::HashMap; let mut servers = HashMap::new(); servers.insert("filesystem".into(), Connection::Stdio { command: "npx".into(), args: vec!["-y".into(), "@anthropic/mcp-filesystem".into()], }); let config = MultiServerMcpClientConfig { servers }; let client = MultiServerMcpClient::connect(config).await?; }
Tools from all servers are merged into a single namespace. Each tool is tracked as an AggregatedToolDescriptor that records which server it came from, enabling correct dispatch when a tool is invoked.
Connection
Transport configuration enum with four variants:
| Variant | Description |
|---|---|
Stdio | Spawn a child process, communicate over stdin/stdout |
Sse | Server-Sent Events over HTTP |
StreamableHttp | Streamable HTTP (MCP 2025-03-26 transport) |
WebSocket | WebSocket transport |
McpClientSession
RAII session guard. On creation, it sends initialize and performs capability negotiation. On drop, it sends shutdown/exit and cleans up transport resources. This ensures that MCP server processes are not leaked even if the client panics.
ToolCallInterceptor
Onion-ordered middleware for tool calls. Interceptors are executed in registration order before the call and in reverse order after it. Use cases include:
- Logging ---
LoggingInterceptorrecords tool call timing and results - Rate limiting --- throttle calls to a specific server
- Caching --- return cached results for deterministic tools
- Approval gates --- block calls pending human review
McpToolProvider
Bridges MCP tools into Synwire's ToolProvider trait. Wraps a MultiServerMcpClient and presents all aggregated MCP tools as if they were native Synwire tools.
PaginationCursor
Cursor-based pagination with a 1000-page safety cap. Used when listing tools or resources from MCP servers that return paginated results.
Tool conversion
The convert module provides bidirectional conversion between MCP tool definitions and Synwire ToolSchema:
- MCP to Synwire: MCP tool schemas (JSON Schema with
name+description) are converted toToolSchemafor use withbind_tools. - Synwire to MCP: Synwire
ToolSchemadefinitions are converted to MCP tool format for advertising intools/listresponses.
Argument validation
validate_tool_arguments performs client-side JSON Schema validation of tool call arguments before sending them to the server. This catches malformed arguments early, providing better error messages than server-side validation and reducing unnecessary round-trips.
The validation uses the jsonschema crate for full JSON Schema Draft 2020-12 support.
Callbacks
McpCallbacks bundles three callback traits:
| Trait | Purpose |
|---|---|
OnMcpLogging | Receives log messages from MCP servers |
OnMcpProgress | Receives progress notifications for long-running operations |
Built-in implementations include DiscardLogging, DiscardProgress (drop all), and TracingLogging (forward to tracing).
Dependencies
| Crate | Role |
|---|---|
synwire-core | McpTransport, ToolProvider, tool types |
synwire-agent | Agent runtime types for tool dispatch |
tokio-tungstenite | WebSocket transport implementation |
jsonschema | Client-side argument validation |
futures-util | Stream utilities for transport handling |
Ecosystem position
synwire-core (McpTransport trait)
|
+-- synwire-mcp-adapters (this crate: client, aggregation, conversion)
| |
| +-- synwire-mcp-server (uses adapters for upstream MCP connections)
|
+-- synwire-agent (McpTransport implementations: stdio, HTTP, in-process)
See also
- MCP Integration --- how-to guide
- synwire-mcp-server --- the server side of MCP
- synwire-core: Trait Contract Layer ---
McpTransporttrait
synwire-mcp-server: MCP Server Binary
synwire-mcp-server is a standalone binary that exposes Synwire's tools over the Model Context Protocol via a stdio JSON-RPC 2.0 transport. It is the primary integration point for using Synwire from MCP-compatible hosts such as Claude Desktop, Cursor, Windsurf, or any other MCP client.
Architecture
The server follows a thin-proxy design:
MCP Client (Claude Desktop, etc.)
| stdio (JSON-RPC 2.0)
v
synwire-mcp-server
|
+-- Built-in tools (file ops, search, etc.)
+-- Agent skills (from $DATA/<product>/skills/)
+-- LSP tools (optional, feature-gated)
+-- DAP tools (optional, feature-gated)
+-- DaemonProxy (forwards to synwire-daemon)
+-- ToolSearchIndex (progressive discovery)
All diagnostic output goes to stderr. Stdout is reserved exclusively for MCP protocol messages.
Protocol
The server implements three MCP methods:
| Method | Description |
|---|---|
initialize | Returns server capabilities, name, and version |
tools/list | Returns tool definitions (filtered by ToolSearchIndex when progressive discovery is active) |
tools/call | Invokes a tool and returns its result |
McpServer
The central runtime type. Holds:
ServerOptions--- resolved configuration from CLI flags and config fileStorageLayout--- product-scoped paths for data, cache, and logs- Tool registry ---
HashMap<String, McpTool>of all registered tools ToolSearchIndex--- progressive tool discovery index that reduces token usage by exposing only relevant tools per queryDaemonProxy--- forwards tool calls to thesynwire-daemonsingleton when it is runningMcpSampling--- placeholder for MCP sampling support (tool-internal LLM access viasampling/createMessage)
Tool registration
At startup, McpServer::new registers tools from three sources:
- Built-in tools ---
builtin_tools()returns the core set of file, search, and management tools. - Agent skills --- the global skills directory (
$DATA/<product>/skills/) is scanned viasynwire-agent-skills. Each discovered skill becomes an MCP tool. - LSP/DAP tools --- when the
lspordapfeatures are enabled and the corresponding CLI flag is set, language server and debug adapter tools are registered.
All tools are indexed in the ToolSearchIndex for progressive discovery.
LSP integration
When --lsp <command> is passed (e.g. --lsp rust-analyzer), the server registers LSP tools: lsp.hover, lsp.definition, lsp.references, lsp.symbols, and others. The LSP client is initialised lazily on first tool call. Requires the lsp feature flag.
DAP integration
When --dap <command> is passed (e.g. --dap lldb-dap), the server registers DAP tools: debug.breakpoint, debug.evaluate, and others. The DAP client is also initialised lazily. Requires the dap feature flag.
Multi-instance safety
Multiple MCP server instances can safely share the same data directories. StorageLayout uses SQLite WAL mode for all databases, and LanceDB handles concurrent access natively. No external file locks are needed.
ServerOptions
| Field | Description |
|---|---|
project | Project root directory (enables project-scoped indexing) |
product_name | Product name for storage scoping (e.g. "synwire") |
embedding_model | Model identifier for tool search and semantic indexing |
lsp | LSP server command (optional) |
dap | DAP server command (optional) |
Feature flags
| Flag | Enables |
|---|---|
lsp | LSP tool dispatch via synwire-lsp |
dap | DAP tool dispatch via synwire-dap |
Dependencies
| Crate | Role |
|---|---|
synwire-core | Tool traits, ToolSearchIndex, SamplingProvider |
synwire-agent | Agent runtime for tool execution |
synwire-agent-skills | Skill discovery and registration |
synwire-storage | StorageLayout, WorktreeId |
synwire-index | Semantic indexing pipeline |
synwire-mcp-adapters | MCP protocol utilities |
synwire-lsp | LSP client (optional) |
synwire-dap | DAP client (optional) |
clap | CLI argument parsing |
tracing / tracing-subscriber / tracing-appender | Structured logging to files |
See also
- Getting Started with the MCP Server --- tutorial
- Advanced MCP Server Setup --- advanced configuration
- Building a Custom MCP Server --- extending the server
- synwire-mcp-adapters --- the client-side MCP infrastructure
- synwire-agent-skills --- how skills become tools
synwire-agent-skills: Composable Agent Skills
synwire-agent-skills implements the agentskills.io specification for discoverable, composable agent skills, extended with Synwire-specific runtime hints. A skill is a self-contained unit of agent capability --- instructions plus optional executable code --- that can be loaded, registered, and invoked at runtime.
Why a separate crate?
Skills occupy a unique position between tools and prompts. A tool is a function with a JSON Schema; a prompt is static text. A skill is both: it carries human-readable instructions (injected into the LLM context) and may also contain executable scripts. Separating skills into their own crate keeps the core tool/agent abstractions clean while allowing the skills runtime to evolve independently.
Skill directory structure
Each skill is a directory containing:
my-skill/
SKILL.md # Required: YAML frontmatter (manifest) + Markdown body (instructions)
scripts/ # Optional: runtime scripts (Lua, Rhai, WASM, shell)
references/ # Optional: reference material the LLM can consult
assets/ # Optional: static assets
Skills are discovered from two locations:
- Global:
$DATA/<product>/skills/--- shared across all projects - Project-local:
.<product>/skills/--- project-specific skills
Key types
SkillManifest
Parsed from the YAML frontmatter of SKILL.md. Fields include:
| Field | Type | Description |
|---|---|---|
name | String | 1--64 chars, lowercase letters, digits, hyphens |
description | String | 1--1024 chars, human-readable summary |
license | Option<String> | SPDX identifier |
compatibility | Option<String> | Semver expression |
metadata | HashMap<String, String> | Arbitrary key-value pairs |
allowed_tools | Vec<String> | Tools this skill is permitted to invoke |
runtime | Option<SkillRuntime> | Synwire extension: execution runtime hint |
SkillRuntime
A #[non_exhaustive] enum specifying how a skill's scripts execute:
Lua--- Lua scripting viamlua(feature:lua-runtime)Rhai--- Rhai scripting (feature:rhai-runtime)Wasm--- WebAssembly viaextism(feature:wasm-runtime)ToolSequence--- a declarative sequence of tool invocations (always available)External--- an external process (always available)
SkillLoader
Scans a directory for immediate child directories containing SKILL.md, parses each manifest, extracts the body text, and validates structural invariants (e.g. the directory name must match manifest.name).
SkillRegistry
An in-memory registry supporting progressive disclosure: callers can list skill names and descriptions cheaply (for tool search indexing), then retrieve the full body only when a skill is activated. This pattern mirrors the ToolSearchIndex approach in synwire-core.
SkillExecutor
The common trait implemented by all runtime variants:
#![allow(unused)] fn main() { pub trait SkillExecutor: Send + Sync { fn execute(&self, input: SkillInput) -> Result<SkillOutput, SkillError>; fn execute_with_context( &self, input: SkillInput, context: Option<&SkillContext>, ) -> Result<SkillOutput, SkillError>; } }
When a SkillContext is provided, runtimes that support it expose filesystem operations scoped to the project root, tool invocation via ToolProvider, and LLM access via SamplingProvider.
Feature flags
| Flag | Enables | Dependency |
|---|---|---|
rhai-runtime | Rhai script executor | rhai |
lua-runtime | Lua script executor | mlua |
wasm-runtime | WASM executor | extism |
sandboxed | Process sandboxing for external runtimes | synwire-sandbox |
The external and sequence runtimes are always available regardless of feature flags.
Dependencies
| Crate | Role |
|---|---|
synwire-core | ToolProvider, SamplingProvider traits |
synwire-storage | StorageLayout for skill directory resolution |
serde_yaml | YAML frontmatter parsing |
walkdir / globset | Directory scanning and pattern matching |
Ecosystem position
Skills are registered as MCP tools by synwire-mcp-server at startup. The server scans the global skills directory, loads each skill via SkillLoader, and wraps it as a tool in the ToolSearchIndex. When an LLM activates a skill, the server dispatches to the appropriate SkillExecutor.
See also
- Authoring Your First Agent Skill --- tutorial
- synwire-mcp-server --- where skills become MCP tools
- synwire-core: Trait Contract Layer ---
ToolProviderandSamplingProvider
synwire-storage: Storage Layout and Project Identity
synwire-storage provides two things: a deterministic mapping from a product name to all storage paths a Synwire deployment needs (StorageLayout), and a two-level project identity (RepoId / WorktreeId) that is stable across clones and branches.
Why a centralised path library?
Without a shared layout library, paths scatter across crates and configuration files, making them difficult to migrate and easy to get wrong. StorageLayout is the single source of truth: every crate that needs a path calls layout.index_cache(worktree) rather than constructing paths ad-hoc.
Path hierarchy
graph TD
DATA["$XDG_DATA_HOME/<product>/"]
CACHE["$XDG_CACHE_HOME/<product>/"]
DATA --> SESSIONS["sessions/<id>.db"]
DATA --> EXPERIENCE["experience/<worktree_key>.db"]
DATA --> SKILLS["skills/"]
DATA --> LOGS["logs/"]
DATA --> PID["daemon.pid"]
DATA --> SOCK["daemon.sock"]
DATA --> GLOBAL["global/"]
GLOBAL --> GREG["registry.json"]
GLOBAL --> GEXP["experience.db"]
GLOBAL --> GDEP["dependencies.db"]
GLOBAL --> GCFG["config.json"]
CACHE --> INDICES["indices/<worktree_key>/"]
CACHE --> GRAPHS["graphs/<worktree_key>/"]
CACHE --> COMMUNITIES["communities/<worktree_key>/"]
CACHE --> LSP["lsp/<worktree_key>/"]
CACHE --> MODELS["models/"]
CACHE --> REPOS["repos/<owner>/<repo>/"]
Durable vs cache
Data is separated by durability:
| Tier | Location | Property | Examples |
|---|---|---|---|
| Durable | $DATA/<product>/ | Must survive reboots | Sessions, experience, skills, logs |
| Cache | $CACHE/<product>/ | Safe to delete and regenerate | Indices, graphs, communities, cloned repos |
This separation aligns with XDG semantics. Cache directories can be cleaned by systemd-tmpfiles, bleachbit, or manual deletion without losing durable state.
Configuration hierarchy
StorageLayout::new(product_name) resolves paths in this priority order:
SYNWIRE_DATA_DIR/SYNWIRE_CACHE_DIRenvironment variables- Programmatic override via
StorageLayout::with_root(root, name) .<product>/config.jsonin the project root- Platform default (
directories::BaseDirs)
This allows per-project, per-machine, and per-container overrides without changing code.
Two-level identity
The problem
A developer may have multiple working copies of the same repository:
~/projects/myapp— main branch~/projects/myapp-feature— feature branch (git worktree)~/builds/myapp— CI checkout
These should share certain data (experience pool, global dependency graph) but have independent indices (because the code differs per branch).
Solution: RepoId + WorktreeId
graph TD
A["git rev-list --max-parents=0 HEAD"] --> R["RepoId (first-commit SHA-1)"]
B["canonical worktree root path"] --> W["WorktreeId (RepoId + SHA-256 of path)"]
R --> W
RepoId is the same for all clones and worktrees of one repository. It is derived from the SHA-1 of the root (first) commit. When Git is unavailable, SHA-256 of the canonical path is used as a fallback.
WorktreeId uniquely identifies a single working copy. It combines RepoId with a SHA-256 of the canonicalised worktree root path. The key() method returns a compact filesystem-safe string: <repo_id>-<worktree_hash[:12]>.
What each tier uses
| Data | Keyed by | Why |
|---|---|---|
| Vector + BM25 indices | WorktreeId | Code differs per branch |
| Code dependency graph | WorktreeId | Call graph is branch-specific |
| Experience pool | WorktreeId | Edit history is branch-specific |
| Global dependency index | — | Spans all repos |
| Global experience | — | Spans all repos |
Path conventions
All path strings produced by StorageLayout use the WorktreeId.key() format as directory names under the cache root. This means:
- Directory names are stable across renames of the project directory (key is based on git content, not path)
- Two machines cloning the same repo get the same
RepoIdand therefore compatible cache structures - Branch switches produce different
WorktreeIds, so index data frommainis not mixed withfeature-branch
Product isolation
Each product name produces fully isolated paths. Two products running on the same machine cannot see each other's data:
#![allow(unused)] fn main() { let a = StorageLayout::new("product-a")?; let b = StorageLayout::new("product-b")?; assert_ne!(a.data_home(), b.data_home()); assert_ne!(a.cache_home(), b.cache_home()); }
This enables multiple Synwire-based tools (e.g. a coding assistant and a documentation assistant) to coexist without conflicting storage.
Directory permissions
StorageLayout::ensure_dir(path) creates directories with 0o700 permissions on Unix. This prevents other users on the same machine from reading session data, experience pools, or API-adjacent artefacts.
See also
- synwire-storage — API reference
- Migration Guide — path changes from pre-StorageLayout deployments
- synwire-daemon — how the daemon uses
daemon_pid_file()anddaemon_socket()
synwire-daemon: Singleton Background Process
Status: Planned — the daemon crate does not exist yet. This document describes the intended design. Current deployments run
synwire-mcp-serverdirectly without a daemon.
synwire-daemon will be a singleton background process, one per product name, that owns shared expensive resources across all MCP server instances connecting to it.
Motivation
Each synwire-mcp-server process currently holds its own embedding model, file watchers, and indexing pipeline. For a developer running multiple MCP clients (Claude Code, Copilot, Cursor) against the same project, these resources are duplicated:
- Embedding model loaded 3× into RAM (~30–110 MB each)
- Three separate file watchers on the same directory tree
- Three independent index caches that may diverge
The daemon solves this by centralising these resources so all clients share them.
Daemon responsibilities
| Resource | Owned by daemon | Per-MCP-server without daemon |
|---|---|---|
| Embedding model | Single instance | One per server process |
| File watchers | Single watcher per project | One per server process |
| Indexing pipeline | Shared, serialised | Independent, may race |
| Global experience pool | Centralised | Shared via SQLite WAL |
| Dependency index | Centralised | Shared via SQLite WAL |
| Cross-project registry | Centralised | Shared via JSON |
Auto-launch lifecycle
The daemon is not managed by systemd, launchctl, or any service manager. It is spawned on demand:
sequenceDiagram
participant C as MCP Server (client 1)
participant D as synwire-daemon
participant C2 as MCP Server (client 2)
C->>D: Connect via UDS socket
Note over D: Not running — socket absent
C->>D: Spawn daemon as detached process
C->>D: Retry connect (back-off)
D-->>C: Accept connection
C2->>D: Connect via UDS socket
D-->>C2: Accept connection
Note over D: Both clients served
C-->>D: Disconnect
C2-->>D: Disconnect
Note over D: Start 5-min grace period
Note over D: No clients reconnect
D->>D: Shut down
- MCP server checks for the daemon socket at
StorageLayout::daemon_socket(). - If absent, it spawns the daemon as a detached child process and polls for the socket.
- The daemon writes its PID to
StorageLayout::daemon_pid_file(). - All subsequent MCP servers connect to the existing daemon.
- When the last client disconnects, a 5-minute grace period begins. If no new clients connect, the daemon shuts down cleanly. If a client connects during the grace period, the timer resets.
Identity: per-product-name singleton
The daemon is scoped to a --product-name. Two products (myapp and yourtool) run independent daemon instances with isolated sockets and PID files:
~/.local/share/myapp/daemon.sock
~/.local/share/myapp/daemon.pid
~/.local/share/yourtool/daemon.sock
~/.local/share/yourtool/daemon.pid
Transport: Unix domain socket
MCP servers communicate with the daemon over a Unix domain socket (UDS). The MCP server acts as a thin stdio ↔ UDS proxy:
graph LR
A["MCP host<br/>(Claude Code)"] -- "stdio JSON-RPC" --> B["synwire-mcp-server<br/>(thin proxy)"]
B -- "UDS JSON-RPC" --> C["synwire-daemon<br/>(resource owner)"]
The proxy adds no protocol logic — it forwards messages verbatim. This keeps synwire-mcp-server trivially simple and allows the daemon to be updated independently.
Global tier
The daemon maintains a global tier of shared knowledge across all projects:
- Registry (
global_registry()): Known projects, theirRepoIds, and worktree paths - Experience pool (
global_experience_db()): Cross-project edit-to-file associations - Dependency index (
global_dependency_db()): Cross-project symbol dependency graph
Planned implementation
When implemented, synwire-daemon will be a separate binary crate that:
- Parses
--product-nameand--data-dirarguments - Initialises
StorageLayout, acquires the PID file, binds the UDS socket - Loads the embedding model once
- Accepts UDS connections from MCP server proxies
- Dispatches tool requests to the appropriate project handler
- Manages the 5-minute idle shutdown timer
See also
- synwire-mcp-server — current deployment without daemon
- synwire-storage —
daemon_pid_file(),daemon_socket()path methods
Glossary
Agent -- A system that uses a language model to decide which actions to take. In Synwire, agents are implemented as state graphs with conditional edges.
BaseChatModel -- Core trait for chat language models. Provides invoke, batch, stream, model_type, and bind_tools.
BaseChannel -- Trait for state channels in graph execution. Manages how values are accumulated during supersteps.
BoxFuture -- Pin<Box<dyn Future<Output = T> + Send + 'a>>. Used for dyn-compatible async trait methods.
BoxStream -- Pin<Box<dyn Stream<Item = T> + Send + 'a>>. Used for streaming responses.
CallbackHandler -- Trait for receiving observability events during execution (LLM start/end, tool start/end, retries).
Channel -- A state management unit in graph execution. Each channel stores and reduces values for a single key.
ChatChunk -- An incremental piece of a streaming chat response. Contains delta_content, delta_tool_calls, finish_reason, and usage.
ChatResult -- The complete result of a chat model invocation. Contains the AI Message, optional generation info, and optional cost estimate.
CompiledGraph -- An executable graph produced by StateGraph::compile(). Runs the Pregel superstep loop.
ConditionFn -- A function that inspects graph state and returns a branch key for conditional edge routing.
CredentialProvider -- Trait for retrieving API keys and secrets. Implementations include EnvCredentialProvider and StaticCredentialProvider.
Document -- A text document with metadata, used in RAG pipelines.
Embeddings -- Trait for text embedding models. Provides embed_documents and embed_query.
FakeChatModel -- A deterministic chat model for testing. Returns pre-configured responses without API calls.
FakeEmbeddings -- A deterministic embedding model for testing. Returns consistent vectors without API calls.
Language Server Protocol (LSP) -- A protocol for providing code intelligence (go-to-definition, hover, diagnostics, completions) between an editor/tool (client) and a language-specific server. Synwire wraps LSP via async-lsp in synwire-lsp.
LanguageServerEntry -- Configuration for a language server: command, args, file extensions, installation instructions. Stored in LanguageServerRegistry. Lives in synwire-lsp.
LanguageServerRegistry -- Registry of known language servers with auto-detection by file extension and installation guidance. Ships with 22 built-in entries from langserver.org. Lives in synwire-lsp.
LocalProvider -- Vfs implementation for real filesystem I/O, scoped to a root path. Path-traversal attacks are blocked via normalize_path(). Lives in synwire-agent.
LspClient -- High-level LSP client wrapping async-lsp's ServerSocket and MainLoop. Provides async methods for all LSP operations and caches server capabilities and diagnostics. Lives in synwire-lsp.
LspPlugin -- Plugin implementation that contributes LSP tools to agents, bridges publishDiagnostics and other server notifications to the hook and signal systems, and auto-starts language servers. Lives in synwire-lsp.
LspServerState -- Enum for language server lifecycle: NotStarted, Initializing, Ready, ShuttingDown, Stopped, Failed. Lives in synwire-lsp.
FsmStrategy -- ExecutionStrategy implementation that governs agent turns with a finite state machine. Built via FsmStrategyBuilder. Lives in synwire-agent.
FsmStrategyBuilder -- Builder for FsmStrategy. Methods: add_state, add_transition, set_initial_state, set_guard, build. Lives in synwire-agent.
ContentLengthCodec -- tokio_util::codec implementation for the DAP wire format (Content-Length: N\r\n\r\n{json}). Shared framing protocol with LSP. Lives in synwire-dap.
DapClient -- High-level Debug Adapter Protocol client. Wraps DapTransport to provide methods for debug operations: breakpoints, stepping, variable inspection. Lives in synwire-dap.
DapPlugin -- Plugin implementation that contributes DAP tools to agents and bridges debug events (stopped, output, terminated) to the hook and signal systems. Lives in synwire-dap.
DapSessionState -- Enum for debug session lifecycle: NotStarted, Initializing, Configured, Running, Stopped, Terminated. Lives in synwire-dap.
Debug Adapter Protocol (DAP) -- A protocol for communicating with debuggers, analogous to LSP for language intelligence. Uses JSON messages with Content-Length framing over stdio.
DebugAdapterRegistry -- Registry of known debug adapters (codelldb, dlv-dap, debugpy, etc.) with auto-detection and installation instructions. Lives in synwire-dap.
GraphError -- Error type for graph construction, compilation, and execution errors.
McpConnectionState -- Enum for MCP transport lifecycle: Disconnected, Connecting, Connected, Reconnecting, Failed. Lives in synwire-agent.
McpLifecycleManager -- Manages connection lifecycle (reconnection, health checks) for any McpTransport. Lives in synwire-agent.
McpTransport -- Trait in synwire-core for MCP protocol transports. Three implementations in synwire-agent: StdioMcpTransport, HttpMcpTransport, InProcessMcpTransport.
Message -- An enum representing conversation messages: human, AI, system, or tool.
NodeFn -- A boxed async function that transforms graph state: Box<dyn Fn(Value) -> BoxFuture<Result<Value, GraphError>>>.
Action -- The response an agent emits after processing a Signal. Variants: Continue, GracefulStop, ForceStop, Transition(state), Custom. Lives in synwire-agent.
AgentError -- Top-level error enum for the agent runtime. #[non_exhaustive]. Covers Vfs, Strategy, Middleware, Permission, Mcp, Session, Plugin variants. Lives in synwire-agent.
AgentEvent -- Stream item emitted by Runner::run. Variants include Text, ToolCall, ToolResult, Thinking, DirectiveEmitted, UsageUpdate, Done. Lives in synwire-agent.
AgentNode -- Core trait in synwire-core. Implementors receive (Directive, &State, &Context) and return BoxFuture<DirectiveResult<S>>.
ApprovalDecision -- Enum returned by an approval callback. Variants: Allow, AllowAlways, AllowModified(Directive), Deny. Lives in synwire-agent.
ApprovalRequest -- Value passed to an approval callback containing the Directive, RiskLevel, and contextual metadata. Lives in synwire-agent.
ArchiveManager -- Vfs implementation for reading and writing tar/zip/gzip archives, scoped to a root path. Lives in synwire-agent.
VfsError -- Error enum for Vfs operations. #[non_exhaustive]. Covers Io, Permission, NotFound, Timeout, Custom variants. Lives in synwire-agent.
Vfs -- Trait in synwire-core defining all file, shell, HTTP, and process operations an agent may perform as algebraic effects.
CompositeProvider -- Routes Vfs calls to different VFS providers by path prefix (mount table). Lives in synwire-agent.
Directive -- Enum in synwire-core representing an intended effect returned by AgentNode::process. Variants: Emit { event }, SpawnAgent { … }, StopChild { id }, Stop { reason }, SpawnTask { … }, StopTask { id }, RunInstruction { … }, Schedule { … }, Cron { … }, Custom(Box<dyn …>).
DirectiveExecutor -- Trait in synwire-core that carries out Directive values. Returns BoxFuture<DirectiveResult<S>>.
DirectiveFilter -- Trait in synwire-core that intercepts Directive values before execution; can allow, deny, or transform them.
DirectiveResult -- Type alias for Result<AgentEvent, AgentError>. The output of DirectiveExecutor::execute. The S type parameter is the agent's state type.
DirectStrategy -- ExecutionStrategy implementation that passes control entirely to the model with no state machine constraints. Lives in synwire-agent.
ExecutionStrategy -- Trait in synwire-core controlling how the Runner sequences turns. Two built-in implementations: DirectStrategy and FsmStrategy (both in synwire-agent).
OutputMode -- Strategy for extracting structured output: Native, Tool, Prompt, or Custom.
OutputParser -- Trait for transforming raw model text into structured types.
GrepMatch -- Struct returned by grep operations. Fields: path, line_number, line, context_before, context_after. Lives in synwire-agent.
GrepOptions -- Config struct for grep/search operations. Fields: pattern, paths, file_type, glob, context_lines, output_mode, invert, count_only, case_insensitive. Lives in synwire-agent.
HookRegistry -- Registry of lifecycle hooks (before_run, after_run, on_event) attached to a Runner. Lives in synwire-agent.
InMemorySessionManager -- In-process SessionManager implementation. Session data is lost on process exit. Lives in synwire-agent.
Shell -- Vfs implementation for sandboxed shell command execution. Working directory is scoped to root. Lives in synwire-agent.
Middleware -- Trait in synwire-core applied before each agent turn. Receives and can mutate the context; returns MiddlewareResult.
MiddlewareStack -- Ordered list of Middleware instances applied left-to-right before each turn. Short-circuits on MiddlewareResult::Stop. Lives in synwire-agent.
PermissionBehavior -- Enum for what happens when a PermissionRule matches. Variants: Allow, Deny, Ask. Lives in synwire-agent.
PermissionMode -- Enum preset for agent permission posture. Variants: Unrestricted, Restricted, Sandboxed, ApprovalRequired, Custom(Vec<PermissionRule>). Lives in synwire-agent.
PermissionRule -- A single declarative rule mapping a tool pattern to a PermissionBehavior. Lives in synwire-agent.
PipelineExecutor -- Runs a sequence of pipeline stages sequentially with a shared timeout. Lives in synwire-agent.
Plugin -- Trait in synwire-core for stateful components with lifecycle hooks: before_run, after_run, on_event, signal_routes.
PluginHandle -- Type-safe accessor for plugin state isolated by PluginStateKey. Prevents cross-plugin state access. Lives in synwire-agent.
PluginStateKey -- Marker trait used to namespace plugin state within the shared agent state. One type per plugin. Lives in synwire-core.
ProcessManager -- Vfs implementation for spawning, monitoring, and killing background processes. Lives in synwire-agent.
Pregel -- The execution model used by synwire-orchestrator. Processes graphs via sequential supersteps.
PromptTemplate -- A string template with named variables for formatting prompts.
ReAct -- Reason + Act agent pattern. Loops between model invocation and tool execution until the model responds without tool calls.
RiskLevel -- Enum classifying how dangerous a Directive is. Variants: None, Low, Medium, High, Critical. Lives in synwire-agent.
Runner -- Entry point for the agent runtime in synwire-agent. Drives the turn loop wrapping AgentNode + ExecutionStrategy + middleware + VFS + session.
Retriever -- Trait for document retrieval, typically backed by a vector store.
RunnableConfig -- Per-invocation configuration carrying callbacks, tags, and metadata.
RunnableCore -- Universal composition trait. Uses serde_json::Value for input/output.
SecretValue -- A wrapper that redacts secrets on Display and Debug. Prevents accidental logging of API keys.
Session -- Persisted agent session containing message history, metadata, and plugin state. Lives in synwire-agent.
SessionManager -- Trait in synwire-core for session CRUD. Methods: create, get, update, delete, list, fork, rewind, tag.
SessionMetadata -- Fields on a Session: id, created_at, updated_at, tags, thread_id, agent_id. Lives in synwire-agent.
Signal -- A value delivered to the agent's signal router. Carries a SignalKind and optional payload. Lives in synwire-agent.
SignalKind -- Enum of signal categories. Variants: Stop, UserMessage, ToolResult, Timer, Custom(String). Lives in synwire-agent.
SignalRoute -- A mapping from a SignalKind pattern to an Action, used in ComposedRouter. Lives in synwire-agent.
MemoryProvider -- Ephemeral in-memory Vfs implementation. All data is lost when the Runner drops. Safe for sandboxed agents. Lives in synwire-agent.
StateGraph -- A builder for constructing state machines with nodes and edges, compiled into CompiledGraph.
StreamMode -- Controls what data is emitted during streaming graph execution: Values, Updates, Debug, Messages, Custom.
StoreProvider -- Vfs implementation backed by a BaseStore for K-V persistence. Lives in synwire-agent.
StrategyError -- Error enum for ExecutionStrategy failures. #[non_exhaustive]. Covers InvalidTransition, GuardFailed, StateNotFound, Custom. Lives in synwire-agent.
StructuredTool -- A concrete Tool implementation built via StructuredToolBuilder.
Superstep -- One iteration of the Pregel loop: execute a node, resolve the next edge.
SynwireError -- Top-level error enum wrapping domain-specific error types.
SynwireErrorKind -- Discriminant enum for matching error categories without inspecting payloads.
ThresholdGate -- Approval gate that triggers human approval when a Directive's RiskLevel meets or exceeds a configured threshold. Lives in synwire-agent.
Tool -- Trait for callable tools with name, description, schema, and invoke.
ToolSchema -- JSON Schema description of a tool's parameters.
Usage -- Token/cost accounting for a single agent turn. Fields: input_tokens, output_tokens, cache_read_tokens, cache_write_tokens. Lives in synwire-agent.
VectorStore -- Trait for storing and querying document embeddings.
RepoId -- Stable identifier for a repository family (all clones and worktrees of the same repo). Derived from the SHA-1 of the first (root) Git commit, or SHA-256 of the canonical path when Git is unavailable. Lives in synwire-storage.
WorktreeId -- Identifies a specific working copy within a repository family. Combines RepoId with a SHA-256 of the canonicalised worktree root path. key() returns a compact filesystem-safe string. Lives in synwire-storage.
StorageLayout -- Computes all Synwire storage paths for a given product name using platform-appropriate base directories ($XDG_DATA_HOME, $XDG_CACHE_HOME). Separates durable data from regenerable cache. Configuration hierarchy: env vars > programmatic override > project config > platform default. Lives in synwire-storage.
CommunityState -- Persisted result of HIT-Leiden community detection over the code graph. Stores community membership, community summaries (generated via SamplingProvider), and inter-community edge weights. Persisted at StorageLayout::communities_dir(). Lives in synwire-index (behind community-detection feature flag).
SamplingProvider -- Trait for tool-internal LLM access. Two implementations: McpSampling (delegates to MCP host via sampling/createMessage) and DirectModelSampling (uses a configured BaseChatModel directly). Used by community summary generation and hierarchical narrowing to avoid zero calls during indexing. Lives in synwire-core.
ToolSearchIndex -- Framework-level tool registry that supports progressive tool discovery via embedding-based retrieval and namespace grouping. Reduces context token usage by ~85% vs listing all tools upfront. Supports hybrid scoring (vector + keyword boosting), seen/unseen adaptive penalties, and transition graph boosting. Lives in synwire-core.
agentskills.io -- Open specification for discoverable, composable agent skills. A skill is a directory containing SKILL.md (YAML frontmatter + instructions), scripts/, references/, and assets/. Synwire extends the spec with an optional runtime field (lua, rhai, wasm, tool-sequence, external). Implemented by synwire-agent-skills.
Common Errors
SynwireError variants
Model errors
| Error | Cause | Resolution |
|---|---|---|
ModelError::RateLimit | API rate limit exceeded | Wait for retry_after duration, or use RunnableRetry |
ModelError::AuthenticationFailed | Invalid or missing API key | Check OPENAI_API_KEY or equivalent |
ModelError::InvalidRequest | Malformed request | Check message format and model parameters |
ModelError::ContentFiltered | Content safety filter triggered | Modify input content |
ModelError::Timeout | Request timed out | Increase timeout or retry |
ModelError::Connection | Network connectivity issue | Check network, retry |
Tool errors
| Error | Cause | Resolution |
|---|---|---|
ToolError::NotFound | Tool name not registered | Check tool name spelling |
ToolError::InvalidName | Name does not match [a-zA-Z0-9_-]{1,64} | Fix tool name |
ToolError::ValidationFailed | Input does not match schema | Check tool call arguments |
ToolError::InvocationFailed | Tool execution failed | Check tool implementation |
ToolError::PathTraversal | Path traversal attempt detected | Security check -- do not bypass |
ToolError::Timeout | Tool execution timed out | Increase timeout |
Parse errors
| Error | Cause | Resolution |
|---|---|---|
ParseError::ParseFailed | Could not parse model output | Check output format, add format instructions |
ParseError::FormatMismatch | Output does not match expected format | Improve prompt or use structured output |
Embedding errors
| Error | Cause | Resolution |
|---|---|---|
EmbeddingError::Failed | Embedding API call failed | Check API key and model name |
EmbeddingError::DimensionMismatch | Vector dimensions do not match | Ensure consistent embedding model |
Vector store errors
| Error | Cause | Resolution |
|---|---|---|
VectorStoreError::NotFound | Document ID not found | Check document was added |
VectorStoreError::DimensionMismatch | Embedding dimensions mismatch | Use same embedding model for add and query |
Other
| Error | Cause |
|---|---|
SynwireError::Prompt | Prompt template variable missing or invalid |
SynwireError::Credential | Credential provider failed |
SynwireError::Serialization | JSON serialisation/deserialisation failed |
SynwireError::Io | File system or I/O error |
GraphError variants
| Error | Cause | Resolution |
|---|---|---|
RecursionLimit | Exceeded step limit | Increase limit or fix loop |
NoEntryPoint | set_entry_point not called | Call graph.set_entry_point("node") |
DuplicateNode | Two nodes with same name | Use unique names |
TaskNotFound | Edge references unknown node | Check node names |
CompileError | Node has no outgoing edges | Add edges for all nodes |
EmptyInput | Empty state provided | Provide initial state |
Interrupt | Graph paused for human input | Handle interrupt, resume later |
MultipleValues | LastValue channel got >1 value | Use Topic channel or fix graph |
Error kind matching
Use SynwireErrorKind for retry and fallback decisions:
use synwire_core::error::SynwireErrorKind;
match err.kind() {
SynwireErrorKind::Model => { /* retry */ }
SynwireErrorKind::Parse => { /* re-prompt */ }
SynwireErrorKind::Credential => { /* fail fast */ }
_ => { /* handle other */ }
}
Feature Flags
synwire-core
| Feature | Default | Description |
|---|---|---|
retry | Yes | Retry support via backoff + tokio |
http | Yes | HTTP client via reqwest |
tracing | No | OpenTelemetry tracing integration |
event-bus | No | Tokio-based event bus for custom events |
batch-api | No | Provider-level batch processing trait |
Example
[dependencies]
synwire-core = { version = "0.1", features = ["tracing"] }
To disable defaults:
synwire-core = { version = "0.1", default-features = false, features = ["http"] }
synwire (umbrella)
| Feature | Default | Description |
|---|---|---|
openai | No | Include synwire-llm-openai provider |
ollama | No | Include synwire-llm-ollama provider |
lsp | No | Include synwire-lsp for Language Server Protocol integration |
dap | No | Include synwire-dap for Debug Adapter Protocol integration |
synwire-index
| Feature | Default | Description |
|---|---|---|
hybrid-search | No | BM25 (tantivy) + vector hybrid search with configurable alpha weighting |
code-graph | No | Cross-file call/import/inherit dependency graph backed by SQLite |
community-detection | No | HIT-Leiden community clustering over the code graph |
Example
[dependencies]
synwire-index = { version = "0.1", features = ["hybrid-search", "code-graph"] }
community-detection requires code-graph — community clustering operates on the graph edges.
synwire-agent-skills
| Feature | Default | Description |
|---|---|---|
lua | No | Lua scripting runtime via mlua |
rhai | No | Rhai scripting runtime |
wasm | No | WebAssembly runtime via extism |
All runtimes are opt-in to keep binary size small. Enable only the runtimes your skills require:
synwire-agent-skills = { version = "0.1", features = ["lua"] }
Example
[dependencies]
synwire = { version = "0.1", features = ["openai", "ollama"] }
Provider crates
synwire-llm-openai and synwire-llm-ollama have no optional features. They always depend on synwire-core with the http feature enabled.
synwire-checkpoint-sqlite
No optional features. Always depends on rusqlite with the bundled feature (compiles SQLite from source).
synwire-derive
No optional features. Proc-macro crate depending on syn, quote, proc-macro2.
Interaction between features
retryrequirestokiofor async backoff delaystracingenablestracing,tracing-opentelemetry,opentelemetry, andopentelemetry_sdkevent-busrequirestokiofor broadcast channels- Disabling
httpremovesreqwest-- provider crates will not compile without it
Checking active features
cargo tree -e features -p synwire-core
Offline Usage
Synwire can be used entirely offline without API keys for development, testing, and CI.
FakeChatModel
Returns pre-configured responses deterministically:
use synwire_core::language_models::{FakeChatModel, BaseChatModel};
use synwire_core::messages::Message;
let model = FakeChatModel::new(vec![
"First response".into(),
"Second response".into(),
]);
// Responses cycle: call 0 -> "First response", call 1 -> "Second response", call 2 -> "First response"
Features
- Error injection:
with_error_at(n)returns an error on calln - Stream chunking:
with_chunk_size(n)splits responses inton-character chunks - Stream errors:
with_stream_error_after(n)injects errors afternchunks - Call tracking:
call_count()andcalls()for assertions
FakeEmbeddings
Returns deterministic embedding vectors:
use synwire_core::embeddings::{FakeEmbeddings, Embeddings};
let embeddings = FakeEmbeddings::new(32); // 32-dimensional vectors
let vectors = embeddings.embed_documents(&["hello".into()]).await?;
InMemoryVectorStore
Full vector store implementation with no external dependencies:
use synwire_core::vectorstores::InMemoryVectorStore;
let store = InMemoryVectorStore::new();
InMemoryCheckpointSaver
In-memory checkpoint storage:
use synwire_checkpoint::memory::InMemoryCheckpointSaver;
let saver = InMemoryCheckpointSaver::new();
Test utilities
The synwire-test-utils crate provides:
- Proptest strategies for all core types (messages, documents, embeddings, tools, channels, graphs)
- Fixture builders (
DocumentBuilder,MessageBuilder,PromptTemplateBuilder,ToolSchemaBuilder) - Re-exports of all strategy modules
CI without API keys
All tests in the workspace run with cargo nextest run without any environment variables. Integration tests requiring live APIs are behind feature flags or in separate test files excluded from default runs.
Disabling network features
For air-gapped environments:
[dependencies]
synwire-core = { version = "0.1", default-features = false }
This removes reqwest and backoff dependencies. Use FakeChatModel and FakeEmbeddings exclusively.
OutputMode and TypedValue Interop
OutputMode
OutputMode controls how structured output is extracted from a language model. Different providers support different mechanisms.
Variants
| Variant | Mechanism | Provider support |
|---|---|---|
Native | Model's native structured output (e.g., response_format) | OpenAI (gpt-4o+), Ollama (some models) |
Tool | Tool calling to extract structured output | OpenAI, Ollama (tool-capable models) |
Prompt | Format instructions embedded in the prompt | All providers (universal fallback) |
Custom(String) | User-defined extraction strategy | Any |
Fallback chain
OutputMode::fallback_chain() returns [Native, Tool, Prompt]. Use this to try the most capable mode first and fall back gracefully:
use synwire_core::output_parsers::OutputMode;
for mode in OutputMode::fallback_chain() {
if mode.validate_compatibility(supports_native, supports_tools).is_ok() {
// Use this mode
break;
}
}
Compatibility validation
Check whether a provider supports a given mode before use:
let mode = OutputMode::Native;
// Returns Err if provider lacks native support
mode.validate_compatibility(
supports_native, // bool: provider has response_format support
supports_tools, // bool: provider has tool calling support
)?;
TypedValue interop
RunnableCore uses serde_json::Value as its universal I/O type. To convert between typed data and Value:
Serialisation
use serde::Serialize;
#[derive(Serialize)]
struct Query {
question: String,
context: Vec<String>,
}
let query = Query {
question: "What is Rust?".into(),
context: vec!["Rust is a language.".into()],
};
let value = serde_json::to_value(&query)?;
// Pass to RunnableCore::invoke
Deserialisation
use serde::Deserialize;
#[derive(Deserialize)]
struct Answer {
text: String,
confidence: f64,
}
let result = runnable.invoke(input, None).await?;
let answer: Answer = serde_json::from_value(result)?;
OutputParser with typed output
StructuredOutputParser combines OutputMode with typed deserialisation:
use synwire_core::output_parsers::StructuredOutputParser;
// Parses model output as JSON into a typed struct
let parser = StructuredOutputParser::<Answer>::new();
let answer = parser.parse(&model_output_text)?;
JsonOutputParser for dynamic values
When the schema is not known at compile time:
use synwire_core::output_parsers::JsonOutputParser;
let parser = JsonOutputParser;
let value: serde_json::Value = parser.parse(&text)?;
Design rationale
The serde_json::Value approach was chosen over generic type parameters for RunnableCore because:
- Object safety:
Vec<Box<dyn RunnableCore>>would not work with generic parameters - Composability: any runnable chains with any other without type conversion boilerplate
- Trade-off: runtime type checking instead of compile-time, but this matches the dynamic nature of LLM outputs
OutputMode provides a type-safe way to select the structured output extraction strategy, while serde_json::Value provides the runtime flexibility needed for heterogeneous chains.
Traits Reference
All public traits in synwire-core and synwire-agent. Every trait is
Send + Sync unless noted otherwise. Methods returning async results use
BoxFuture<'_, Result<T, E>> from synwire_core::BoxFuture.
Core Agent Traits — synwire_core::agents
AgentNode
#![allow(unused)] fn main() { pub trait AgentNode: Send + Sync { fn name(&self) -> &str; fn description(&self) -> &str; fn run(&self, input: Value) -> BoxFuture<'_, Result<AgentEventStream, AgentError>>; fn sub_agents(&self) -> Vec<String> { vec![] } // default: empty } }
A runnable agent that produces a stream of AgentEvent values.
| Method | Description |
|---|---|
name | Stable identifier used for routing, logging, and sub-agent references. |
description | Human-readable string; surfaced in introspection and UI. |
run | Start a turn. Returns an event stream that terminates with TurnComplete or Error. |
sub_agents | Names of agents this node may spawn. Used by the runner for capability declaration. |
The Agent<O> builder implements AgentNode. Provider crates replace the
stub model invocation with a real LLM call.
ExecutionStrategy
#![allow(unused)] fn main() { pub trait ExecutionStrategy: Send + Sync { fn execute<'a>(&'a self, action: &'a str, input: Value) -> BoxFuture<'a, Result<Value, StrategyError>>; fn tick(&self) -> BoxFuture<'_, Result<Option<Value>, StrategyError>>; fn snapshot(&self) -> Result<Box<dyn StrategySnapshot>, StrategyError>; fn signal_routes(&self) -> Vec<SignalRoute> { vec![] } // default: empty } }
Controls how an agent orchestrates actions.
| Method | Description |
|---|---|
execute | Attempt an action from the current state. Returns the (possibly modified) input on success. FsmStrategy validates the action against the current FSM state. DirectStrategy passes input through unconditionally. |
tick | Process pending deferred work. Returns Some(value) if there is a result to route back; None otherwise. |
snapshot | Capture serialisable strategy state for checkpointing. |
signal_routes | Signal routes contributed by this strategy to the composed router. |
GuardCondition
#![allow(unused)] fn main() { pub trait GuardCondition: Send + Sync { fn evaluate(&self, input: &Value) -> bool; fn name(&self) -> &str; } }
Predicate evaluated before an FSM transition is accepted.
| Method | Description |
|---|---|
evaluate | Return true to allow the transition. |
name | Human-readable name used in error messages and logging. |
ClosureGuard is a convenience adapter that wraps Fn(&Value) -> bool.
StrategySnapshot
#![allow(unused)] fn main() { pub trait StrategySnapshot: Send + Sync { fn to_value(&self) -> Result<Value, StrategyError>; } }
Serialises strategy state to JSON for checkpointing. The value is opaque from the runtime's perspective; each strategy defines its own schema.
DirectiveFilter
#![allow(unused)] fn main() { pub trait DirectiveFilter: Send + Sync { fn filter(&self, directive: Directive) -> Option<Directive>; fn decision(&self, directive: &Directive) -> FilterDecision { /* default */ } } }
Filters directives before they reach the executor.
| Method | Description |
|---|---|
filter | Return Some(directive) to pass through (possibly modified) or None to suppress. |
decision | Inspect a directive without consuming it. Default implementation calls filter on a clone. |
FilterChain applies a sequence of filters in registration order; the first
None result short-circuits the chain.
DirectiveExecutor
#![allow(unused)] fn main() { pub trait DirectiveExecutor: Send + Sync { fn execute_directive( &self, directive: &Directive, ) -> BoxFuture<'_, Result<Option<Value>, DirectiveError>>; } }
Executes a Directive and optionally routes a result value back to
the agent.
| Returns | Meaning |
|---|---|
Ok(None) | Directive executed; no result to route back. |
Ok(Some(v)) | Result value to inject into the next agent turn (used by RunInstruction). |
Err(e) | Execution failed. |
NoOpExecutor always returns Ok(None). Useful for pure directive-testing
without side effects.
DirectivePayload
#![allow(unused)] fn main() { #[typetag::serde(tag = "custom_type")] pub trait DirectivePayload: Debug + Send + Sync + DynClone {} }
Marker trait for user-defined directive data carried by Directive::Custom.
Requires #[typetag::serde] on the implementation for serialisation support.
Middleware
#![allow(unused)] fn main() { pub trait Middleware: Send + Sync { fn name(&self) -> &str; fn process(&self, input: MiddlewareInput) -> BoxFuture<'_, Result<MiddlewareResult, AgentError>>; // default: pass-through fn tools(&self) -> Vec<Box<dyn Tool>> { vec![] } fn system_prompt_additions(&self) -> Vec<String> { vec![] } } }
Cross-cutting concern injected into the agent loop via MiddlewareStack.
| Method | Description |
|---|---|
name | Identifier for logging and ordering diagnostics. |
process | Transform MiddlewareInput. Return Continue(modified) to chain or Terminate(reason) to halt. The default implementation is a no-op pass-through. |
tools | Additional tools injected into the agent context by this middleware. |
system_prompt_additions | Prompt fragments appended in stack order by MiddlewareStack::system_prompt_additions. |
Plugin
#![allow(unused)] fn main() { pub trait Plugin: Send + Sync { fn name(&self) -> &str; fn on_user_message<'a>(&'a self, input: &'a PluginInput, state: &'a PluginStateMap) -> BoxFuture<'a, Vec<Directive>> { /* default: empty */ } fn on_event<'a>(&'a self, event: &'a AgentEvent, state: &'a PluginStateMap) -> BoxFuture<'a, Vec<Directive>> { /* default: empty */ } fn before_run<'a>(&'a self, state: &'a PluginStateMap) -> BoxFuture<'a, Vec<Directive>> { /* default: empty */ } fn after_run<'a>(&'a self, state: &'a PluginStateMap) -> BoxFuture<'a, Vec<Directive>> { /* default: empty */ } fn signal_routes(&self) -> Vec<SignalRoute> { vec![] } } }
Lifecycle extension point for the agent loop. All methods have default no-op implementations; plugins only override hooks they require.
| Method | Triggered when |
|---|---|
on_user_message | A user message arrives. |
on_event | Any AgentEvent is emitted. |
before_run | Before each run loop iteration. |
after_run | After each run loop iteration. |
signal_routes | Called at startup to register signal routes in the composed router. |
Plugins return Vec<Directive> to request effects without direct mutation.
PluginStateKey
#![allow(unused)] fn main() { pub trait PluginStateKey: Send + Sync + 'static { type State: Send + Sync + 'static; const KEY: &'static str; } }
Typed key for isolated plugin state stored in PluginStateMap.
| Associated item | Description |
|---|---|
type State | The concrete state type stored for this plugin. Must be Send + Sync + 'static. |
const KEY | Unique string key used for serialisation. Must be globally unique across all registered plugins. |
Plugins cannot access other plugins' state — the TypeId of P enforces
isolation at runtime.
SignalRouter
#![allow(unused)] fn main() { pub trait SignalRouter: Send + Sync { fn route(&self, signal: &Signal) -> Option<Action>; fn routes(&self) -> Vec<SignalRoute>; } }
Routes Signal values to Action decisions.
| Method | Description |
|---|---|
route | Return the best-matching action, or None if no route matches. |
routes | All routes contributed by this router. |
ComposedRouter merges strategy, agent, and plugin route tiers: strategy routes
always win regardless of priority value. Within a tier, the highest priority
field wins.
SessionManager
#![allow(unused)] fn main() { pub trait SessionManager: Send + Sync { fn list(&self) -> BoxFuture<'_, Result<Vec<SessionMetadata>, AgentError>>; fn resume(&self, session_id: &str) -> BoxFuture<'_, Result<Session, AgentError>>; fn save(&self, session: &Session) -> BoxFuture<'_, Result<(), AgentError>>; fn delete(&self, session_id: &str) -> BoxFuture<'_, Result<(), AgentError>>; fn fork(&self, session_id: &str, new_name: Option<String>) -> BoxFuture<'_, Result<SessionMetadata, AgentError>>; fn rewind(&self, session_id: &str, turn_index: u32) -> BoxFuture<'_, Result<Session, AgentError>>; fn tag(&self, session_id: &str, tags: Vec<String>) -> BoxFuture<'_, Result<(), AgentError>>; fn rename(&self, session_id: &str, new_name: String) -> BoxFuture<'_, Result<(), AgentError>>; } }
Manages session persistence and lifecycle.
| Method | Description |
|---|---|
list | Return all session metadata, ordered by updated_at descending. |
resume | Load the full Session by ID. |
save | Create or update a session, refreshing updated_at. |
delete | Remove a session and all associated data. |
fork | Duplicate a session with a new ID. The copy shares history up to the fork point. new_name overrides the name or appends " (fork)". |
rewind | Truncate messages to turn_index (zero-based). Returns the modified session. |
tag | Add tags. Duplicate tags are silently ignored. |
rename | Update the human-readable session name. |
InMemorySessionManager (in synwire_agent) is an ephemeral implementation
suitable for testing. A persistent SQLite implementation is in synwire-checkpoint.
ModelProvider
#![allow(unused)] fn main() { pub trait ModelProvider: Send + Sync { fn list_models(&self) -> BoxFuture<'_, Result<Vec<ModelInfo>, AgentError>>; } }
Implemented by LLM provider crates (synwire-llm-openai, synwire-llm-ollama).
Returns the set of models offered by the provider along with their capabilities
and context window sizes.
Backend Traits — synwire_core::vfs
Vfs
#![allow(unused)] fn main() { pub trait Vfs: Send + Sync { fn ls(&self, path: &str) -> BoxFuture<'_, Result<Vec<DirEntry>, VfsError>>; fn read(&self, path: &str) -> BoxFuture<'_, Result<FileContent, VfsError>>; fn write(&self, path: &str, content: &[u8]) -> BoxFuture<'_, Result<WriteResult, VfsError>>; fn edit(&self, path: &str, old: &str, new: &str) -> BoxFuture<'_, Result<EditResult, VfsError>>; fn grep(&self, pattern: &str, opts: GrepOptions) -> BoxFuture<'_, Result<Vec<GrepMatch>, VfsError>>; fn glob(&self, pattern: &str) -> BoxFuture<'_, Result<Vec<GlobEntry>, VfsError>>; fn upload(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>>; fn download(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>>; fn pwd(&self) -> BoxFuture<'_, Result<String, VfsError>>; fn cd(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>>; fn rm(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>>; fn cp(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>>; fn mv_file(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>>; fn capabilities(&self) -> VfsCapabilities; } }
Unified protocol for agent filesystem and storage operations. Backends that do
not support an operation return VfsError::Unsupported.
| Method | Description |
|---|---|
ls | List directory contents. |
read | Read file bytes and optional MIME type. |
write | Write bytes; creates or overwrites. |
edit | Replace the first occurrence of old with new (text files only). |
grep | Ripgrep-style content search with GrepOptions. |
glob | Find paths matching a glob pattern. |
upload | Copy a local file (from) to the backend (to). |
download | Copy a backend file (from) to a local path (to). |
pwd | Return the current working directory. |
cd | Change the current working directory. |
rm | Remove a file or directory. |
cp | Copy within the backend. |
mv_file | Move or rename within the backend. |
capabilities | Return the VfsCapabilities bitflags supported. |
Implementations in synwire_agent: LocalProvider, GitBackend,
HttpBackend, ProcessManager, ArchiveManager, StoreProvider,
MemoryProvider, Shell, CompositeProvider.
SandboxVfs
#![allow(unused)] fn main() { pub trait SandboxVfs: Send + Sync { fn execute(&self, cmd: &str, args: &[String]) -> BoxFuture<'_, Result<ExecuteResponse, VfsError>>; fn execute_pipeline(&self, stages: &[PipelineStage]) -> BoxFuture<'_, Result<Vec<ExecuteResponse>, VfsError>>; fn id(&self) -> &str; } }
Separate from Vfs to make command-execution capability explicit.
| Method | Description |
|---|---|
execute | Run a single command with arguments. |
execute_pipeline | Run a sequence of stages; each stage's stdout is piped into the next. |
id | Sandbox identifier used for logging and audit. |
BaseSandbox is a type alias for dyn SandboxVfs + Send + Sync.
ApprovalCallback
#![allow(unused)] fn main() { pub trait ApprovalCallback: Send + Sync { fn request(&self, req: ApprovalRequest) -> BoxFuture<'_, ApprovalDecision>; } }
Gate for risky operations. Called before any operation whose RiskLevel requires
approval under the active PermissionMode.
AutoApproveCallback always returns ApprovalDecision::Allow.
AutoDenyCallback always returns ApprovalDecision::Deny.
ThresholdGate auto-approves operations at or below a configured RiskLevel
and delegates higher-risk operations to an inner callback.
MCP Traits — synwire_core::mcp
McpTransport
#![allow(unused)] fn main() { pub trait McpTransport: Send + Sync { fn connect(&self) -> BoxFuture<'_, Result<(), AgentError>>; fn reconnect(&self) -> BoxFuture<'_, Result<(), AgentError>>; fn disconnect(&self) -> BoxFuture<'_, Result<(), AgentError>>; fn status(&self) -> BoxFuture<'_, McpServerStatus>; fn list_tools(&self) -> BoxFuture<'_, Result<Vec<McpToolDescriptor>, AgentError>>; fn call_tool(&self, tool_name: &str, arguments: Value) -> BoxFuture<'_, Result<Value, AgentError>>; } }
Low-level transport for communicating with an MCP server.
| Method | Description |
|---|---|
connect | Establish connection. For StdioMcpTransport, this spawns the subprocess. |
reconnect | Re-establish after a drop without changing configuration. |
disconnect | Clean shutdown. |
status | Return a McpServerStatus snapshot including call counters and connection state. |
list_tools | Return all tools advertised by the server. |
call_tool | Invoke a tool by name with JSON arguments; returns the tool's JSON response. |
Implementations in synwire_agent: StdioMcpTransport, HttpMcpTransport,
InProcessMcpTransport. These are managed by McpLifecycleManager.
OnElicitation
#![allow(unused)] fn main() { pub trait OnElicitation: Send + Sync { fn elicit(&self, request: ElicitationRequest) -> BoxFuture<'_, Result<ElicitationResult, AgentError>>; } }
Receives mid-call requests for additional user input from an MCP server
(credentials, confirmations, etc.) and returns a ElicitationResult.
CancelAllElicitations is the default implementation — it cancels every request
without prompting.
Store Trait — synwire_agent
BaseStore
#![allow(unused)] fn main() { pub trait BaseStore: Send + Sync { fn get(&self, namespace: &str, key: &str) -> Result<Option<Vec<u8>>, VfsError>; fn set(&self, namespace: &str, key: &str, value: Vec<u8>) -> Result<(), VfsError>; fn delete(&self, namespace: &str, key: &str) -> Result<(), VfsError>; fn list(&self, namespace: &str) -> Result<Vec<String>, VfsError>; } }
Synchronous namespaced key-value store. Note: unlike most Synwire traits,
BaseStore methods are synchronous (Result, not BoxFuture).
| Method | Description |
|---|---|
get | Return the value for namespace/key, or None if absent. |
set | Write value to namespace/key. Creates or overwrites. |
delete | Remove namespace/key. Returns VfsError::NotFound if absent. |
list | Return all keys in namespace (without the namespace prefix). |
InMemoryStore wraps a BTreeMap behind a RwLock.
StoreProvider wraps a BaseStore and exposes it as Vfs with
paths of the form /<namespace>/<key>.
Types Reference
All public structs, enums, and type aliases. Enums marked #[non_exhaustive]
cannot be exhaustively matched outside this crate — always include a _ arm.
Agent Builder — synwire_core::agents::agent_node
Agent<O>
Builder for configuring and constructing a runnable agent. O is the optional
structured output type; use () for unstructured text.
#![allow(unused)] fn main() { pub struct Agent<O: Serialize + Send + Sync + 'static = ()> { /* private */ } }
Builder methods (all #[must_use], all return Self):
| Method | Description |
|---|---|
new(name, model) | Create a builder with name and primary model set. |
description(s) | Human-readable description. |
model(s) | Override the primary model identifier. |
fallback_model(s) | Model used when the primary is rate-limited or unavailable. |
effort(EffortLevel) | Reasoning effort hint. |
thinking(ThinkingConfig) | Extended thinking / chain-of-thought configuration. |
tool(t) | Add a tool available to the agent. |
allowed_tools(iter) | Allowlist of tool names (only these tools may be called). |
exclude_tool(name) | Remove a tool by name from the effective set. |
plugin(p) | Attach a plugin. |
middleware(mw) | Append a middleware to the stack. |
hooks(HookRegistry) | Register lifecycle hooks. |
output_mode(OutputMode) | Configure structured output extraction. |
output_schema(Value) | JSON Schema for output validation. |
max_turns(u32) | Maximum turns per run. |
max_budget(f64) | Maximum cumulative cost in USD. |
system_prompt(SystemPromptConfig) | Append to or replace the base system prompt. |
permission_mode(PermissionMode) | Permission preset. |
permission_rule(PermissionRule) | Add a declarative permission rule. |
sandbox(SandboxConfig) | Sandbox configuration. |
env(key, value) | Set an environment variable available to the agent. |
cwd(path) | Set the working directory. |
debug() | Enable verbose debug logging. |
debug_file(path) | Write debug output to a file. |
mcp_server(name) | Register an MCP server by name. |
before_agent(f) | Callback invoked before each turn. |
after_agent(f) | Callback invoked after each turn (success or failure). |
on_model_error(f) | Callback invoked on model errors; returns ModelErrorAction. |
Agent<O> implements AgentNode.
RunContext
Runtime context made available during agent execution.
#![allow(unused)] fn main() { pub struct RunContext { pub session_id: Option<String>, pub model: String, pub retry_count: u32, pub cumulative_cost_usd: f64, pub metadata: HashMap<String, Value>, } }
| Field | Description |
|---|---|
session_id | Active session ID, or None for stateless runs. |
model | Model identifier resolved for this run. |
retry_count | Number of retries for the current turn (0 = first attempt). |
cumulative_cost_usd | Total cost accumulated in this session so far. |
metadata | Arbitrary metadata attached at the call site. |
ModelErrorAction
Recovery action returned by an OnModelErrorCallback. #[non_exhaustive]
| Variant | Description |
|---|---|
Retry | Retry the current request unchanged. |
Abort(String) | Abort the run with the given message. |
SwitchModel(String) | Switch to the specified model and retry. |
Runner — synwire_core::agents::runner
Runner<O>
Drives the agent execution loop. Stateless between runs.
#![allow(unused)] fn main() { pub struct Runner<O: Serialize + Send + Sync + 'static = ()> { /* private */ } }
| Method | Description |
|---|---|
new(agent) | Create a runner wrapping the given Agent<O>. |
async set_model(model) | Switch to a different model for subsequent turns without resetting conversation history. |
async stop_graceful() | Signal a graceful stop; the runner finishes any in-flight tool call then emits TurnComplete { reason: Stopped }. |
async stop_force() | Signal an immediate stop; emits TurnComplete { reason: Aborted }. |
async run(input, config) -> Result<Receiver<AgentEvent>, AgentError> | Start a run. Events arrive on the returned mpsc::Receiver. The stream ends after a TurnComplete or Error event. |
RunnerConfig
Configuration for a single runner execution.
#![allow(unused)] fn main() { pub struct RunnerConfig { pub model_override: Option<String>, pub session_id: Option<String>, pub max_retries: u32, // default: 3 } }
| Field | Description |
|---|---|
model_override | Override the agent's model for this specific run. |
session_id | Resume an existing session, or None for a new session. |
max_retries | Maximum retries per model error before falling back or aborting. |
StopKind
#![allow(unused)] fn main() { pub enum StopKind { Graceful, Force, } }
| Variant | Description |
|---|---|
Graceful | Drain in-flight tool calls, then stop. |
Force | Cancel immediately without draining. |
RunErrorAction
Action taken by the runner when an error occurs. #[non_exhaustive]
| Variant | Description |
|---|---|
Retry | Retry the current request (up to max_retries). |
Continue | Ignore this error and advance to the next turn. |
Abort(String) | Abort the run immediately. |
SwitchModel(String) | Switch to the given model and retry. |
Directive System — synwire_core::agents::directive
Directive
Typed effect description returned by agent nodes. Directives describe side
effects without executing them, enabling pure unit tests. #[non_exhaustive]
#![allow(unused)] fn main() { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] #[non_exhaustive] pub enum Directive { ... } }
| Variant | Fields | Description |
|---|---|---|
Emit | event: AgentEvent | Emit an event to the event stream. |
SpawnAgent | name: String, config: Value | Request spawning a child agent. |
StopChild | name: String | Request stopping a child agent. |
Schedule | action: String, delay: Duration | Schedule a delayed action. Delay is serialised via humantime_serde. |
RunInstruction | instruction: String, input: Value | Ask the runtime to execute an instruction and route the result back. |
Cron | expression: String, action: String | Schedule a recurring action. |
Stop | reason: Option<String> | Request agent stop. |
SpawnTask | description: String, input: Value | Spawn a background task. |
StopTask | task_id: String | Cancel a background task by ID. |
Custom | payload: Box<dyn DirectivePayload> | User-defined directive. Requires #[typetag::serde] on the payload type. |
DirectiveResult<S>
Combines a state update with zero or more directives. S must implement
synwire_core::State.
#![allow(unused)] fn main() { pub struct DirectiveResult<S: State> { pub state: S, pub directives: Vec<Directive>, } }
Constructors:
| Method | Description |
|---|---|
state_only(s) | No directives. |
with_directive(s, d) | One directive. |
with_directives(s, ds) | Multiple directives. |
From<S> | Converts state directly (equivalent to state_only). |
Errors
AgentError
Top-level error for agent operations. #[non_exhaustive]
| Variant | Description |
|---|---|
Model(ModelError) | LLM API error (#[from] conversion from ModelError). |
Tool(String) | Tool execution failure. |
Strategy(String) | Execution strategy error. |
Middleware(String) | Middleware error. |
Directive(String) | Directive execution error. |
Backend(String) | Backend operation error. |
Session(String) | Session management error. |
Panic(String) | Caught panic with message payload. |
BudgetExceeded(f64) | Cost exceeded the configured budget (USD). |
ModelError
LLM API error with retryability metadata. #[non_exhaustive]
| Variant | Retryable | Description |
|---|---|---|
Authentication(String) | No | API key or credential failure. |
Billing(String) | No | Quota or billing error. |
RateLimit(String) | Yes | Rate limit exceeded. |
ServerError(String) | Yes | Provider server error (5xx). |
InvalidRequest(String) | No | Malformed request. |
MaxOutputTokens | No | Response exceeded the output token limit. |
Methods:
| Method | Returns | Description |
|---|---|---|
is_retryable() | bool | true for RateLimit and ServerError variants. |
StrategyError
Execution strategy error. #[non_exhaustive]
| Variant | Fields | Description |
|---|---|---|
InvalidTransition | current_state, attempted_action, valid_actions | Action not valid from the current FSM state. |
GuardRejected(String) | — | All guard conditions rejected the transition. |
NoInitialState | — | FsmStrategyBuilder::build called without setting an initial state. |
Execution(String) | — | General execution failure (e.g. mutex poisoned). |
DirectiveError
Error from DirectiveExecutor. #[non_exhaustive]
| Variant | Description |
|---|---|
ExecutionFailed(String) | Directive could not be executed. |
Unsupported(String) | Directive type not supported by this executor. |
FilterDecision
Decision returned by DirectiveFilter::decision. #[non_exhaustive]
| Variant | Description |
|---|---|
Pass | Directive passes through (may be modified). |
Suppress | Directive is silently dropped. |
Reject | Directive is rejected with an error. |
FilterChain
Ordered sequence of DirectiveFilter implementations.
| Method | Description |
|---|---|
new() | Create an empty chain. |
add(filter) | Append a filter. |
apply(directive) -> Option<Directive> | Apply all filters in order. Returns None if any filter suppresses. |
VfsError
Backend operation error. #[non_exhaustive]
| Variant | Description |
|---|---|
NotFound(String) | File or directory not found. |
PermissionDenied(String) | Insufficient permissions. |
IsDirectory(String) | Expected file, got directory. |
PathTraversal { attempted, root } | Path traversal attempt blocked. |
ScopeViolation { path, scope } | Path outside the allowed scope. |
ResourceLimit(String) | Resource limit exceeded. |
Timeout(String) | Operation timed out. |
OperationDenied(String) | Denied by approval gate. |
Unsupported(String) | Operation not supported by this backend. |
Io(io::Error) | I/O error (#[from] conversion). |
Streaming — synwire_core::agents::streaming
AgentEvent
Streaming event produced during an agent run. #[non_exhaustive]
#![allow(unused)] fn main() { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] #[non_exhaustive] pub enum AgentEvent { ... } }
| Variant | Key fields | Description |
|---|---|---|
TextDelta | content: String | Streaming text chunk from the model. |
ToolCallStart | id: String, name: String | Tool invocation started. |
ToolCallDelta | id: String, arguments_delta: String | Streaming argument fragment. |
ToolCallEnd | id: String | Tool invocation arguments complete. |
ToolResult | id: String, output: ToolOutput | Tool execution result. |
ToolProgress | id: String, message: String, progress_pct: Option<f32> | Progress report from a long-running tool. |
StateUpdate | patch: Value | JSON patch to apply to agent state. |
DirectiveEmitted | directive: Value | Agent emitted a directive (serialised). |
StatusUpdate | status: String, progress_pct: Option<f32> | Human-readable status message. |
UsageUpdate | usage: Usage | Token and cost counters for the current turn. |
RateLimitInfo | utilization_pct: f32, reset_at: i64, allowed: bool | Rate limit information. |
TaskNotification | task_id: String, kind: TaskEventKind, payload: Value | Background task lifecycle event. |
PromptSuggestion | suggestions: Vec<String> | Model-suggested follow-up prompts. |
TurnComplete | reason: TerminationReason | Turn ended. Always the last event unless Error occurs first. |
Error | message: String | Fatal error; no further events follow. |
Method:
| Method | Returns | Description |
|---|---|---|
is_final_response() | bool | true for TurnComplete and Error. |
AgentEventStream
#![allow(unused)] fn main() { pub type AgentEventStream = Pin<Box<dyn Stream<Item = Result<AgentEvent, AgentError>> + Send>>; }
Type alias for the stream returned by AgentNode::run.
TerminationReason
Why a turn ended. #[non_exhaustive]
| Variant | Description |
|---|---|
Complete | Agent finished normally. |
MaxTurnsExceeded | max_turns limit reached. |
BudgetExceeded | max_budget limit reached. |
Stopped | Graceful stop requested via Runner::stop_graceful. |
Aborted | Force stop requested via Runner::stop_force. |
Error | Terminated due to an unrecoverable error. |
TaskEventKind
Lifecycle event for background tasks. #[non_exhaustive]
| Variant | Description |
|---|---|
Started | Task began executing. |
Progress | Task reported intermediate progress. |
Completed | Task finished successfully. |
Failed | Task encountered an error. |
Session — synwire_core::agents::session
Session
Full snapshot of a conversation, persisted by SessionManager.
#![allow(unused)] fn main() { pub struct Session { pub metadata: SessionMetadata, pub messages: Vec<Value>, pub state: Value, } }
| Field | Description |
|---|---|
metadata | Identity and statistics. |
messages | Conversation history as an array of JSON message objects. |
state | Arbitrary agent state (plugin state, environment, etc.) serialised as JSON. |
SessionMetadata
#![allow(unused)] fn main() { pub struct SessionMetadata { pub id: String, pub name: Option<String>, pub tags: Vec<String>, pub agent_name: String, pub created_at: i64, pub updated_at: i64, pub turn_count: u32, pub total_tokens: u64, } }
| Field | Description |
|---|---|
id | Unique session identifier (UUID string). |
name | Optional human-readable name. |
tags | User-defined tags for filtering and search. |
agent_name | Name of the agent this session belongs to. |
created_at | Unix milliseconds at creation. |
updated_at | Unix milliseconds at last save. |
turn_count | Number of conversation turns recorded. |
total_tokens | Cumulative token usage. |
Model and Config — synwire_core::agents
ModelInfo
#![allow(unused)] fn main() { pub struct ModelInfo { pub id: String, pub display_name: String, pub description: String, pub capabilities: ModelCapabilities, pub context_window: u32, pub max_output_tokens: u32, pub supported_effort_levels: Vec<EffortLevel>, } }
ModelCapabilities
#![allow(unused)] fn main() { pub struct ModelCapabilities { pub tool_calling: bool, pub vision: bool, pub streaming: bool, pub structured_output: bool, pub effort_levels: bool, } }
EffortLevel
Reasoning effort hint. #[non_exhaustive]
| Variant | Description |
|---|---|
Low | Minimal reasoning. |
Medium | Moderate reasoning. |
High | Deep reasoning (default). |
Max | Maximum reasoning. |
ThinkingConfig
Extended thinking / chain-of-thought configuration. #[non_exhaustive]
| Variant | Fields | Description |
|---|---|---|
Adaptive | — | Model decides reasoning depth. |
Enabled | budget_tokens: u32 | Fixed token budget for reasoning. |
Disabled | — | No reasoning. |
Usage
Token usage and cost for a single turn.
#![allow(unused)] fn main() { pub struct Usage { pub input_tokens: u64, pub output_tokens: u64, pub cache_read_tokens: u64, pub cache_creation_tokens: u64, pub cost_usd: f64, pub context_utilization_pct: f32, } }
context_utilization_pct is in the range 0.0–1.0.
OutputMode
How the agent extracts structured output from the model response.
#[non_exhaustive]
| Variant | Description |
|---|---|
Tool | Via tool call (most reliable). Default. |
Native | Native JSON mode (provider must support it). |
Prompt | Post-process raw text via prompt. |
Custom | User-supplied extraction function. |
SystemPromptConfig
#[non_exhaustive]
| Variant | Fields | Description |
|---|---|---|
Append | content: String | Append to the base system prompt. |
Replace | content: String | Replace the base system prompt entirely. |
PermissionMode
#[non_exhaustive]
| Variant | Description |
|---|---|
Default | Prompt for dangerous operations. Default. |
AcceptEdits | Auto-approve file modifications. |
PlanOnly | Read-only; no mutations allowed. |
BypassAll | Auto-approve everything. |
DenyUnauthorized | Deny unless a matching PermissionRule allows. |
PermissionBehavior
#[non_exhaustive]
| Variant | Description |
|---|---|
Allow | Allow the operation. |
Deny | Deny the operation. |
Ask | Prompt the user. |
PermissionRule
Declarative permission rule.
#![allow(unused)] fn main() { pub struct PermissionRule { pub tool_pattern: String, pub behavior: PermissionBehavior, } }
tool_pattern is a glob matched against tool names.
SandboxConfig
#![allow(unused)] fn main() { pub struct SandboxConfig { pub enabled: bool, pub network: Option<NetworkConfig>, pub filesystem: Option<FilesystemConfig>, pub allowed_commands: Option<Vec<String>>, pub denied_commands: Vec<String>, } }
NetworkConfig
#![allow(unused)] fn main() { pub struct NetworkConfig { pub enabled: bool, pub allowed_domains: Option<Vec<String>>, pub denied_domains: Vec<String>, } }
allowed_domains = None means all domains are permitted.
FilesystemConfig
#![allow(unused)] fn main() { pub struct FilesystemConfig { pub allowed_roots: Vec<String>, pub denied_paths: Vec<String>, } }
Signals — synwire_core::agents::signal
Signal
#![allow(unused)] fn main() { pub struct Signal { pub kind: SignalKind, pub payload: Value, } }
Constructor: Signal::new(kind, payload) — both fields are public but the
constructor is provided for convenience.
SignalKind
#[non_exhaustive]
| Variant | Description |
|---|---|
Stop | User requested stop. |
UserMessage | User message received. |
ToolResult | Tool invocation result available. |
Timer | Timer or cron event. |
Custom(String) | Application-defined signal. |
SignalRoute
Maps a signal kind (with optional predicate) to an action.
#![allow(unused)] fn main() { pub struct SignalRoute { pub kind: SignalKind, pub predicate: Option<fn(&Signal) -> bool>, pub action: Action, pub priority: i32, } }
The predicate field uses a function pointer (not a closure) so SignalRoute
remains Clone + Send + Sync.
Constructors:
| Method | Description |
|---|---|
new(kind, action, priority) | Route without predicate. |
with_predicate(kind, predicate, action, priority) | Route with a predicate. |
Action
Action resulting from signal routing. #[non_exhaustive]
| Variant | Description |
|---|---|
Continue | Continue processing normally. |
GracefulStop | Stop after draining in-flight work. |
ForceStop | Stop immediately. |
Transition(String) | Transition the FSM to the named state. |
Custom(String) | Application-defined action identifier. |
ComposedRouter
Three-tier signal router (strategy > agent > plugin). Within a tier, the route
with the highest priority value wins.
#![allow(unused)] fn main() { pub struct ComposedRouter { /* private */ } }
| Constructor | Description |
|---|---|
new(strategy_routes, agent_routes, plugin_routes) | Build a composed router from three priority tiers. |
Implements SignalRouter.
Hooks — synwire_core::agents::hooks
HookRegistry
Registry of lifecycle hooks with typed registration and per-hook timeout
enforcement. Hooks that exceed their timeout are skipped with a warn! log
and treated as HookResult::Continue.
Registration methods:
| Method | Hook point |
|---|---|
on_pre_tool_use(matcher, f) | Before a tool is called. |
on_post_tool_use(matcher, f) | After a tool succeeds. |
on_post_tool_use_failure(matcher, f) | After a tool fails. |
on_notification(matcher, f) | On notification events. |
on_subagent_start(matcher, f) | When a sub-agent starts. |
on_subagent_stop(matcher, f) | When a sub-agent stops. |
on_pre_compact(matcher, f) | Before conversation compaction. |
on_post_compact(matcher, f) | After conversation compaction. |
on_session_start(matcher, f) | When a session starts. |
on_session_end(matcher, f) | When a session ends. |
Hook functions have signature Fn(ContextType) -> BoxFuture<'static, HookResult>.
HookMatcher
Selects which events a hook applies to.
#![allow(unused)] fn main() { pub struct HookMatcher { pub tool_name_pattern: Option<String>, pub timeout: Duration, // default: 30 seconds } }
tool_name_pattern supports * as a wildcard. None matches all events.
timeout is enforced per-hook invocation.
HookResult
#[non_exhaustive]
| Variant | Description |
|---|---|
Continue | Proceed with normal execution. |
Abort(String) | Abort the current operation. |
Plugin System — synwire_core::agents::plugin
PluginStateMap
Type-keyed map for isolated plugin state. Access is via PluginStateKey
implementations; plugins cannot read each other's state.
| Method | Description |
|---|---|
new() | Create an empty map. |
register::<P>(state) -> Result<PluginHandle<P>, &'static str> | Register state for plugin P. Fails if P is already registered (returns P::KEY as Err). |
get::<P>() -> Option<&P::State> | Immutable borrow. |
get_mut::<P>() -> Option<&mut P::State> | Mutable borrow. |
insert::<P>(state) | Insert or replace state unconditionally. |
serialize_all() -> Value | Serialise all plugin state to a JSON object keyed by P::KEY strings. |
PluginHandle<P>
Zero-sized proof token returned by PluginStateMap::register. Holding a
PluginHandle<P> proves that plugin P has been registered. Implements
Copy + Clone + Debug.
PluginInput
Context passed to plugin lifecycle hooks.
#![allow(unused)] fn main() { pub struct PluginInput { pub turn: u32, pub message: Option<String>, } }
Middleware — synwire_core::agents::middleware
MiddlewareInput
#![allow(unused)] fn main() { pub struct MiddlewareInput { pub messages: Vec<Value>, pub context: Value, } }
MiddlewareResult
#[non_exhaustive]
| Variant | Description |
|---|---|
Continue(MiddlewareInput) | Pass (possibly modified) input to the next middleware. |
Terminate(String) | Halt the chain immediately. |
MiddlewareStack
Ordered collection of Middleware implementations.
| Method | Description |
|---|---|
new() | Create an empty stack. |
push(mw) | Append a middleware. |
async run(input) -> Result<MiddlewareResult, AgentError> | Execute all middleware in order. Stops at the first Terminate. |
system_prompt_additions() -> Vec<String> | Collect additions from all middleware in order. |
tools() -> Vec<Box<dyn Tool>> | Collect tools from all middleware in order. |
Execution Strategies — synwire_core::agents::execution_strategy
FsmStateId
Newtype wrapping String for FSM state identifiers. Implements
From<&str> + From<String> + Hash + Eq.
ActionId
Newtype wrapping String for action identifiers. Implements
From<&str> + From<String> + Hash + Eq.
ClosureGuard
Adapter wrapping Fn(&Value) -> bool as a GuardCondition.
#![allow(unused)] fn main() { pub struct ClosureGuard { /* private */ } }
| Constructor | Description |
|---|---|
new(name, f) | Create a named closure guard. |
synwire_agent — Implementations
FsmStrategy
FSM-constrained execution strategy. Actions must be valid transitions from the current state; guard conditions further restrict which transitions fire.
| Method | Description |
|---|---|
builder() -> FsmStrategyBuilder | Return a builder. |
current_state() -> Result<FsmStateId, StrategyError> | Return the current FSM state. |
Implements ExecutionStrategy.
FsmStrategyWithRoutes
FsmStrategy bundled with associated signal routes (produced by FsmStrategyBuilder::build).
Fields:
strategy: FsmStrategy— public for direct state inspection.
Implements ExecutionStrategy.
FsmStrategyBuilder
Builder for FsmStrategyWithRoutes.
| Method | Description |
|---|---|
state(id) | Declare a state (documentation only; states are inferred from transitions). |
transition(from, action, to) | Add an unconditional transition. |
transition_with_guard(from, action, to, guard, priority) | Add a guarded transition. Higher priority is evaluated first. |
initial(state) | Set the initial FSM state. |
route(SignalRoute) | Add a signal route contributed by this strategy. |
build() -> Result<FsmStrategyWithRoutes, StrategyError> | Build. Fails with StrategyError::NoInitialState if no initial state was declared. |
FsmTransition
#![allow(unused)] fn main() { pub struct FsmTransition { pub target: FsmStateId, pub guard: Option<Box<dyn GuardCondition>>, pub priority: i32, } }
DirectStrategy
Executes actions immediately without state constraints. Input is passed through unchanged.
#![allow(unused)] fn main() { pub struct DirectStrategy; // Clone + Default }
DirectStrategy::new() is equivalent to DirectStrategy::default().
Implements ExecutionStrategy.
InMemorySessionManager
Ephemeral SessionManager backed by a tokio::sync::RwLock<HashMap>. All
data is lost when the process exits. See synwire-checkpoint for persistence.
| Constructor | Description |
|---|---|
new() | Create an empty manager. |
InMemoryStore
In-memory BaseStore backed by RwLock<BTreeMap<String, Vec<u8>>>.
| Constructor | Description |
|---|---|
new() | Create an empty store. |
StoreProvider
Wraps a BaseStore and exposes it as Vfs. Keys are paths of the
form /<namespace>/<key>. Supports only READ, WRITE, RM.
| Constructor | Description |
|---|---|
new(namespace, store) | Create a backend scoped to namespace. |
CompositeProvider
Routes Vfs operations to the backend whose prefix is the longest
segment-boundary match. /store matches /store/foo but not /storefront.
#![allow(unused)] fn main() { pub struct CompositeProvider { /* private */ } }
| Constructor | Description |
|---|---|
new(mounts: Vec<Mount>) | Sorts mounts by descending prefix length automatically. |
For grep: delegates to the first mount that advertises VfsCapabilities::GREP.
For glob: aggregates results from all mounts that advertise VfsCapabilities::GLOB.
Mount
#![allow(unused)] fn main() { pub struct Mount { pub prefix: String, pub backend: Box<dyn Vfs>, } }
ThresholdGate
ApprovalCallback that auto-approves operations at or below a RiskLevel
threshold and delegates higher-risk operations to an inner callback. Caches
AllowAlways decisions for subsequent calls to the same operation.
| Constructor | Description |
|---|---|
new(threshold, inner) | Create a threshold gate. |
McpLifecycleManager
Manages multiple named MCP server connections: connects on start, reconnects on disconnect, and supports runtime enable/disable.
| Method | Description |
|---|---|
new() | Create an empty manager. |
async register(name, transport, reconnect_delay) | Register a transport under name. |
async start_all() | Connect all enabled servers. |
async stop_all() | Disconnect all servers. |
async enable(name) | Enable and connect a named server. |
async disable(name) | Disable and disconnect a named server. |
async all_status() -> Vec<McpServerStatus> | Current status of all managed servers. |
async list_tools(server_name) | List tools from a named server. |
async call_tool(server_name, tool_name, arguments) | Call a tool; reconnects if the server is disconnected. |
spawn_health_monitor(self: Arc<Self>, interval) | Spawn a background task that polls and reconnects dropped servers. |
Stdio / Http / InProcess MCP Transports
All three implement McpTransport.
| Type | Config variant | Description |
|---|---|---|
StdioMcpTransport | McpServerConfig::Stdio | Spawns a subprocess; communicates over stdin/stdout with newline-delimited JSON-RPC. |
HttpMcpTransport | McpServerConfig::Http | Communicates over HTTP; supports optional bearer token authentication. |
InProcessMcpTransport | McpServerConfig::InProcess | In-process server backed by registered tool definitions. |
SummarisationMiddleware
Detects when conversation history exceeds configured thresholds and marks the context for summarisation. The actual LLM summarisation call is injected by provider crates.
| Constructor | Description |
|---|---|
new(thresholds) | Create with custom thresholds. |
default() | max_messages = 50, max_tokens = 80_000, max_context_utilisation = 0.8. |
SummarisationThresholds
#![allow(unused)] fn main() { pub struct SummarisationThresholds { pub max_messages: Option<usize>, pub max_tokens: Option<usize>, pub max_context_utilisation: Option<f32>, } }
Any threshold set to None is not checked. max_context_utilisation is in
the range 0.0–1.0.
Backend Types — synwire_core::vfs::types
VfsCapabilities
Bitflags struct. Individual flags:
| Flag | Operation |
|---|---|
LS | List directory |
READ | Read files |
WRITE | Write files |
EDIT | Edit files |
GREP | Search content |
GLOB | Find files |
UPLOAD | Upload files |
DOWNLOAD | Download files |
PWD | Get working directory |
CD | Change working directory |
RM | Remove files |
CP | Copy files |
MV | Move files |
EXEC | Execute commands |
DirEntry
#![allow(unused)] fn main() { pub struct DirEntry { pub name: String, pub path: String, pub is_dir: bool, pub size: Option<u64>, pub modified: Option<DateTime<Utc>>, } }
FileContent
#![allow(unused)] fn main() { pub struct FileContent { pub content: Vec<u8>, pub mime_type: Option<String>, } }
WriteResult
#![allow(unused)] fn main() { pub struct WriteResult { pub path: String, pub bytes_written: u64, } }
EditResult
#![allow(unused)] fn main() { pub struct EditResult { pub path: String, pub edits_applied: usize, pub content_after: Option<String>, } }
edits_applied is 0 when the old string was not found; 1 when the first
occurrence was replaced.
GrepMatch
#![allow(unused)] fn main() { pub struct GrepMatch { pub file: String, pub line_number: usize, // 1-indexed; 0 when line_numbers is false pub column: usize, // 0-indexed byte offset of match start pub line_content: String, pub before: Vec<String>, pub after: Vec<String>, } }
In GrepOutputMode::Count mode, line_number holds the match count and
line_content holds its string representation.
GlobEntry
#![allow(unused)] fn main() { pub struct GlobEntry { pub path: String, pub is_dir: bool, pub size: Option<u64>, } }
TransferResult
#![allow(unused)] fn main() { pub struct TransferResult { pub path: String, pub bytes_transferred: u64, } }
Used by upload, download, cp, and mv_file.
FileInfo
#![allow(unused)] fn main() { pub struct FileInfo { pub path: String, pub size: u64, pub is_dir: bool, pub is_symlink: bool, pub modified: Option<DateTime<Utc>>, pub permissions: Option<u32>, // Unix mode bits } }
ExecuteResponse
#![allow(unused)] fn main() { pub struct ExecuteResponse { pub exit_code: i32, pub stdout: String, pub stderr: String, } }
ProcessInfo
#![allow(unused)] fn main() { pub struct ProcessInfo { pub pid: u32, pub command: String, pub cpu_pct: Option<f32>, pub mem_bytes: Option<u64>, pub parent_pid: Option<u32>, pub state: String, } }
JobInfo
Background job information.
#![allow(unused)] fn main() { pub struct JobInfo { pub id: String, pub pid: Option<u32>, pub command: String, pub status: String, } }
ArchiveEntry
#![allow(unused)] fn main() { pub struct ArchiveEntry { pub path: String, pub is_dir: bool, pub size: u64, // uncompressed size } }
ArchiveInfo
#![allow(unused)] fn main() { pub struct ArchiveInfo { pub entries: Vec<ArchiveEntry>, pub format: String, pub compressed_size: u64, } }
PipelineStage
One stage in a multi-command pipeline.
#![allow(unused)] fn main() { pub struct PipelineStage { pub command: String, pub args: Vec<String>, pub stderr_to_stdout: bool, pub timeout_secs: Option<u64>, } }
Used by SandboxVfs::execute_pipeline.
GrepOptions — synwire_core::vfs::grep_options
GrepOptions
Ripgrep-style search configuration. All fields have zero-value defaults.
#![allow(unused)] fn main() { pub struct GrepOptions { pub path: Option<String>, pub after_context: u32, pub before_context: u32, pub context: Option<u32>, // symmetric; overrides before/after if set pub case_insensitive: bool, pub glob: Option<String>, pub file_type: Option<String>, pub max_matches: Option<usize>, pub output_mode: GrepOutputMode, pub multiline: bool, pub line_numbers: bool, pub invert: bool, pub fixed_string: bool, } }
path = None searches from the current working directory.
file_type accepts ripgrep-style type names: rust, python, js,
typescript, json, yaml, toml, markdown, go, sh.
GrepOutputMode
#[non_exhaustive]
| Variant | Description |
|---|---|
Content | Return matching lines with context. Default. |
FilesWithMatches | Return only file paths that contain a match. |
Count | Return match counts per file (via GrepMatch::line_number). |
Approval — synwire_core::vfs::approval
ApprovalRequest
#![allow(unused)] fn main() { pub struct ApprovalRequest { pub operation: String, pub description: String, pub risk: RiskLevel, pub timeout_secs: Option<u64>, pub context: Value, } }
ApprovalDecision
#[non_exhaustive]
| Variant | Description |
|---|---|
Allow | Permit this invocation. |
Deny | Deny this invocation. |
AllowAlways | Permit this and all future invocations of the same operation. |
Abort | Abort the entire agent run. |
AllowModified { modified_context } | Permit with substituted context. |
RiskLevel
Ordered (PartialOrd) risk classification. #[non_exhaustive]
| Variant | Description |
|---|---|
None | No meaningful risk (read-only). |
Low | Reversible writes. |
Medium | File deletions, overwrites. |
High | System changes, process spawning. |
Critical | Irreversible or destructive. |
AutoApprove / AutoDeny Callbacks
| Type | Behaviour |
|---|---|
AutoApproveCallback | Always returns ApprovalDecision::Allow. |
AutoDenyCallback | Always returns ApprovalDecision::Deny. |
Both implement ApprovalCallback + Clone + Default + Debug.
MemoryProvider
Ephemeral in-memory implementation of Vfs. All data lives for
the lifetime of the backend instance. Suitable for agent scratchpads and test
fixtures.
Supports: LS, READ, WRITE, EDIT, GREP, GLOB, PWD, CD, RM,
CP, MV.
| Constructor | Description |
|---|---|
new() | Create an empty backend with / as the working directory. |
MCP Types — synwire_core::mcp
McpServerConfig
Connection configuration for an MCP server. #[non_exhaustive]
| Variant | Key fields | Description |
|---|---|---|
Stdio | command, args, env | Launch a subprocess and communicate over stdin/stdout. |
Http | url, auth_token, timeout_secs | Connect to an HTTP MCP server. |
Sse | url, auth_token, timeout_secs | Connect via Server-Sent Events transport. |
InProcess | name | In-process server backed by registered tool definitions. |
Method: transport_kind() -> &'static str — returns "stdio", "http",
"sse", or "in-process".
McpServerStatus
#![allow(unused)] fn main() { pub struct McpServerStatus { pub name: String, pub state: McpConnectionState, pub calls_succeeded: u64, pub calls_failed: u64, pub enabled: bool, } }
McpToolDescriptor
#![allow(unused)] fn main() { pub struct McpToolDescriptor { pub name: String, pub description: String, pub input_schema: Value, // JSON Schema } }
McpConnectionState
#[non_exhaustive]
| Variant | Description |
|---|---|
Disconnected | Not yet connected. Default. |
Connecting | Connection attempt in progress. |
Connected | Ready to accept tool calls. |
Reconnecting | Reconnection in progress after a drop. |
Shutdown | Server has been shut down. |
ElicitationRequest
#![allow(unused)] fn main() { pub struct ElicitationRequest { pub request_id: String, pub message: String, pub response_schema: Value, // JSON Schema for the expected response pub required: bool, } }
ElicitationResult
#[non_exhaustive]
| Variant | Fields | Description |
|---|---|---|
Provided | request_id: String, value: Value | User provided a valid response. |
Cancelled | request_id: String | User cancelled without providing a response. |
CancelAllElicitations
Default OnElicitation implementation. Cancels every request without prompting.
Implements Default + Debug.
Dependency Reference
Every significant third-party crate used by Synwire, grouped by concern. When you encounter an unfamiliar import in Synwire source code or a compiler error mentioning a crate you don't recognise, look it up here.
Async runtime
tokio
docs.rs: https://docs.rs/tokio
What it is: The most widely used async runtime for Rust. Provides the thread pool, timers, I/O reactors, channels, and synchronisation primitives that underpin all of Synwire.
Where you'll see it: #[tokio::main], #[tokio::test], tokio::spawn, tokio::sync::Mutex, tokio::time::sleep.
When to use it directly: Your application entry point needs #[tokio::main]; async tests need #[tokio::test]. All Synwire async code runs on the tokio executor.
futures-core / futures-util
docs.rs: https://docs.rs/futures
What it is: The Stream trait and its combinators (StreamExt, FutureExt, SinkExt).
Where you'll see it: BoxStream in all streaming APIs; StreamExt::next() to consume AgentEvent streams.
When to use it directly: When consuming a BoxStream — call .next().await or .for_each(...).
pin-project-lite
docs.rs: https://docs.rs/pin-project-lite
What it is: Safe Pin projection for custom Future and Stream implementations.
Where you'll see it: Synwire internals for stream adapters; you rarely touch this directly.
When to use it directly: When writing a custom Future or Stream struct that holds pinned fields.
Serialisation
serde + serde_json
docs.rs: https://docs.rs/serde, https://docs.rs/serde_json
What it is: The de-facto serialisation framework for Rust. serde defines Serialize/Deserialize traits; serde_json implements them for JSON.
Where you'll see it: serde_json::Value is the universal I/O type for RunnableCore, StateGraph channels, and Checkpoint storage. Every Synwire public type derives Serialize and Deserialize.
When to use it directly: Your own state structs and tool parameter types need #[derive(Serialize, Deserialize)].
typetag
docs.rs: https://docs.rs/typetag
What it is: Enables dyn Serialize and dyn Deserialize for trait objects — normally impossible because trait objects erase type information.
Where you'll see it: Directive::Custom(Box<dyn CustomDirective>) uses typetag to serialise unknown directive types.
When to use it directly: When implementing CustomDirective for a new directive variant.
humantime-serde
docs.rs: https://docs.rs/humantime-serde
What it is: Serde support for human-readable duration strings ("30s", "2h", "500ms").
Where you'll see it: TimeoutMiddleware, RateLimitMiddleware config structs.
When to use it directly: Add #[serde(with = "humantime_serde")] to any Duration field in your config struct.
json-patch
docs.rs: https://docs.rs/json-patch
What it is: RFC 6902 JSON Patch and RFC 7386 JSON Merge Patch.
Where you'll see it: StateGraph channel merge operations.
When to use it directly: Rarely needed directly.
Error handling
thiserror
docs.rs: https://docs.rs/thiserror
What it is: #[derive(Error)] for library error types. Generates Display and From impls from annotations.
Where you'll see it: Every Synwire error enum (SynwireError, AgentError, VfsError, etc.).
When to use it directly: Your own extension crates should use thiserror for their error types — this ensures errors compose well with Synwire's error hierarchy. Use anyhow only in application binaries and tests.
HTTP and streaming
reqwest
docs.rs: https://docs.rs/reqwest
What it is: Async HTTP client with rustls TLS, JSON request/response, streaming, and multipart.
Where you'll see it: Used by synwire-llm-openai, synwire-llm-ollama, and HttpBackend.
When to use it directly: When implementing a custom LLM provider or HttpBackend extension.
reqwest-middleware / reqwest-retry
docs.rs: https://docs.rs/reqwest-middleware
What it is: Middleware layer and automatic retry logic for reqwest.
Where you'll see it: synwire-llm-openai wraps its client with exponential backoff retry.
When to use it directly: When building a custom HTTP-based backend with retry behaviour.
eventsource-stream
docs.rs: https://docs.rs/eventsource-stream
What it is: Parses Server-Sent Events (SSE) from an async byte stream.
Where you'll see it: ChatOpenAI::stream() and ChatOllama::stream() use this to parse streaming LLM responses.
When to use it directly: When implementing a custom SSE-based streaming provider.
backoff
docs.rs: https://docs.rs/backoff
What it is: Exponential backoff with jitter for retry loops.
Where you'll see it: synwire-llm-openai retry logic.
When to use it directly: Custom retry loops in extension crates.
Security / credentials
secrecy
docs.rs: https://docs.rs/secrecy
What it is: Secret<T> wrapper that prevents values from appearing in Debug or Display output.
Where you'll see it: API keys in ChatOpenAI, ChatOllama, and any credential provider.
When to use it directly: Wrap API keys in Secret<String> in your own provider or tool implementations. Never store secrets in plain String fields.
Persistence
rusqlite
docs.rs: https://docs.rs/rusqlite
What it is: Synchronous SQLite bindings for Rust. Used with the bundled feature — no system libsqlite3 required.
Where you'll see it: synwire-checkpoint-sqlite uses this for durable checkpoint storage.
When to use it directly: When implementing a custom BaseCheckpointSaver backed by SQLite.
r2d2 + r2d2_sqlite
docs.rs: https://docs.rs/r2d2
What it is: Thread-safe connection pool. r2d2_sqlite provides the SQLite adapter.
Where you'll see it: SqliteSaver uses r2d2 to pool SQLite connections for concurrent checkpoint reads/writes.
When to use it directly: Any custom synchronous SQLite-backed component.
Observability
tracing
docs.rs: https://docs.rs/tracing
What it is: Structured, async-aware logging and span tracing. #[tracing::instrument] automatically records function arguments and execution time.
Where you'll see it: All Synwire async operations emit tracing spans when the tracing feature is enabled.
When to use it directly: Add #[tracing::instrument] to your AgentNode::process implementations and use tracing::info! / tracing::debug! for observability. Wire up a subscriber (e.g., tracing-subscriber) in your main.
opentelemetry + tracing-opentelemetry
docs.rs: https://docs.rs/opentelemetry
What it is: OpenTelemetry SDK and the tracing bridge that exports spans to OTLP collectors.
Where you'll see it: Optional feature for exporting Synwire traces to Jaeger, Honeycomb, etc.
When to use it directly: When you need distributed tracing across microservices.
Caching
moka
docs.rs: https://docs.rs/moka
What it is: Async-aware, bounded, concurrent LRU cache.
Where you'll see it: CacheBackedEmbeddings in the synwire umbrella crate caches embedding vectors to avoid redundant API calls.
When to use it directly: When building a custom CachingMiddleware variant or caching tool results.
Schema generation
schemars
docs.rs: https://docs.rs/schemars
What it is: Derives JSON Schema from Rust types. Used by #[tool] to generate tool input schemas.
Where you'll see it: Every #[tool]-annotated function's parameter type must implement JsonSchema.
When to use it directly: #[derive(JsonSchema)] on your tool parameter structs. Add #[schemars(description = "...")] on fields to populate JSON Schema descriptions.
Compression / archive
tar / flate2 / zip
docs.rs: https://docs.rs/tar, https://docs.rs/flate2, https://docs.rs/zip
What they are: Rust implementations of tar, gzip, and zip archive formats.
Where you'll see them: ArchiveManager uses all three to read and write archive files.
When to use them directly: You interact via ArchiveManager, not these crates directly.
Utilities
uuid
docs.rs: https://docs.rs/uuid
What it is: UUID generation (v4 random, v7 sortable).
Where you'll see it: Session IDs, checkpoint IDs, job IDs throughout the runtime.
When to use it directly: Uuid::new_v4() for your own entity IDs.
chrono
docs.rs: https://docs.rs/chrono
What it is: Date and time types with serde support.
Where you'll see it: SessionMetadata.created_at, SessionMetadata.updated_at, timestamps in checkpoint metadata.
When to use it directly: chrono::Utc::now() for timestamps in custom session or checkpoint implementations.
regex
docs.rs: https://docs.rs/regex
What it is: Regular expression engine (NFA-based, no backtracking, linear time).
Where you'll see it: GrepOptions.pattern is compiled to a regex::Regex inside LocalProvider and Shell.
When to use it directly: Custom search backends.
bitflags
docs.rs: https://docs.rs/bitflags
What it is: bitflags! macro for type-safe flag sets backed by an integer.
Where you'll see it: Permission flag sets in the agent runtime.
When to use it directly: Custom permission or capability flag types.
dyn-clone
docs.rs: https://docs.rs/dyn-clone
What it is: DynClone trait that enables clone() on trait objects.
Where you'll see it: Synwire clones boxed Middleware and Plugin instances when building runner variants.
When to use it directly: When implementing Middleware or Plugin and your type needs to be cloneable behind a Box<dyn ...>.
Protocol integration
async-lsp
docs.rs: https://docs.rs/async-lsp
What it is: Tower-based async Language Server Protocol client and server library. Handles Content-Length framing over stdio, request/response correlation, and middleware (concurrency limits, tracing, panic catching).
Where you'll see it: synwire-lsp uses MainLoop::new_client() and ServerSocket to communicate with language servers.
When to use it directly: Only via synwire-lsp — the crate wraps async-lsp and exposes agent-friendly tools.
lsp-types
docs.rs: https://docs.rs/lsp-types
What it is: Complete Rust type definitions for the Language Server Protocol specification. Approximately 300 types covering all LSP requests, responses, notifications, and data structures.
Where you'll see it: All LSP request/response types in synwire-lsp (GotoDefinitionResponse, Hover, CompletionResponse, ServerCapabilities, etc.).
When to use it directly: When constructing custom LSP request parameters or interpreting tool outputs.
dapts
docs.rs: https://docs.rs/dapts
What it is: Auto-generated Rust type definitions for the Debug Adapter Protocol specification. Covers requests, responses, events, and data types.
Where you'll see it: DAP message types in synwire-dap.
When to use it directly: When constructing custom DAP request parameters or interpreting debug tool outputs.
Testing
mockall
docs.rs: https://docs.rs/mockall
What it is: #[automock] attribute that generates a full mock struct for any trait, with call count assertions, argument matchers, and sequence enforcement.
Where you'll see it: Internal Synwire unit tests; you can use it in your own tests.
When to use it directly: When FakeChatModel isn't expressive enough — e.g., you need to assert "this method was called exactly 3 times with argument X".
proptest
docs.rs: https://docs.rs/proptest
What it is: Property-based testing framework. Generates random inputs from strategies and shrinks failures.
Where you'll see it: synwire-test-utils::strategies exposes proptest strategies for all Synwire types.
When to use it directly: proptest! { #[test] fn ... } macro for property tests. Use the strategies from synwire-test-utils rather than writing your own.
tokio-test
docs.rs: https://docs.rs/tokio-test
What it is: Test utilities for async code: task::spawn, assert_ready!, assert_pending!, io::Builder for mock I/O.
Where you'll see it: Synwire internal tests for async state machines.
When to use it directly: When testing custom Future or Stream implementations.
criterion
docs.rs: https://docs.rs/criterion
What it is: Statistical benchmark harness. Runs benchmarks many times, applies Welch's t-test, and produces HTML reports.
Where you'll see it: benches/ directory in synwire-core and synwire-orchestrator.
When to use it directly: Add a [[bench]] section to Cargo.toml and write benchmarks with criterion_group! and criterion_main!.
Proc-macro internals
syn / quote / proc-macro2
docs.rs: https://docs.rs/syn, https://docs.rs/quote, https://docs.rs/proc-macro2
What they are: The standard toolkit for writing Rust procedural macros. syn parses Rust source into an AST; quote! emits new Rust code; proc-macro2 provides token streams.
Where you'll see them: synwire-derive uses all three to implement #[tool] and #[derive(State)].
When to use them directly: When writing your own proc-macros. You do not need these when using synwire-derive.
Contributor Setup
Prerequisites
- Rust stable (1.85+, edition 2024)
cargo-nextestfor test executioncargo-clippyfor linting (included with rustup)
Clone and build
git clone https://github.com/randomvariable/synwire.git
cd synwire
cargo build --workspace
Run tests
# All tests (recommended)
cargo nextest run --workspace --all-features
# Doctests (nextest does not run these)
cargo test --workspace --doc
# Single crate
cargo nextest run -p synwire-core
Linting
# Clippy with workspace lints (must pass with zero warnings)
cargo clippy --workspace --all-targets --all-features -- -D warnings
# Format check
cargo fmt --all -- --check
Documentation
# Build rustdoc
cargo doc --workspace --no-deps --all-features
# Check for doc warnings
cargo doc --workspace --no-deps 2>&1 | grep -c warning
Workspace lints
The workspace enforces strict lints via Cargo.toml:
clippy::pedanticandclippy::nurseryat warn levelclippy::unwrap_used,clippy::expect_used,clippy::panic,clippy::todoare deniedmissing_docsis denied -- all public items must have doc commentsunsafe_codeis denied across all crates
Adding a new crate
- Create the crate directory under
crates/ - Add it to the workspace
membersin the rootCargo.toml - Add
[lints] workspace = trueto inherit workspace lints - Add
#![deny(unsafe_code)]or#![forbid(unsafe_code)]tolib.rs - Add
//!module-level documentation tolib.rs
Pull request checklist
-
cargo fmt --allpasses -
cargo clippy --workspace --all-targets --all-features -- -D warningspasses -
cargo nextest run --workspace --all-featurespasses -
cargo test --workspace --docpasses - New public items have doc comments
-
No
unsafecode unless justified and documented - No new dependencies without discussion
Documentation Style Guide
General principles
- Concise: prefer short, direct sentences
- Practical: include runnable code examples where possible
- Consistent: follow the patterns established in existing docs
Rust doc comments
Module-level (//!)
Every lib.rs and public module must have a //! doc comment explaining:
- What the module provides
- Key types it exports
#![allow(unused)] fn main() { //! Embedding traits and types. //! //! This module provides the [`Embeddings`] trait for text embedding models, //! plus a [`FakeEmbeddings`] implementation for deterministic testing. }
Item-level (///)
All public items (structs, enums, traits, functions, methods) must have /// doc comments.
#![allow(unused)] fn main() { /// Returns the `k` most similar documents to the query. /// /// # Errors /// /// Returns `SynwireError` if the embedding or search operation fails. fn similarity_search<'a>(/* ... */); }
Sections to include
| Section | When |
|---|---|
| Description | Always (first paragraph) |
# Examples | When the usage is not obvious |
# Errors | For fallible functions |
# Panics | If the function can panic (should be rare) |
# Safety | For unsafe functions (should not exist) |
Code examples
- Use
rust,ignorefor examples that require runtime context (async, API keys) - Use plain
rustfor examples that should compile as doctests - Prefer
FakeChatModel/FakeEmbeddingsin examples to avoid API key requirements - Wrap async examples in
tokio_test::block_onfor doctests
#![allow(unused)] fn main() { /// # Examples /// /// ``` /// use synwire_core::language_models::fake::FakeChatModel; /// use synwire_core::language_models::traits::BaseChatModel; /// use synwire_core::messages::Message; /// /// # tokio_test::block_on(async { /// let model = FakeChatModel::new(vec!["Hello!".into()]); /// let result = model.invoke(&[Message::human("Hi")], None).await.unwrap(); /// assert_eq!(result.message.content().as_text(), "Hello!"); /// # }); /// ``` }
mdbook documentation
File naming
- Lowercase with hyphens:
first-chat.md,retry-fallback.md - Match the SUMMARY.md entry exactly
Headings
#for page title (one per file)##for major sections###for subsections- Do not skip heading levels
Code blocks
- Use
rust,ignorefor Rust code that should not be tested - Use
tomlfor Cargo.toml snippets - Use
shfor shell commands - Use
mermaidfor diagrams (inside fenced code blocks)
Tables
Use Markdown tables for structured information. Align columns for readability in source.
Links
- Use relative paths for internal links:
[Streaming](./streaming.md) - Use
../to link across sections:[Feature Flags](../reference/feature-flags.md) - Link to rustdoc for API details rather than duplicating signatures
Error documentation
Every error enum variant must have a /// doc comment explaining:
- When this error occurs
- What the fields mean
#![allow(unused)] fn main() { /// Rate limit exceeded. #[error("rate limit exceeded")] RateLimit { /// Optional duration to wait before retrying. retry_after: Option<Duration>, }, }
Commit messages
- Start with a verb: "Add", "Fix", "Update", "Remove"
- Reference the task number if applicable
- Keep the first line under 72 characters