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/await with Tokio
  • Graph orchestration -- build stateful agent workflows with StateGraph
  • Type-safe macros -- #[tool] and #[derive(State)] for ergonomic definitions
  • Comprehensive testing -- FakeChatModel and FakeEmbeddings for 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

CrateDescription
synwire-coreCore traits: BaseChatModel, Embeddings, VectorStore, Tool, RunnableCore
synwire-orchestratorGraph-based orchestration: StateGraph, CompiledGraph, channels
synwire-checkpointCheckpoint traits and in-memory implementation
synwire-checkpoint-sqliteSQLite checkpoint backend
synwire-llm-openaiOpenAI provider (ChatOpenAI, OpenAIEmbeddings)
synwire-llm-ollamaOllama provider (ChatOllama, OllamaEmbeddings)
synwire-deriveProcedural macros (#[tool], #[derive(State)])
synwire-test-utilsFake models, proptest strategies, fixture builders
synwireConvenience re-exports, caches, text splitters, few-shot prompts
  • 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

TypePurpose
BaseChatModelTrait for all chat models
MessageConversation message (human, AI, system, tool)
ChatResultModel response including the AI message
FakeChatModelDeterministic model for testing
RunnableConfigOptional configuration (callbacks, tags, metadata)

Next steps

See also

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. BaseChatModel is a trait — because both ChatOllama and ChatOpenAI implement it, you can store either behind a Box<dyn BaseChatModel> and swap them without changing any other code.

Prerequisites

  1. Install Ollama from https://ollama.com
  2. Pull a model:
ollama pull llama3.2
  1. 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 fn and .await let this code run concurrently without blocking a thread. StreamExt::next().await yields 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

MethodDefaultDescription
.model(name)Required. Any model pulled via ollama pull
.base_url(url)http://localhost:11434Ollama server address
.temperature(f32)model defaultSampling temperature
.top_k(u32)model defaultTop-k sampling
.top_p(f32)model defaultTop-p (nucleus) sampling
.num_predict(i32)model defaultMax tokens to generate (-1 for unlimited)
.timeout(Duration)5 minutesRequest timeout

See also

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

ParserOutput typeUse case
StrOutputParserStringPlain text extraction
JsonOutputParserserde_json::ValueJSON responses
StructuredOutputParserTyped T: DeserializeOwnedStructured data
ToolsOutputParserVec<ToolCall>Tool call extraction

Next steps

  • Streaming -- stream responses incrementally
  • RAG -- add retrieval-augmented generation

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

FieldTypeDescription
delta_contentOption<String>Incremental text content
delta_tool_callsVec<ToolCallChunk>Incremental tool call data
finish_reasonOption<String>"stop" on the final chunk
usageOption<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)

This tutorial builds a RAG pipeline using vector stores, embeddings, and a retriever.

Overview

A RAG pipeline:

  1. Splits documents into chunks
  2. Embeds chunks into a vector store
  3. Retrieves relevant chunks for a query
  4. 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

See also

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
  1. agent node: invokes the model with current messages
  2. tools_condition: checks if the AI response contains tool calls
  3. tools node: executes tool calls and appends results
  4. 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

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::Value passed 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

See also

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

AnnotationChannel typeBehaviour
(none)LastValueOverwrites with the latest value
#[reducer(topic)]TopicAppends 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

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:

  1. Constructs an Agent using the builder API.
  2. Wraps it in a Runner.
  3. Sends a single input message and reads the event stream to completion.
  4. 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 } and tokio = { 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:

MethodPurpose
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:

VariantMeaning
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:

EventWhen 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.md for registering typed tool handlers.
  • Structured output: See ../how-to/structured_output.md for binding Agent<MyType>.
  • Understanding the event model: See ../explanation/event_model.md for a deep dive into how events, turns, and retries interact.
  • Next tutorial: Continue with 02-pure-directive-testing.md to 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:

  1. Increments a counter in state.
  2. Emits a SpawnAgent directive when the counter reaches a threshold.
  3. Emits a Stop directive 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>,
}
}
  • state is the new state value after the node ran. It is applied immediately.
  • directives is 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:

ConstructorUse 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:

VariantPurpose
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 — no async, no runtime references.
  • The Directive enum describes every possible side effect.
  • NoOpExecutor lets you wire the executor interface into tests without executing anything.
  • Directive is fully serialisable for logging, queueing, or persistence.

Next steps

  • Execution strategies: Continue with 03-execution-strategies.md to learn how to constrain which actions an agent can take based on FSM state.
  • Custom directives: See ../explanation/directive_system.md for implementing a custom DirectivePayload via typetag.
  • How-to guide: See ../how-to/testing.md for composing test fixtures with synwire-test-utils proptest 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

  1. A DirectStrategy agent that accepts any action.
  2. An FsmStrategy for a simple three-state workflow: idle → running → done.
  3. A guard that rejects a transition based on input content.
  4. 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

StrategyWhen to use
DirectStrategyNo ordering constraints; any action is valid
FsmStrategyOrdered workflow; reject invalid action sequences
ClosureGuardRuntime inspection of action input before committing

Next steps

  • Plugin state: Continue with 04-plugin-state-isolation.md to learn how plugins attach isolated state slices to an agent.
  • Persisting snapshots: See ../how-to/checkpointing.md for storing FSM snapshots in the SQLite checkpoint backend.
  • Deep dive: See ../explanation/execution_strategies.md for the full lifecycle of a strategy within the runner loop.

See also

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 State is the concrete data type stored for this plugin.
  • KEY is 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:

MethodCalled when
on_user_messageA user message arrives
on_eventAny AgentEvent is emitted
before_runBefore each turn loop iteration
after_runAfter each turn loop iteration
signal_routesAt 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 — TypeId lookups are O(1) hash operations.
  • The KEY string constant exists only for serialisation; it plays no role in access control.

Next steps

  • Backend operations: Continue with 05-backend-operations.md to learn how to read and write files through the backend protocol.
  • Plugin how-to: See ../how-to/plugins.md for a complete guide to plugin registration, ordering, and dependency injection.
  • Architecture: See ../explanation/plugin_system.md for a deeper explanation of how TypeId-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

  1. Writing, reading, and navigating with MemoryProvider.
  2. Attempting a path traversal and observing the rejection.
  3. Using LocalProvider scoped to a temporary directory.
  4. Searching file content with grep and GrepOptions.
  5. Reading GrepMatch fields.

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:

VariantMeaning
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

FieldTypeDefaultDescription
pathOption<String>None (= cwd)Restrict search to this path
after_contextu320Lines to show after each match
before_contextu320Lines to show before each match
contextOption<u32>NoneSymmetric context (overrides before/after)
case_insensitiveboolfalseCase-insensitive match
globOption<String>NoneFile name glob filter (e.g. "*.rs")
file_typeOption<String>NoneRipgrep-style type filter ("rust", "python", ...)
max_matchesOption<usize>NoneStop after this many matches
output_modeGrepOutputModeContentOne of Content, FilesWithMatches, Count
line_numbersboolfalseInclude line numbers in output
invertboolfalseShow non-matching lines
fixed_stringboolfalseTreat pattern as literal string, not regex
multilineboolfalseAllow 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.


#![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

  • Vfs is the uniform interface for file operations across backends.
  • MemoryProvider is fully in-memory — perfect for tests and agent scratchpads.
  • LocalProvider is scoped to a root directory and enforces path traversal protection using normalised path comparison.
  • Both backends reject ../../etc/passwd-style traversal attempts with VfsError::PathTraversal.
  • grep supports case insensitivity, context lines, file type/glob filters, output modes, invert matching, and match limits through GrepOptions.
  • GrepMatch carries the file path, line number, column, matched content, and context lines.

Next steps

  • Composite backends: See ../how-to/vfs.md for composing MemoryProvider, LocalProvider, and custom backends through the CompositeProvider pipeline.
  • Shell execution: See ../how-to/shell.md for running commands with Shell and reading ExecuteResponse.
  • Architecture: See ../explanation/backend_protocol.md for 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:

  1. Understand what checkpointing does and when you need it
  2. Wire InMemoryCheckpointSaver into a StateGraph
  3. Resume a run from a checkpoint using thread_id
  4. Switch to SqliteSaver for persistence across process restarts
  5. 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 use Arc<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: InMemoryCheckpointSaver loses all state when the process exits. For true resumability across restarts, use SqliteSaver (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

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 Vfs and Tool are 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:

  1. Model receives the user's request plus the conversation history.
  2. Model emits a tool call (e.g., read_file("src/main.rs")).
  3. Runtime executes the tool and appends the result to the conversation.
  4. Model receives the tool result and decides whether to call another tool or respond.
  5. 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: schemars derives JSON Schema from your Rust types. The #[tool] attribute macro in synwire-derive calls 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::Deserialize and schemars::JsonSchema on the same struct serve different purposes: Deserialize parses JSON arguments at runtime; JsonSchema generates 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. The move keyword 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 an Arc creates 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 unwraps Ok(value) or immediately returns the Err to the caller — it replaces the boilerplate match 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 match is exhaustive — the compiler forces you to handle every variant. #[non_exhaustive] on AgentEvent means 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] transforms async fn main into a standard synchronous entry point by starting the Tokio runtime. Without it, you cannot .await at 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_code with the symbol name to find where it is defined. Only then call read_file on 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_hover or lsp_goto_definition to navigate directly to the relevant symbol. Use lsp_diagnostics after 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.launch to run it under the debugger. Set a breakpoint at the failing assertion, then use debug.variables to 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:

CapabilityImplementation
File read (with line windows)read_file_tool + LocalProvider
File write (full overwrite)write_file_tool + path traversal protection
Directory listinglist_dir_tool
Code search (ripgrep-style)search_code_tool + GrepOptions
Shell command executionrun_command_tool + Shell
Streaming terminal outputAgentEvent matching in REPL
Conversation continuityRunnerConfig::session_id
Approval gatesPermissionBehavior::Ask + handle_approval
Safe sandboxingLocalProvider path traversal protection
Unit-testable toolsMemoryProvider + FakeChatModel
Code intelligence (hover, goto-def, diagnostics)LspPlugin + auto-started language server
Interactive debugging (breakpoints, variables)DapPlugin + debug adapter

See also

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:

PrimitiveRole
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 FsmStrategy to enforce: research first, then implement, then test.
  • The inner tool uses StateGraph to chain two nodes: codebase exploration → report synthesis.
  • The agent sees deep_research as just another tool — it doesn't know there's a graph inside.

📖 Rust note: A StructuredTool wraps a closure Fn(Value) -> BoxFuture<Result<ToolOutput, SynwireError>>. Because StateGraph::invoke is an async function that returns Result<S, GraphError>, wrapping a graph as a tool is a one-liner: call invoke inside 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:

ApproachTopologyAgent autonomy
Graph-first (Tutorial 8 v1)StateGraph is the top level; the agent is a nodeLow — stages are fixed at compile time
Agent-first (this tutorial)Agent is the top level; the graph is a toolHigh — 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 the State trait 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 is Send + Sync, so wrapping it in Arc lets the tool closure capture it safely. The closure itself must be Fn (not FnOnce) 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: move closures take ownership of captured variables. The event-loop closure captures Arc clones of the counters and FSM, so each closure call can mutate the shared counters through Mutex::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:

  1. The agent starts in Idle, transitions to Researching.
  2. The agent calls deep_research("How is src/text.rs structured? What existing functions and tests exist?").
  3. Inside deep_research, the StateGraph runs: explore_node reads the codebase with sub-agents → synthesise_node produces a report → the report string is returned.
  4. The FSM transitions to Implementing. The agent can now call write_file.
  5. The agent writes src/text.rs, calls cargo test.
  6. The FSM transitions to Testing. On pass → Done. On fail → back to Implementing (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.

LayerCrateWhat it enforces
Outer agentsynwire-agentTurn order via FSM guards
Research toolsynwire-orchestratorGraph topology (explore → synthesise → END)
Tool traitsynwire-coreAPI 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

ComponentWhat it does
ResearchStateTyped graph state: query, raw findings, synthesised report
explore_nodeDirectStrategy agent that reads the codebase with file tools
synthesise_nodeOne-shot agent that distils findings into a structured report
build_research_graphStateGraph: explore → synthesise → END
deep_research_toolStructuredTool that wraps the graph — called by the outer agent
build_coder_fsmFsmStrategy: idle → researching → implementing → testing → done
run_coding_agentEvent loop that wires the FSM, tools, and agent together
FSM guardshas_researched, has_written, retry_allowed — structural safety

See also

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:

  1. Direct API — call LocalProvider methods from your own code
  2. As a built-in agent tool — the agent calls semantic_search alongside file and shell tools
  3. 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. Arc lets multiple tools share one VFS provider without copying it. dyn Vfs means "any type implementing the Vfs trait" — 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.


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

  1. Walk: synwire-index recursively traverses the directory, applying your include/exclude filters and file size limit.
  2. 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.
  3. Embed: Each chunk is converted into a 384-dimension vector using BAAI/bge-small-en-v1.5 (local ONNX inference).
  4. Store: Vectors are written to a LanceDB table cached on disk.
  5. 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 until break is reached. The match arms 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:

FieldDescription
filePath relative to the indexed directory
line_start1-indexed first line of the matching chunk
line_end1-indexed last line of the matching chunk
contentThe full chunk text (function body, paragraph, etc.)
scoreRelevance score (higher = more relevant after reranking)
symbolFunction/struct/class name, if extracted from AST
languageProgramming 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());
}

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:

  1. Calls index("src", {}) to start indexing
  2. Polls index_status until Ready
  3. Calls semantic_search("error handling patterns", { top_k: 10 })
  4. Reads the results, possibly calls read_file on interesting hits for full context
  5. Synthesises an answer

You write zero tool wrappers — vfs_tools handles everything.

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:

  1. semantic_search("authentication and authorization") → finds fn verify_token in auth.rs
  2. grep("verify_token") → finds all 14 call sites across the codebase
  3. read_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.

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,
}

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

ApproachUse whenExample
Direct APIYou control the flow yourself; no agent involvedCLI tools, scripts, tests
Built-in agent toolThe agent decides when to search; search is one of many actionsCoding agents, Q&A bots, interactive assistants
StateGraph nodeSearch is a fixed stage in a multi-step pipelineResearch 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:

ToolGranularityWhen the agent uses it
grepExact text/regex matchKnown identifiers, error strings
semantic_searchRaw vector search resultsFocused conceptual queries
semantic_researchSummarised research reportBroad "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-search feature flag enables local semantic search on LocalProvider
  • index() starts background indexing and returns an IndexHandle immediately
  • index_status() polls progress until the index is ready
  • semantic_search() finds code by meaning, with optional filtering and reranking
  • vfs_tools() automatically generates agent tools for all VFS capabilities — including index, index_status, and semantic_search
  • StateGraph nodes can run the index/search pipeline as a deterministic stage
  • The graph can be wrapped as a StructuredTool for agent-driven research

See also

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:

  1. Oneshot command — LLM runs a compiler, gets exit code + diagnostics
  2. Long-lived command with polling — LLM starts a test suite in background, polls for completion, reads partial output
  3. 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). Use run_command for 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):

ToolLLM calls it to...
run_commandExecute a command (oneshot or background)
open_shellStart an interactive PTY session
shell_writeSend keystrokes to a PTY session
shell_readRead available output (non-blocking)
shell_expectWait for a regex pattern (with capture groups)
shell_expect_casesWait for one of N patterns (switch/case)
shell_batchRun a send/expect sequence in one call
shell_signalSend an OS signal (Ctrl-C, SIGTERM, etc.)
list_processesSee all running processes
wait_for_processBlock until a process exits
read_process_outputRead captured stdout/stderr
kill_processSend a signal to a process
process_statsGet 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

  1. run_command translates SandboxConfig to an OCI runtime spec
  2. Calls runc run --bundle <tmpdir> <id> with stdout/stderr redirected to files (so output survives even if the process is killed)
  3. Waits up to timeout_secs for the process to exit
  4. Reads the captured output files
  5. 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: false returns immediately — the process runs in background
  • monitor_child updates the registry when the process exits
  • read_process_output reads 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 last Arc<CapturedOutput> is dropped (Go defer semantics)

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 yes to 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 destroy
  • kubectl delete with --confirm
  • apt 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

ScenarioToolWhy
Compile, lint, formatrun_command(wait: true)One call, one answer
Test suite, long buildrun_command(wait: false) + pollingLLM controls timing
Detect CLI promptshell_expect(pattern)Blocks until pattern appears — no polling loop
CLI asks for confirmationshell_expect → hand to userUser types the approval
Multiple possible outcomesshell_expect_casesMatch first of N patterns with flow control
Multi-step scripted flowshell_batchSend/expect sequence in one call
Cancel a running commandshell_signal("SIGINT")Ctrl-C equivalent
SSH/GPG key promptsshell_expect("password:") → hand to userSecrets stay in the PTY
Raw PTY I/Oshell_write + shell_readWhen expect patterns are unknown
File listing, read, writeVFS toolsNo 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;
OperationParent seesChild sees
list_processesown + childown only
read_process_outputown + childown only
kill_processown onlyown only

Next steps

Getting Started with the MCP Server

This tutorial is a placeholder. Content will be added when synwire-mcp-server reaches 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-server installed 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-server installed
  • A project of at least 1 000 lines of code to make search interesting

See these existing resources:

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 line
  • nf = number of passing tests that cover this line
  • ep = 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.


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
  • SbflRanker aggregates line-level scores to file-level rankings
  • fuse_sbfl_semantic combines SBFL with semantic search for better fault localization
  • The code.fault_localize MCP tool exposes this pipeline to agents

See also

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_definition to follow values through call chains.
  • For languages without let or = assignment syntax, results may be incomplete.

What you learned

  • DataflowTracer finds where a variable is defined and modified using heuristic pattern matching
  • max_hops controls how many origin sites are returned
  • The tracer works across languages that use standard assignment syntax
  • The code.trace_dataflow MCP tool makes this available to agents
  • Combine with LSP tools for cross-function tracing

See also

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
}

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:

ToolNamespacePurpose
code.trace_callerscodeQuery callers/callees of a symbol
code.trace_dataflowcodeTrace variable assignments backward
code.fault_localizecodeRank 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:

  1. Search for "discount code application" via index.search
  2. Find apply_discount in src/checkout.rs
  3. Query code.trace_callers for callers of apply_discount -- discovers it is called from both process_item and finalize_cart
  4. Trace discount_amount via code.trace_dataflow -- finds it is accumulated without resetting
  5. Report that the discount is applied per-item and per-cart, causing double application

What you learned

  • DynamicCallGraph stores 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, and code.fault_localize are available via synwire-mcp-server

See also

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:

ToolDescription
lsp.hoverGet type information and documentation for a symbol at a position
lsp.goto_definitionJump to where a symbol is defined
lsp.referencesFind all references to a symbol
lsp.document_symbolsList 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:

LanguageCommand
Rustrust-analyzer
TypeScripttypescript-language-server
Pythonpyright or pylsp
Gogopls
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:

ToolDescription
debug.set_breakpointsSet breakpoints at a file and line
debug.evaluateEvaluate 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.


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:

NamespaceTools
fsfs.read, fs.write, fs.edit, fs.grep, fs.glob, fs.tree
codecode.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
indexindex.run, index.status, index.search, index.hybrid_search
lsplsp.hover, lsp.goto_definition, lsp.references, lsp.document_symbols
debugdebug.launch, debug.attach, debug.set_breakpoints, debug.evaluate, ... (14 total)
vcsvcs.clone_repo
metameta.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 --project is set
  • --lsp and --dap override auto-detection with a specific server/adapter
  • --no-lsp and --no-dap disable auto-detection and all related tools
  • Polyglot repos detect multiple servers; the primary language is used for dispatch
  • meta.tool_search and meta.tool_list provide 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 RepoId but maintain separate indices

See also

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:

  1. Accepts a bug report as input
  2. Uses semantic search to find relevant code
  3. Uses SBFL to identify suspicious files from test coverage
  4. Uses dataflow analysis to trace variable origins
  5. Uses LSP tools for precise type information
  6. 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:

  1. Semantic search for "SQL query construction" and "user input sanitisation"
  2. fs.grep for the error string unrecognized token
  3. Read the user service files found by search
  4. Dataflow trace the name variable to find where it enters the SQL query
  5. 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.hover to check the type of a variable (is name a &str or a sanitised SafeString?)
  • lsp.goto_definition to find the exact function that builds the SQL query
  • lsp.references to 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-agent can be wrapped as StructuredTool instances
  • LSP tools add type-aware precision to the investigation
  • The agent autonomously decides which tools to call and in what order

See also

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

  • CompositeToolProvider merges multiple namespace-grouped providers into one dispatch table
  • vfs_tools(), code_tool_provider(), and index_tool_provider() supply pre-built tools under fs.*, code.*, and index.* namespaces
  • The #[tool] macro creates tools with automatic JSON Schema generation
  • LoggingInterceptor traces every tool call without touching tool implementations
  • StateGraph::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

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

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

ChannelBehaviour
LastValueStores the most recent value (overwrites)
TopicAppends all values (accumulator)
AnyValueAccepts any single value
BinaryOperatorCombines values with a custom function
NamedBarrierSynchronisation barrier
EphemeralValue 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:

  • update receives a batch of values from a single superstep
  • get must return the current accumulated value
  • checkpoint/restore_checkpoint enable state persistence
  • consume takes 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

ProviderSourceUse case
EnvCredentialProviderEnvironment variablesProduction deployments
StaticCredentialProviderHardcoded valuesTesting 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:

KindWhen to retry
ModelRate limits, timeouts, transient failures
ToolTool invocation failures
ParseOutput parsing failures (consider with caution)
EmbeddingEmbedding API failures
CredentialTypically not retryable
SerializationNot 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):

ToolCoreutilDescription
mountmountShow mounted providers, their paths, and capabilities
pwdpwdPrint working directory
cdcdChange working directory
lslsList directory contents (-a, -R, long format)
treetreeRecursive directory tree (-L, -d)
readcatRead entire file
headheadFirst N lines (-n)
tailtailLast N lines (-n)
statstatFile metadata
wcwcLine/word/byte counts
write>Write file (create/overwrite)
append>>Append to file
mkdirmkdirCreate directory (-p)
touchtouchCreate empty file / update timestamp
editsedFind and replace in file
diffdiffCompare two files (-U)
rmrmRemove file/directory (-r, -f)
cpcpCopy (-r, -n)
mvmvMove / rename
grepgrepSearch file contents (regex, -i, file type filter)
globFind files by glob pattern
findfindSearch 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: 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

FormatWhen to useToken cost
OutputFormat::JsonDebugging, human reviewHighest
OutputFormat::JsonCompactBandwidth-sensitive, small payloadsMedium
OutputFormat::ToonProduction LLM agents, tabular dataLowest

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

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:

  1. PromptCachingMiddleware — mark static prompts before any additions accumulate
  2. PatchToolCallsMiddleware — fix model output before tools run
  3. FilesystemMiddleware / HttpMiddleware / GitMiddleware / … — capability injection
  4. SummarisationMiddleware — 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):

VariantTypical operations
NoneRead-only (file read, ls, status)
LowReversible writes (write file, edit)
MediumDeletions, overwrites
HighSystem changes, process spawning
CriticalIrreversible or destructive

The callback returns an ApprovalDecision:

VariantEffect
AllowProceed once
DenyBlock this invocation
AllowAlwaysProceed and cache approval for this operation name
AbortStop 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:

  1. PermissionRule patterns allow or deny by tool name before the operation is submitted.
  2. ThresholdGate intercepts 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

SituationWinner
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 kindHighest priority value
Predicate fails on higher-priority routeNext matching route in same tier
No route matches in any tierNone — 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
}
ModeBehaviour
DefaultAllow safe operations; prompt for dangerous ones
AcceptEditsAuto-approve write/edit/rm; prompt for higher-risk ops
PlanOnlyBlock all mutations; safe for dry-run or planning phases
BypassAllApprove all operations without prompting
DenyUnauthorizedDeny 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:

VariantMeaning
AllowPermit without prompting
DenyBlock immediately
AskDelegate to the approval callback

How rules interact with approval callbacks

Rules are evaluated before the operation reaches an approval gate:

  1. The runner matches the tool name against all PermissionRule patterns in order.
  2. On Deny — the operation is blocked immediately; the approval callback is not called.
  3. On Allow — the operation proceeds; the approval callback is not called.
  4. On Ask (or no matching rule) — the operation is forwarded to the ApprovalCallback (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:

RuntimeBinaryIsolation model
runcruncLinux namespaces + seccomp — processes share the host kernel
gVisorrunscUser-space kernel — syscalls are intercepted by a Go-based sentry, providing a much stronger isolation boundary

Prerequisites

RequirementMinimum versionPurpose
Linux kernel4.15cgroup v2 unified hierarchy
systemd239User cgroup delegation
runc1.1+Namespace isolation (standard)
runsc (gVisor)latestNamespace 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:

  1. Creates a temporary OCI bundle directory
  2. Generates an OCI runtime spec (config.json) from the SandboxConfig
  3. Generates /etc/passwd and /etc/group so the current user is resolvable inside the container (whoami, id, ls -la all work)
  4. Runs runc run --bundle <dir> <id> (or runsc --rootless run ... for gVisor)
  5. 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:

CapabilityPurpose
CAP_KILLSignal child processes spawned by the agent
CAP_NET_BIND_SERVICEBind ports <1024 if networking is enabled
CAP_SETPCAPDrop 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:

PlatformMechanismPerformanceCompatibility
systrap (default)Patches syscall instruction sitesFastestRequires CAP_SYS_PTRACE
ptracePTRACE_SYSEMU / CLONE_PTRACESlowerUniversal

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

LevelMechanismRequires
CgroupTrackingcgroup v2 accounting onlyuser delegation
NamespaceOCI container via runc (PID/mount/UTS/IPC/net namespaces)runc + user namespaces
GvisorOCI 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-exec and 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 fieldSBPL effect
network: true(allow network*)
network: falseNetwork 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

PresetFilesystemNetworkSubprocesses
BaselineRead home, read/write workdir and tmpdirAllowedAllowed
PrivilegedRead/write homeAllowedAllowed
RestrictedRead/write workdir onlyDeniedDenied

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:

FlagPurpose
--volume <host>:<container>Bind-mount working directory
--network noneDisable 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-privilegesPrevent 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)

LevelMechanismRequires
Seatbeltsandbox-exec SBPL profilesmacOS (built-in)
Container (Apple Container)Lightweight Linux VM via Virtualization.frameworkmacOS 26+, Apple Silicon, container on $PATH
Container (Docker Desktop)OCI container in Docker Desktop VMDocker Desktop running (docker version succeeds)
Container (Podman)OCI container in Podman Machine VMpodman on $PATH
Container (Colima)OCI container via Colima VM + Docker socketcolima 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.


#![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

Semantic Search

Task-focused recipes for common semantic search operations.

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_indexed against expected file count.
  • Enable tracing at WARN level 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

alphaBest for
0.0Conceptual queries ("authentication logic")
0.5General use — balanced (default)
1.0Exact 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

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:

  1. Looks up the file extension in the LanguageServerRegistry.
  2. Checks whether the server binary is available via which::which().
  3. Spawns the server in --stdio mode if found.
  4. Performs the LSP initialize / initialized handshake.
  5. Sends textDocument/didOpen for 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 / append triggers textDocument/didOpen or textDocument/didChange.
  • edit triggers textDocument/didChange with incremental edits.
  • rm triggers textDocument/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:

FieldTypeDescription
commandStringServer binary name or path
argsVec<String>CLI arguments appended after the command
initialization_optionsserde_json::ValueSent in the LSP initialize request
envVec<(String, String)>Extra environment variables for the server process
root_uri_overrideOption<String>Override the workspace root URI

See also

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:

VariantEffect
NotifyEmit the signal only; the model decides what to inspect
AutoInspect { .. }Fetch variables and stack trace automatically, inject into context
IgnoreSuppress 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 levelTools
Nonedebug.variables, debug.stack_trace
Lowdebug.set_breakpoints, debug.continue, debug.step_over, debug.step_in, debug.step_out
Mediumdebug.launch, debug.attach, debug.disconnect
Criticaldebug.evaluate

Configuration

DapAdapterConfig fields:

FieldTypeDescription
nameStringIdentifier used in tool calls
commandStringAdapter binary name or path
argsVec<String>CLI arguments for the adapter process
languagesVec<String>File extensions this adapter handles
envVec<(String, String)>Extra environment variables
launch_timeoutDurationMax time to wait for adapter initialisation (default: 10s)

DapPluginConfig fields:

FieldTypeDescription
adaptersVec<DapAdapterConfig>Registered debug adapters
on_stoppedStoppedBehaviourHow to handle breakpoint/exception stops
max_concurrent_sessionsusizeLimit simultaneous debug sessions (default: 1)

See also

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.

LanguageServerCommandInstall
Rustrust-analyzerrust-analyzerrustup component add rust-analyzer
Gogoplsgopls servego install golang.org/x/tools/gopls@latest
Pythonpylsppylsppip install python-lsp-server
Pythonpyrightpyright-langserver --stdionpm install -g pyright
TypeScript/JStypescript-language-servertypescript-language-server --stdionpm install -g typescript-language-server typescript
C/C++clangdclangdapt install clangd / brew install llvm
JavajdtlsjdtlsEclipse JDT.LS manual setup
C#csharp-lscsharp-lsdotnet tool install csharp-ls
Rubysolargraphsolargraph stdiogem install solargraph
Rubyruby-lspruby-lspgem install ruby-lsp
Lualua-language-serverlua-language-serverGitHub releases
Bashbash-language-serverbash-language-server startnpm install -g bash-language-server
YAMLyaml-language-serveryaml-language-server --stdionpm install -g yaml-language-server
Kotlinkotlin-language-serverkotlin-language-serverGitHub releases
Scalametalsmetalscoursier install metals
Haskellhaskell-language-serverhaskell-language-server-wrapper --lspghcup install hls
Elixirelixir-lslanguage_server.shGitHub releases
ZigzlszlsGitHub releases
OCamlocaml-lspocamllspopam install ocaml-lsp-server
Swiftsourcekit-lspsourcekit-lspBundled with Xcode/Swift toolchain
PHPphpactorphpactor language-servercomposer global require phpactor/phpactor
Terraformterraform-lsterraform-ls servebrew install hashicorp/tap/terraform-ls
Dockerfiledockerfile-language-serverdocker-langserver --stdionpm 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

FieldTypeDescription
languageStringLanguage identifier (used in registry lookups)
server_nameStringHuman-readable server name
commandStringBinary name or absolute path
argsVec<String>CLI arguments appended after the command
extensionsVec<String>File extensions that map to this server
install_hintStringShown to the model when the binary is missing
initialization_optionsserde_json::ValueSent in the LSP initialize request
priorityu8Lower wins when multiple servers match (default: 0)

See also

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

DataOld pathNew 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-key flag is not implemented in v0.1. Use option A (delete) unless you need to preserve index data, in which case build synwire-storage separately 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

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:

  1. Superstep: execute the current node's function with the current state
  2. Edge resolution: determine the next node (static or conditional)
  3. 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

  1. 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.
  2. Static edges are checked next if no conditional edge exists for the current node.
  3. If neither exists, GraphError::CompileError is 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 superstep
  • get: read the current value
  • consume: 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

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 caseChannelReason
Single current valueLastValueOverwrites; always has the latest
Message historyTopicAppends; preserves full history
Intermediate computationEphemeralCleared after read; no state accumulation
Custom reductionBinaryOperatorUser-defined combine function
Fan-in synchronisationNamedBarrierWaits 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 StateGraph and FsmStrategy sit 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 State has an explicit merge rule (LastValue, Topic, Ephemeral, etc.); concurrent node writes are safe and deterministic
  • Conditional routingadd_conditional_edges routes to different nodes based on state; enables branching and retry loops
  • Checkpointingwith_checkpoint_saver snapshots 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 Directive values decide whether a transition fires; guards can be priority-ordered
  • Approval gate integration — a guard can check RiskLevel and block a transition until human approval arrives
  • No topology knowledgeFsmStrategy operates entirely within one Runner's turn loop; it has no notion of other nodes or channels

Decision table

DimensionStateGraphFsmStrategy
ScopeMultiple distinct system components (LLM, retriever, validator, formatter)Single agent's internal turn logic
State sharingExplicit channels; nodes exchange structured stateAgent's own State type; not shared across nodes
RoutingConditional edges defined at graph build timeGuard conditions evaluated at runtime per directive
CheckpointingFirst-class, per-superstepNot built-in (session management handles persistence)
ConcurrencyParallel node execution within a superstepSequential turns within one Runner
When to chooseYou have ≥ 2 distinct processing rolesYou 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

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?

  1. Compile time: users only compile what they use. An Ollama-only project does not compile OpenAI code.
  2. Dependency isolation: synwire-core has minimal dependencies. Provider crates add reqwest, eventsource-stream, etc.
  3. Feature flag surface: each crate has independent feature flags rather than one mega-crate with dozens of flags.
  4. Clear API boundaries: traits in synwire-core cannot 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_end
  • on_tool_start / on_tool_end / on_tool_error
  • on_chain_start / on_chain_end
  • on_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() and ignore_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:

ModulePurpose
agent::preludeGlob-importable set of agent types: Agent, AgentNode, Runner, Directive, Session, AgentEvent, Usage
cacheMoka-backed embedding cache --- wraps any Embeddings impl and deduplicates repeated queries
chat_historyChat message history traits and implementations for managing conversation context windows
promptsFew-shot prompt templates and example selectors
text_splittersText splitter implementations for chunking documents before embedding
output_parsersAdditional output parsers beyond those in synwire-core

Conditional modules

Several heavyweight integrations are gated behind feature flags so they impose zero cost when unused:

ModuleFeatureRe-exports
sandboxsandboxsynwire-sandbox --- process isolation, SandboxedAgent::with_sandbox(), ProcessPlugin
lsplspsynwire-lsp --- LspPlugin, LanguageServerRegistry, go-to-definition, hover, diagnostics
dapdapsynwire-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

FlagEnables
openaisynwire-llm-openai (not re-exported as a module, but available as a dependency)
ollamasynwire-llm-ollama
sandboxsynwire-sandbox + the sandbox module
lspsynwire-lsp + the lsp module
dapsynwire-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

CrateRole
synwire-coreAlways present --- trait definitions and shared types
mokaConcurrent cache for the embedding cache module
serde / serde_jsonSerialization for prompt templates and output parsers
regexText splitter pattern matching
tokioAsync runtime
tracingObservability

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: 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 Trait in a function argument accepts any type that satisfies it; dyn Trait allows 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 bespoke SessionManager, a new LLM provider — and you want users to be able to depend on your crate without pulling in synwire-agent or 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(...)
}
}
TraitPurposeImplemented in
BaseChatModelChat completions: invoke, batch, stream, model_type, bind_toolssynwire-llm-openai, synwire-llm-ollama, FakeChatModel
BaseLLMText-completion (string in, string out)Provider crates
Embeddingsembed_documents, embed_querysynwire-llm-openai, synwire-llm-ollama
VectorStoreDocument storage with similarity searchUser-implemented or third-party
Tool / StructuredToolCallable tools with JSON SchemaAny #[tool] function, user-implemented
RunnableCoreUniversal composition via serde_json::Value I/OAll runnables
OutputParserTyped output parsing from model responsessynwire umbrella crate
DocumentLoaderAsync document ingestionUser-implemented or third-party
AgentNodeAgent turn logic returning DirectiveResultUser-implemented
ExecutionStrategyControls how the runner sequences turnsDirectStrategy, FsmStrategy in synwire-agent
VfsFile, shell, HTTP, and process operations as effectsAll backends in synwire-agent
MiddlewareApplied before each agent turnAll middleware in synwire-agent
PluginStateful component with lifecycle hooksUser-implemented
SessionManagerSession CRUD: create, get, update, delete, list, fork, rewind, tagInMemorySessionManager in synwire-agent
McpTransportMCP protocol transportstdio/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 for Pin<Box<dyn Future<Output = T> + Send + '_>>. Trait methods can't use async fn directly and remain object-safe, so Synwire returns heap-allocated, pinned futures instead. You interact with these via .await exactly like any other future.

  • BoxStream<'a, T>Pin<Box<dyn Stream<Item = T> + Send + 'a>>; used by streaming methods
  • Message / MessageContent / ContentBlock — chat message types
  • ChatResult / ChatChunk — invoke and stream response types
  • ToolSchema / ToolOutput / ToolCall — tool interface types
  • Document — a text chunk with metadata, used by loaders and vector stores
  • SynwireError — top-level library error; all public APIs return Result<T, SynwireError>
  • Directive — an intended effect returned by AgentNode::process
  • DirectiveResult<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

FlagEnables
retryreqwest-retry middleware for automatic retries on transient HTTP errors
httpreqwest HTTP client (needed by provider crates)
tracingtracing spans on all async operations
event-busInternal event bus for cross-component messaging
batch-apiBatch request support for providers that offer it

See also

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. StateGraph implements 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 (from synwire-derive) reads the #[reducer(...)] attribute on each field and generates the State trait implementation, including channels() which returns the channel type for each field.

ChannelAttributeBehaviourUse when
LastValue#[reducer(last_value)] or omittedOverwrites on each writeCurrent node name, flags, scalars
Topic#[reducer(topic)]Appends; accumulates across stepsMessage history, event logs
Ephemeral#[reducer(ephemeral)]Cleared after each superstepPer-step scratch data
BinaryOperatormanual impl StateCustom reducer functionCounters, set union, custom merges
NamedBarriermanual impl StateFan-in: waits for all named producersSynchronising parallel branches
AnyValueN/AAccepts any JSON valueDynamic / 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

synwire-checkpoint: Persistence and Resumability

synwire-checkpoint provides two persistence mechanisms:

  1. BaseCheckpointSaver — snapshots StateGraph runs so they can be resumed, forked, or rewound
  2. BaseStore — 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

  • CheckpointConfigthread_id (namespace) + optional checkpoint_id (specific snapshot)
  • Checkpointid, channel_values (full state), format_version
  • CheckpointMetadatasource (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

SaverCratePersistenceUse when
InMemoryCheckpointSaversynwire-checkpointProcess-lifetimeTests, short workflows
SqliteSaversynwire-checkpoint-sqliteDisk, survives restartsSingle-process production
Custom BaseCheckpointSaverYour cratePostgreSQL, 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:

MethodBehaviour
putSerializes the checkpoint to JSON, enforces max_checkpoint_size, records the parent chain, and inserts via INSERT OR REPLACE
get_tupleRetrieves the latest checkpoint for a thread (or a specific checkpoint by ID)
listReturns 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

CrateRole
synwire-checkpointBaseCheckpointSaver trait and checkpoint types
synwire-coreBoxFuture for async trait methods
rusqliteSQLite bindings
r2d2 / r2d2_sqliteConnection pooling
serde_jsonCheckpoint serialization
thiserrorError 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-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:

  1. Third-party backends can add synwire-checkpoint-conformance as a dev-dependency and run the same test suite that the built-in SQLite backend uses.
  2. The contract is executable. Instead of relying on prose documentation to define correct behaviour, the conformance suite is the specification.
  3. 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:

AreaWhat is validated
Basic CRUDput stores a checkpoint, get_tuple retrieves it, list returns all checkpoints for a thread
Orderingget_tuple with no checkpoint ID returns the most recent checkpoint; list returns checkpoints in reverse chronological order
Parent chainEach put records the previous checkpoint as its parent; parent_config is correctly populated
Specific retrievalget_tuple with a specific checkpoint ID returns exactly that checkpoint
Missing dataQuerying a non-existent thread returns None, not an error
List limitslist with a limit parameter returns at most that many results
Metadata round-tripCheckpointMetadata (source, step, writes, parents) survives serialization and deserialization

Dependencies

CrateRole
synwire-checkpointBaseCheckpointSaver trait and checkpoint types
synwire-coreBoxFuture and shared error types
tokioAsync 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

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_base override)
  • 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

OptionDefaultDescription
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)
temperatureNoneSampling temperature
max_tokensNoneMaximum tokens to generate
top_pNoneNucleus sampling parameter
stopNoneStop sequences
timeout30 secondsRequest timeout
max_retries3Automatic retries on transient errors
model_kwargs{}Additional JSON parameters passed through to the API
credential_providerNoneDynamic 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

CrateRole
synwire-coreBaseChatModel, Embeddings, RunnableCore traits (with http feature)
reqwestHTTP client (rustls backend)
reqwest-middleware / reqwest-retryAutomatic retry on transient errors
eventsource-streamSSE parsing for streaming responses
futures-core / futures-utilStream processing
serde / serde_jsonRequest/response serialization
thiserrorError 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

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

OptionDefaultDescription
model"llama3.2"Ollama model name
base_url"http://localhost:11434"Ollama server URL
temperatureNoneSampling temperature
top_kNoneTop-k sampling parameter
top_pNoneNucleus sampling parameter
num_predictNoneMaximum tokens to generate
timeout120 secondsRequest timeout
credential_providerNoneDynamic 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

CrateRole
synwire-coreBaseChatModel, Embeddings traits (with http feature)
reqwestHTTP client (rustls backend)
futures-core / futures-utilStream processing for NDJSON responses
serde / serde_jsonRequest/response serialization
thiserrorError 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

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> in StateGraph<S> let the graph work with any State-implementing type while retaining type safety. The #[derive(State)] macro generates the implementation for your specific struct.

Field attributes and their channels

AttributeChannelBehaviour
#[reducer(topic)]TopicAppends; accumulates each update (message history, event logs)
#[reducer(last_value)]LastValueOverwrites on each write (default; use for current node, flags)
(none)LastValueDefaults to LastValue

When to use macros vs manual implementation

Use macrosUse manual impl
Tool parameters map cleanly to a Rust structTool schema is dynamic or variadic
State fields have clear LastValue or Topic semanticsState needs BinaryOperator or NamedBarrier channels
Proc-macro error messages are clear enoughYou need better diagnostics during early development
90% of casesComplex 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):

StrategyProduces
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-agent implements all three: session management (memory), Vfs (tools), and ExecutionStrategy (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");
}
BackendScopeRisk level
MemoryProviderNone (ephemeral)None
LocalProviderRooted pathLow (scoped)
GitBackendGit repo boundaryLow (version-controlled)
HttpBackendExternal networkMedium
ShellSandboxed working dirMedium
ProcessManagerAny processHigh
CompositeProviderMount tableDepends 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

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:

  1. 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.
  2. Agent tier: The agent's own default routing, registered at build time. Second in authority.
  3. 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

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 implementing AgentNode can be used as a node in an orchestration graph.
  • ExecutionStrategy: The trait for controlling which actions an agent may take and in what order. FsmStrategy and DirectStrategy are 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: A DirectiveExecutor that 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.
  • executors module: Conformance test suites that any DirectiveExecutor implementation 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:

Querygrep finds it?Semantic search finds it?
fn authenticateYesYes
"authentication flow"NoYes
"how are errors propagated?"NoYes
ECONNREFUSEDYesYes
"network connection failures"NoYes

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>"]
  1. Walk: synwire-index traverses a directory, filtering by include/exclude globs and maximum file size.
  2. Chunk: synwire-chunker splits each file into semantic units — AST definitions for code, overlapping text segments for prose.
  3. Embed: synwire-embeddings-local converts each chunk into a 384-dimension float vector using BAAI/bge-small-en-v1.5 (ONNX, runs locally).
  4. Store: synwire-vectorstore-lancedb writes 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

LanguageAST chunkingLanguageAST chunking
RustYesRubyYes
PythonYesBashYes
JavaScriptYesJSONYes
TypeScriptYesYAMLYes
GoYesHTMLYes
JavaYesCSSYes
CYesTOMLText only
C++YesMarkdownText 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 via spawn_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:

ColumnArrow typePurpose
idUtf8UUID per chunk
textUtf8Chunk content
vectorFixedSizeList(Float32, 384)Embedding vector
metadataUtf8JSON-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:

  1. Retrieval (fast, bi-encoder): embed the query, find top-k nearest vectors
  2. 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: The xxhash-rust crate 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 methodPurpose
index(path, opts)Start indexing a directory; returns immediately with an IndexHandle
index_status(id)Poll progress: PendingIndexingReady/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 nameTool / ecosystem
.gitignoreGit (universal baseline)
.cursorignoreCursor
.aiignoreEmerging cross-tool standard
.claudeignoreClaude Code
.aiderignoreAider
.copilotignoreGitHub Copilot
.codeiumignoreCodeium
.tabbyignoreTabby

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 listings
  • grep — hidden files are skipped during content search
  • glob — 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 with VfsError::IndexDenied to prevent accidentally indexing the entire filesystem.
  • Path traversal protection: LocalProvider canonicalises 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-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&lt;Document&gt; with symbol metadata"]
    C -->|No definitions / parse failure| D
    D --> F["Vec&lt;Document&gt; with chunk_index metadata"]

The Chunker facade:

  1. Detects the language from the file extension via detect_language(path).
  2. Attempts AST chunking with tree-sitter.
  3. 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:

LanguageDefinition node kinds
Rustfunction_item, impl_item, struct_item, enum_item, trait_item, type_alias
Pythonfunction_definition, class_definition
JavaScriptfunction_declaration, class_declaration, method_definition, arrow_function
TypeScriptfunction_declaration, class_declaration, method_definition, interface_declaration, type_alias_declaration
Gofunction_declaration, method_declaration, type_declaration
Javamethod_declaration, class_declaration, interface_declaration, constructor_declaration
Cfunction_definition, struct_specifier
C++function_definition, struct_specifier, class_specifier, namespace_definition
C#method_declaration, class_declaration, interface_declaration, property_declaration
Rubymethod, singleton_method, class, module
Bashfunction_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:

  1. Paragraph boundary (\n\n) — preserves paragraph structure
  2. Newline (\n) — preserves line structure
  3. Space ( ) — preserves word boundaries
  4. 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:

KeyAST chunksText chunksTypeDescription
fileYesYesStringSource file path
languageYesNoStringLowercase language name
symbolWhen foundNoStringDefinition name (e.g. add)
line_startYesYesNumber1-indexed first line
line_endYesYesNumber1-indexed last line
chunk_indexNoYesNumber0-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

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

ComponentModelParametersOutputPurpose
LocalEmbeddingsBAAI/bge-small-en-v1.533M384-dim f32 vectorBi-encoder: fast similarity search
LocalRerankerBAAI/bge-reranker-base110MRelevance scoreCross-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:

  1. Wrap the underlying model in Arc<T>, making it safely shareable across tasks.
  2. 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:

MethodInputOutput
embed_documents&[String]Vec<Vec<f32>> (batch)
embed_query&strVec<f32> (single vector)

LocalReranker implements synwire_core::rerankers::Reranker:

MethodInputOutput
rerankquery, &[Document], top_nVec<Document> (re-ordered)

Both return Result<T, SynwireError> — embedding failures are mapped to SynwireError::Embedding(EmbeddingError::Failed { message }).

Error handling

Error typeCause
LocalEmbeddingsError::InitModel download failure or ONNX load error
LocalRerankerError::InitSame, for the reranker model
EmbeddingError::FailedInference 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

OperationTypical latency (CPU)Notes
Model construction50–200 ms (cached)First-ever: download ~30 MB
embed_query1–5 ms per querySingle text, 384-dim output
embed_documents~2 ms per document (batch)Batching amortises overhead
rerank5–20 ms per candidateCross-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

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:

RequirementLanceDBAlternatives (Qdrant, Milvus, etc.)
No server processYes (embedded library)Requires separate server
Disk-backed persistenceYes (Lance format files)Yes
No network dependencyYesTypically client-server
Apache Arrow nativeYes (zero-copy data access)Varies
Rust-firstYesOften Python-first
Approximate nearest-neighbourIVF-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:

ColumnArrow typeDescription
idUtf8UUID v4 per chunk
textUtf8Chunk content (source code or prose)
vectorFixedSizeList(Float32, dims)Embedding vector (384 dims default)
metadataUtf8JSON-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:

MethodDescription
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&lt;Document&gt;"] --> B["Embed texts<br/>(Embeddings trait)"]
    B --> C["Build Arrow<br/>RecordBatch"]
    C --> D["LanceDB<br/>table.add()"]
    D --> E["Return Vec&lt;String&gt;<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

ErrorCause
LanceDbError::Lance(String)LanceDB operation failed (I/O, corruption)
LanceDbError::Embedding(String)Embedding call failed during add/search
LanceDbError::DimensionMismatchVector dims do not match table schema
LanceDbError::NoTableTable 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

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

ConcernHandled by
Directory traversalwalker module (walkdir + globset)
File → chunkssynwire-chunker (AST + text)
Chunks → vectorssynwire-embeddings-local
Vectors → storagesynwire-vectorstore-lancedb
Cache location + metadatacache module
Background file watchingwatcher module (notify crate)
Status tracking + eventsSemanticIndex 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 / with VfsError::IndexDenied.
  • Checks the cache (unless opts.force is true). If meta.json exists and indicates a recent index, the status transitions directly to Ready with was_cached: true.
  • Generates a UUID index_id and returns an IndexHandle immediately.
  • Spawns a background tokio::task for 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&lt;Document&gt;| E["Embeddings::embed_documents"]
    E -->|Vec&lt;Vec&lt;f32&gt;&gt;| 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 walkdir for recursive traversal and globset for 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.json is written with file/chunk counts and a timestamp, and hashes.json is 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.

SemanticIndex::search(path, query, opts) performs:

  1. Validation: Checks that the path has a ready index (either from a completed index() call or from cache).
  2. Vector search: Embeds the query and calls similarity_search_with_score on the vector store.
  3. Reranking (optional, default on): Passes the top candidates through the cross-encoder reranker for more accurate scoring.
  4. Filtering: Applies min_score threshold and file_filter glob patterns.
  5. Result mapping: Converts (Document, f32) pairs into SemanticSearchResult structs 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:

FieldTypeDefaultPurpose
cache_baseOption<PathBuf>OS cache dirOverride cache location
chunk_sizeusize1500Target bytes for text chunks
chunk_overlapusize200Overlap 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:

EventWhen
IndexEvent::ProgressDuring pipeline execution (periodic)
IndexEvent::CompletePipeline finished successfully
IndexEvent::FailedPipeline encountered a fatal error
IndexEvent::FileChangedWatcher detected a file change

See also

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

PlatformLight isolationStrong isolation
Linuxcgroup v2 + AppArmorNamespace container (runc/crun)
macOSsandbox-exec SeatbeltPodman / Apple Container / Docker Desktop / Colima
OtherNone (fallback)None

Crate structure

platform

Platform-specific backends:

  • linux::namespace --- OCI runtime spec generation, container lifecycle (create, start, wait, kill), --console-socket PTY handoff
  • linux::cgroup --- CgroupV2Manager for per-agent resource limits (CPU, memory, PIDs) with cleanup-on-drop via cgroup.kill
  • macos::seatbelt --- Sandbox Profile Language (SBPL) generation from SandboxConfig
  • macos::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_output
  • CommandPlugin (via command_tools) --- contributes four execution tools: run_command, open_shell, shell_write, shell_read
  • SandboxContext --- shared state holding the process registry, sandbox configuration, and output capture settings
  • expect_engine --- PTY automation via expectrl for 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 session
  • All --- 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

CrateRole
synwire-coreTool traits for plugin tools
expectrlPTY pattern matching (goexpect equivalent)
oci-specOCI runtime spec generation (Linux only)
nixUnix system calls (Linux only)
uuidProcess record identifiers
chronoTimestamps for process records
whichRuntime binary detection
tempfileTemporary 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

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:

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

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

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

  4. PTY support for human-in-the-loop. Many real-world CLI tools require interactive confirmation: terraform apply, ssh host key prompts, gpg passphrase entry. The sandbox must provide a pseudo-terminal so that an expect-style automation layer can drive these interactions.

  5. 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 syscall instructions with traps that the Sentry handles directly. This is faster (roughly 10% overhead vs native) but requires CAP_SYS_PTRACE in the sandbox's ambient capability set.

  • Ptrace uses Linux's PTRACE_SYSEMU and CLONE_PTRACE to 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():

  1. Apple Container (preferred)
  2. Docker Desktop (widely installed)
  3. Podman (fallback)
  4. 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 --- container is 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-spec Rust 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 runtime EINVAL.

  • Inspectability. The spec is a JSON file on disk. When debugging container issues, developers can read the generated config.json directly, modify it, and re-run the container manually with runc 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 SeatbeltApple ContainerDocker Desktop (macOS)Podman (macOS)Colima (macOS)
Requires daemonYesNoNoNoNoYes (VM)Yes (VM)Yes (VM)
Requires rootYes*NoNoNoNoNoNoNo
Startup latency1--5s~50ms~200ms~10ms~100ms~500ms~500ms~500ms
Kernel isolationNamespacesNamespacesUser-space kernelPolicy enforcementVM (Virtualization.framework)VM + namespacesVM + namespacesVM + namespaces
PTY supportdocker exec -it--console-socket--console-socketNativeNativedocker exec -itpodman exec -itdocker exec -it
Syscall filteringSeccompSeccompSentry kernelSBPLFull Linux kernelSeccompSeccompSeccomp
Resource limitscgroupscgroups v2cgroups v2NoneVM-levelcgroups v2 (in VM)cgroups v2 (in VM)cgroups v2 (in VM)
macOS requirementN/AN/AN/AAny macOSmacOS 26+, Apple SiliconAny macOSAny macOSAny 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:

  1. Fires a NotificationContext hook via the HookRegistry. This allows any registered notification hook to observe the event. A diagnostic notification, for example, produces a NotificationContext with level: "diagnostics" and a message containing the file URI and diagnostic summary.

  2. Emits an AgentEvent::TaskNotification with a Custom kind 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:

  1. Registry lookup: The LspPlugin configuration specifies a language identifier (e.g., "rust", "go", "python"). The language server registry maps this to a server binary name and default arguments.

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

  3. Process spawn: The binary is launched as a child process with stdio transport. The async-lsp MainLoop manages the stdin/stdout pipes.

  4. Initialisation handshake: The plugin sends initialize with the workspace root and client capabilities. The server responds with its ServerCapabilities. 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:

LanguageBinaryDefault Arguments
rustrust-analyzer(none)
gogoplsserve
pythonpylsp(none)
typescripttypescript-language-server--stdio
cclangd(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.launch and debug.attach are 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 to Running immediately.
  • Running: debug.set_breakpoints and debug.pause are 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 EventSignal KindPayload
stoppeddap_stopped{ "reason": "breakpoint", "thread_id": 1, "all_threads_stopped": true }
outputdap_output{ "category": "stdout", "output": "test output line\n" }
terminateddap_terminated{ "restart": false }
exiteddap_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:

  1. Transport layer handles wire-protocol framing — Content-Length headers, JSON serialisation, multiplexing requests and notifications on a single stdio pipe. It produces typed Rust structs from raw bytes.

  2. Notification handler is the protocol-specific dispatch logic. For LSP, the LanguageClient trait provides per-notification-type methods (publish_diagnostics, show_message, log_message). For DAP, a match on the event's event field routes to handler functions. The handler converts protocol-specific types into synwire's generic representations.

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

  4. Event bus delivers AgentEvent::TaskNotification to any listener. The event carries a task_id (the plugin name), a TaskEventKind, and a JSON payload with the full event data.

  5. Signal emission converts the event into a Signal with SignalKind::Custom(name). This is the boundary where protocol events enter the agent's decision system.

  6. ComposedRouter applies the three-tier routing logic (strategy > agent > plugin) to determine the Action the agent should take.

Hook Integration

The HookRegistry provides several hook types. LSP and DAP plugins use a subset of them:

Hook TypeUsed ByPurpose
PreToolUseNeitherLSP/DAP do not intercept before tool execution
PostToolUseLSPDetects VFS file mutations to send didChange/didOpen/didClose
PostToolUseFailureNeitherLSP/DAP do not react to tool failures
NotificationBothRoutes diagnostics, messages, debug events to observation layer
SessionStartLSPTriggers language server startup when session begins
SessionEndBothTriggers 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 NameSourceMeaning
lsp_diagnostics_changedLSP textDocument/publishDiagnosticsDiagnostics for a file have been updated
lsp_server_crashedLSP server process exitThe language server exited unexpectedly
lsp_server_readyLSP initialize response receivedThe language server is ready for requests
dap_stoppedDAP stopped eventThe debuggee hit a breakpoint or completed a step
dap_outputDAP output eventThe debuggee produced stdout/stderr output
dap_terminatedDAP terminated eventThe debuggee finished execution
dap_exitedDAP exited eventThe 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:

AspectMCPLSP/DAP
Tool contributionYesYes
Signal routesNo (request-response only)Yes (event-driven)
Hook usageMinimal (connection lifecycle)Extensive (PostToolUse for sync, Notification for events)
TransportMcpTransport traitasync-lsp MainLoop / ContentLengthCodec
State machineConnection state onlyFull 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:

VariantDescription
StdioSpawn a child process, communicate over stdin/stdout
SseServer-Sent Events over HTTP
StreamableHttpStreamable HTTP (MCP 2025-03-26 transport)
WebSocketWebSocket 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 --- LoggingInterceptor records 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 to ToolSchema for use with bind_tools.
  • Synwire to MCP: Synwire ToolSchema definitions are converted to MCP tool format for advertising in tools/list responses.

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:

TraitPurpose
OnMcpLoggingReceives log messages from MCP servers
OnMcpProgressReceives progress notifications for long-running operations

Built-in implementations include DiscardLogging, DiscardProgress (drop all), and TracingLogging (forward to tracing).

Dependencies

CrateRole
synwire-coreMcpTransport, ToolProvider, tool types
synwire-agentAgent runtime types for tool dispatch
tokio-tungsteniteWebSocket transport implementation
jsonschemaClient-side argument validation
futures-utilStream 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

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:

MethodDescription
initializeReturns server capabilities, name, and version
tools/listReturns tool definitions (filtered by ToolSearchIndex when progressive discovery is active)
tools/callInvokes a tool and returns its result

McpServer

The central runtime type. Holds:

  • ServerOptions --- resolved configuration from CLI flags and config file
  • StorageLayout --- 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 query
  • DaemonProxy --- forwards tool calls to the synwire-daemon singleton when it is running
  • McpSampling --- placeholder for MCP sampling support (tool-internal LLM access via sampling/createMessage)

Tool registration

At startup, McpServer::new registers tools from three sources:

  1. Built-in tools --- builtin_tools() returns the core set of file, search, and management tools.
  2. Agent skills --- the global skills directory ($DATA/<product>/skills/) is scanned via synwire-agent-skills. Each discovered skill becomes an MCP tool.
  3. LSP/DAP tools --- when the lsp or dap features 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

FieldDescription
projectProject root directory (enables project-scoped indexing)
product_nameProduct name for storage scoping (e.g. "synwire")
embedding_modelModel identifier for tool search and semantic indexing
lspLSP server command (optional)
dapDAP server command (optional)

Feature flags

FlagEnables
lspLSP tool dispatch via synwire-lsp
dapDAP tool dispatch via synwire-dap

Dependencies

CrateRole
synwire-coreTool traits, ToolSearchIndex, SamplingProvider
synwire-agentAgent runtime for tool execution
synwire-agent-skillsSkill discovery and registration
synwire-storageStorageLayout, WorktreeId
synwire-indexSemantic indexing pipeline
synwire-mcp-adaptersMCP protocol utilities
synwire-lspLSP client (optional)
synwire-dapDAP client (optional)
clapCLI argument parsing
tracing / tracing-subscriber / tracing-appenderStructured logging to files

See also

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:

FieldTypeDescription
nameString1--64 chars, lowercase letters, digits, hyphens
descriptionString1--1024 chars, human-readable summary
licenseOption<String>SPDX identifier
compatibilityOption<String>Semver expression
metadataHashMap<String, String>Arbitrary key-value pairs
allowed_toolsVec<String>Tools this skill is permitted to invoke
runtimeOption<SkillRuntime>Synwire extension: execution runtime hint

SkillRuntime

A #[non_exhaustive] enum specifying how a skill's scripts execute:

  • Lua --- Lua scripting via mlua (feature: lua-runtime)
  • Rhai --- Rhai scripting (feature: rhai-runtime)
  • Wasm --- WebAssembly via extism (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

FlagEnablesDependency
rhai-runtimeRhai script executorrhai
lua-runtimeLua script executormlua
wasm-runtimeWASM executorextism
sandboxedProcess sandboxing for external runtimessynwire-sandbox

The external and sequence runtimes are always available regardless of feature flags.

Dependencies

CrateRole
synwire-coreToolProvider, SamplingProvider traits
synwire-storageStorageLayout for skill directory resolution
serde_yamlYAML frontmatter parsing
walkdir / globsetDirectory 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

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/&lt;product&gt;/"]
    CACHE["$XDG_CACHE_HOME/&lt;product&gt;/"]

    DATA --> SESSIONS["sessions/&lt;id&gt;.db"]
    DATA --> EXPERIENCE["experience/&lt;worktree_key&gt;.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/&lt;worktree_key&gt;/"]
    CACHE --> GRAPHS["graphs/&lt;worktree_key&gt;/"]
    CACHE --> COMMUNITIES["communities/&lt;worktree_key&gt;/"]
    CACHE --> LSP["lsp/&lt;worktree_key&gt;/"]
    CACHE --> MODELS["models/"]
    CACHE --> REPOS["repos/&lt;owner&gt;/&lt;repo&gt;/"]

Durable vs cache

Data is separated by durability:

TierLocationPropertyExamples
Durable$DATA/<product>/Must survive rebootsSessions, experience, skills, logs
Cache$CACHE/<product>/Safe to delete and regenerateIndices, 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:

  1. SYNWIRE_DATA_DIR / SYNWIRE_CACHE_DIR environment variables
  2. Programmatic override via StorageLayout::with_root(root, name)
  3. .<product>/config.json in the project root
  4. 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

DataKeyed byWhy
Vector + BM25 indicesWorktreeIdCode differs per branch
Code dependency graphWorktreeIdCall graph is branch-specific
Experience poolWorktreeIdEdit history is branch-specific
Global dependency indexSpans all repos
Global experienceSpans 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 RepoId and therefore compatible cache structures
  • Branch switches produce different WorktreeIds, so index data from main is not mixed with feature-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-daemon: Singleton Background Process

Status: Planned — the daemon crate does not exist yet. This document describes the intended design. Current deployments run synwire-mcp-server directly 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

ResourceOwned by daemonPer-MCP-server without daemon
Embedding modelSingle instanceOne per server process
File watchersSingle watcher per projectOne per server process
Indexing pipelineShared, serialisedIndependent, may race
Global experience poolCentralisedShared via SQLite WAL
Dependency indexCentralisedShared via SQLite WAL
Cross-project registryCentralisedShared 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
  1. MCP server checks for the daemon socket at StorageLayout::daemon_socket().
  2. If absent, it spawns the daemon as a detached child process and polls for the socket.
  3. The daemon writes its PID to StorageLayout::daemon_pid_file().
  4. All subsequent MCP servers connect to the existing daemon.
  5. 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, their RepoIds, 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:

  1. Parses --product-name and --data-dir arguments
  2. Initialises StorageLayout, acquires the PID file, binds the UDS socket
  3. Loads the embedding model once
  4. Accepts UDS connections from MCP server proxies
  5. Dispatches tool requests to the appropriate project handler
  6. Manages the 5-minute idle shutdown timer

See also

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

ErrorCauseResolution
ModelError::RateLimitAPI rate limit exceededWait for retry_after duration, or use RunnableRetry
ModelError::AuthenticationFailedInvalid or missing API keyCheck OPENAI_API_KEY or equivalent
ModelError::InvalidRequestMalformed requestCheck message format and model parameters
ModelError::ContentFilteredContent safety filter triggeredModify input content
ModelError::TimeoutRequest timed outIncrease timeout or retry
ModelError::ConnectionNetwork connectivity issueCheck network, retry

Tool errors

ErrorCauseResolution
ToolError::NotFoundTool name not registeredCheck tool name spelling
ToolError::InvalidNameName does not match [a-zA-Z0-9_-]{1,64}Fix tool name
ToolError::ValidationFailedInput does not match schemaCheck tool call arguments
ToolError::InvocationFailedTool execution failedCheck tool implementation
ToolError::PathTraversalPath traversal attempt detectedSecurity check -- do not bypass
ToolError::TimeoutTool execution timed outIncrease timeout

Parse errors

ErrorCauseResolution
ParseError::ParseFailedCould not parse model outputCheck output format, add format instructions
ParseError::FormatMismatchOutput does not match expected formatImprove prompt or use structured output

Embedding errors

ErrorCauseResolution
EmbeddingError::FailedEmbedding API call failedCheck API key and model name
EmbeddingError::DimensionMismatchVector dimensions do not matchEnsure consistent embedding model

Vector store errors

ErrorCauseResolution
VectorStoreError::NotFoundDocument ID not foundCheck document was added
VectorStoreError::DimensionMismatchEmbedding dimensions mismatchUse same embedding model for add and query

Other

ErrorCause
SynwireError::PromptPrompt template variable missing or invalid
SynwireError::CredentialCredential provider failed
SynwireError::SerializationJSON serialisation/deserialisation failed
SynwireError::IoFile system or I/O error

GraphError variants

ErrorCauseResolution
RecursionLimitExceeded step limitIncrease limit or fix loop
NoEntryPointset_entry_point not calledCall graph.set_entry_point("node")
DuplicateNodeTwo nodes with same nameUse unique names
TaskNotFoundEdge references unknown nodeCheck node names
CompileErrorNode has no outgoing edgesAdd edges for all nodes
EmptyInputEmpty state providedProvide initial state
InterruptGraph paused for human inputHandle interrupt, resume later
MultipleValuesLastValue channel got >1 valueUse 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

FeatureDefaultDescription
retryYesRetry support via backoff + tokio
httpYesHTTP client via reqwest
tracingNoOpenTelemetry tracing integration
event-busNoTokio-based event bus for custom events
batch-apiNoProvider-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)

FeatureDefaultDescription
openaiNoInclude synwire-llm-openai provider
ollamaNoInclude synwire-llm-ollama provider
lspNoInclude synwire-lsp for Language Server Protocol integration
dapNoInclude synwire-dap for Debug Adapter Protocol integration

synwire-index

FeatureDefaultDescription
hybrid-searchNoBM25 (tantivy) + vector hybrid search with configurable alpha weighting
code-graphNoCross-file call/import/inherit dependency graph backed by SQLite
community-detectionNoHIT-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

FeatureDefaultDescription
luaNoLua scripting runtime via mlua
rhaiNoRhai scripting runtime
wasmNoWebAssembly 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

  • retry requires tokio for async backoff delays
  • tracing enables tracing, tracing-opentelemetry, opentelemetry, and opentelemetry_sdk
  • event-bus requires tokio for broadcast channels
  • Disabling http removes reqwest -- 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 call n
  • Stream chunking: with_chunk_size(n) splits responses into n-character chunks
  • Stream errors: with_stream_error_after(n) injects errors after n chunks
  • Call tracking: call_count() and calls() 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

VariantMechanismProvider support
NativeModel's native structured output (e.g., response_format)OpenAI (gpt-4o+), Ollama (some models)
ToolTool calling to extract structured outputOpenAI, Ollama (tool-capable models)
PromptFormat instructions embedded in the promptAll providers (universal fallback)
Custom(String)User-defined extraction strategyAny

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:

  1. Object safety: Vec<Box<dyn RunnableCore>> would not work with generic parameters
  2. Composability: any runnable chains with any other without type conversion boilerplate
  3. 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.

MethodDescription
nameStable identifier used for routing, logging, and sub-agent references.
descriptionHuman-readable string; surfaced in introspection and UI.
runStart a turn. Returns an event stream that terminates with TurnComplete or Error.
sub_agentsNames 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.

MethodDescription
executeAttempt 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.
tickProcess pending deferred work. Returns Some(value) if there is a result to route back; None otherwise.
snapshotCapture serialisable strategy state for checkpointing.
signal_routesSignal 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.

MethodDescription
evaluateReturn true to allow the transition.
nameHuman-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.

MethodDescription
filterReturn Some(directive) to pass through (possibly modified) or None to suppress.
decisionInspect 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.

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

MethodDescription
nameIdentifier for logging and ordering diagnostics.
processTransform MiddlewareInput. Return Continue(modified) to chain or Terminate(reason) to halt. The default implementation is a no-op pass-through.
toolsAdditional tools injected into the agent context by this middleware.
system_prompt_additionsPrompt 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.

MethodTriggered when
on_user_messageA user message arrives.
on_eventAny AgentEvent is emitted.
before_runBefore each run loop iteration.
after_runAfter each run loop iteration.
signal_routesCalled 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 itemDescription
type StateThe concrete state type stored for this plugin. Must be Send + Sync + 'static.
const KEYUnique 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.

MethodDescription
routeReturn the best-matching action, or None if no route matches.
routesAll 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.

MethodDescription
listReturn all session metadata, ordered by updated_at descending.
resumeLoad the full Session by ID.
saveCreate or update a session, refreshing updated_at.
deleteRemove a session and all associated data.
forkDuplicate a session with a new ID. The copy shares history up to the fork point. new_name overrides the name or appends " (fork)".
rewindTruncate messages to turn_index (zero-based). Returns the modified session.
tagAdd tags. Duplicate tags are silently ignored.
renameUpdate 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.

MethodDescription
lsList directory contents.
readRead file bytes and optional MIME type.
writeWrite bytes; creates or overwrites.
editReplace the first occurrence of old with new (text files only).
grepRipgrep-style content search with GrepOptions.
globFind paths matching a glob pattern.
uploadCopy a local file (from) to the backend (to).
downloadCopy a backend file (from) to a local path (to).
pwdReturn the current working directory.
cdChange the current working directory.
rmRemove a file or directory.
cpCopy within the backend.
mv_fileMove or rename within the backend.
capabilitiesReturn 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.

MethodDescription
executeRun a single command with arguments.
execute_pipelineRun a sequence of stages; each stage's stdout is piped into the next.
idSandbox 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.

MethodDescription
connectEstablish connection. For StdioMcpTransport, this spawns the subprocess.
reconnectRe-establish after a drop without changing configuration.
disconnectClean shutdown.
statusReturn a McpServerStatus snapshot including call counters and connection state.
list_toolsReturn all tools advertised by the server.
call_toolInvoke 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).

MethodDescription
getReturn the value for namespace/key, or None if absent.
setWrite value to namespace/key. Creates or overwrites.
deleteRemove namespace/key. Returns VfsError::NotFound if absent.
listReturn 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):

MethodDescription
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>,
}
}
FieldDescription
session_idActive session ID, or None for stateless runs.
modelModel identifier resolved for this run.
retry_countNumber of retries for the current turn (0 = first attempt).
cumulative_cost_usdTotal cost accumulated in this session so far.
metadataArbitrary metadata attached at the call site.

ModelErrorAction

Recovery action returned by an OnModelErrorCallback. #[non_exhaustive]

VariantDescription
RetryRetry 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 */ }
}
MethodDescription
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
}
}
FieldDescription
model_overrideOverride the agent's model for this specific run.
session_idResume an existing session, or None for a new session.
max_retriesMaximum retries per model error before falling back or aborting.

StopKind

#![allow(unused)]
fn main() {
pub enum StopKind {
    Graceful,
    Force,
}
}
VariantDescription
GracefulDrain in-flight tool calls, then stop.
ForceCancel immediately without draining.

RunErrorAction

Action taken by the runner when an error occurs. #[non_exhaustive]

VariantDescription
RetryRetry the current request (up to max_retries).
ContinueIgnore 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 { ... }
}
VariantFieldsDescription
Emitevent: AgentEventEmit an event to the event stream.
SpawnAgentname: String, config: ValueRequest spawning a child agent.
StopChildname: StringRequest stopping a child agent.
Scheduleaction: String, delay: DurationSchedule a delayed action. Delay is serialised via humantime_serde.
RunInstructioninstruction: String, input: ValueAsk the runtime to execute an instruction and route the result back.
Cronexpression: String, action: StringSchedule a recurring action.
Stopreason: Option<String>Request agent stop.
SpawnTaskdescription: String, input: ValueSpawn a background task.
StopTasktask_id: StringCancel a background task by ID.
Custompayload: 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:

MethodDescription
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]

VariantDescription
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]

VariantRetryableDescription
Authentication(String)NoAPI key or credential failure.
Billing(String)NoQuota or billing error.
RateLimit(String)YesRate limit exceeded.
ServerError(String)YesProvider server error (5xx).
InvalidRequest(String)NoMalformed request.
MaxOutputTokensNoResponse exceeded the output token limit.

Methods:

MethodReturnsDescription
is_retryable()booltrue for RateLimit and ServerError variants.

StrategyError

Execution strategy error. #[non_exhaustive]

VariantFieldsDescription
InvalidTransitioncurrent_state, attempted_action, valid_actionsAction not valid from the current FSM state.
GuardRejected(String)All guard conditions rejected the transition.
NoInitialStateFsmStrategyBuilder::build called without setting an initial state.
Execution(String)General execution failure (e.g. mutex poisoned).

DirectiveError

Error from DirectiveExecutor. #[non_exhaustive]

VariantDescription
ExecutionFailed(String)Directive could not be executed.
Unsupported(String)Directive type not supported by this executor.

FilterDecision

Decision returned by DirectiveFilter::decision. #[non_exhaustive]

VariantDescription
PassDirective passes through (may be modified).
SuppressDirective is silently dropped.
RejectDirective is rejected with an error.

FilterChain

Ordered sequence of DirectiveFilter implementations.

MethodDescription
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]

VariantDescription
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 { ... }
}
VariantKey fieldsDescription
TextDeltacontent: StringStreaming text chunk from the model.
ToolCallStartid: String, name: StringTool invocation started.
ToolCallDeltaid: String, arguments_delta: StringStreaming argument fragment.
ToolCallEndid: StringTool invocation arguments complete.
ToolResultid: String, output: ToolOutputTool execution result.
ToolProgressid: String, message: String, progress_pct: Option<f32>Progress report from a long-running tool.
StateUpdatepatch: ValueJSON patch to apply to agent state.
DirectiveEmitteddirective: ValueAgent emitted a directive (serialised).
StatusUpdatestatus: String, progress_pct: Option<f32>Human-readable status message.
UsageUpdateusage: UsageToken and cost counters for the current turn.
RateLimitInfoutilization_pct: f32, reset_at: i64, allowed: boolRate limit information.
TaskNotificationtask_id: String, kind: TaskEventKind, payload: ValueBackground task lifecycle event.
PromptSuggestionsuggestions: Vec<String>Model-suggested follow-up prompts.
TurnCompletereason: TerminationReasonTurn ended. Always the last event unless Error occurs first.
Errormessage: StringFatal error; no further events follow.

Method:

MethodReturnsDescription
is_final_response()booltrue 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]

VariantDescription
CompleteAgent finished normally.
MaxTurnsExceededmax_turns limit reached.
BudgetExceededmax_budget limit reached.
StoppedGraceful stop requested via Runner::stop_graceful.
AbortedForce stop requested via Runner::stop_force.
ErrorTerminated due to an unrecoverable error.

TaskEventKind

Lifecycle event for background tasks. #[non_exhaustive]

VariantDescription
StartedTask began executing.
ProgressTask reported intermediate progress.
CompletedTask finished successfully.
FailedTask 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,
}
}
FieldDescription
metadataIdentity and statistics.
messagesConversation history as an array of JSON message objects.
stateArbitrary 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,
}
}
FieldDescription
idUnique session identifier (UUID string).
nameOptional human-readable name.
tagsUser-defined tags for filtering and search.
agent_nameName of the agent this session belongs to.
created_atUnix milliseconds at creation.
updated_atUnix milliseconds at last save.
turn_countNumber of conversation turns recorded.
total_tokensCumulative 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]

VariantDescription
LowMinimal reasoning.
MediumModerate reasoning.
HighDeep reasoning (default).
MaxMaximum reasoning.

ThinkingConfig

Extended thinking / chain-of-thought configuration. #[non_exhaustive]

VariantFieldsDescription
AdaptiveModel decides reasoning depth.
Enabledbudget_tokens: u32Fixed token budget for reasoning.
DisabledNo 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]

VariantDescription
ToolVia tool call (most reliable). Default.
NativeNative JSON mode (provider must support it).
PromptPost-process raw text via prompt.
CustomUser-supplied extraction function.

SystemPromptConfig

#[non_exhaustive]

VariantFieldsDescription
Appendcontent: StringAppend to the base system prompt.
Replacecontent: StringReplace the base system prompt entirely.

PermissionMode

#[non_exhaustive]

VariantDescription
DefaultPrompt for dangerous operations. Default.
AcceptEditsAuto-approve file modifications.
PlanOnlyRead-only; no mutations allowed.
BypassAllAuto-approve everything.
DenyUnauthorizedDeny unless a matching PermissionRule allows.

PermissionBehavior

#[non_exhaustive]

VariantDescription
AllowAllow the operation.
DenyDeny the operation.
AskPrompt 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]

VariantDescription
StopUser requested stop.
UserMessageUser message received.
ToolResultTool invocation result available.
TimerTimer 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:

MethodDescription
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]

VariantDescription
ContinueContinue processing normally.
GracefulStopStop after draining in-flight work.
ForceStopStop 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 */ }
}
ConstructorDescription
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:

MethodHook 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]

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

MethodDescription
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() -> ValueSerialise 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]

VariantDescription
Continue(MiddlewareInput)Pass (possibly modified) input to the next middleware.
Terminate(String)Halt the chain immediately.

MiddlewareStack

Ordered collection of Middleware implementations.

MethodDescription
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 */ }
}
ConstructorDescription
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.

MethodDescription
builder() -> FsmStrategyBuilderReturn 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.

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

ConstructorDescription
new()Create an empty manager.

InMemoryStore

In-memory BaseStore backed by RwLock<BTreeMap<String, Vec<u8>>>.

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

ConstructorDescription
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 */ }
}
ConstructorDescription
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.

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

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

TypeConfig variantDescription
StdioMcpTransportMcpServerConfig::StdioSpawns a subprocess; communicates over stdin/stdout with newline-delimited JSON-RPC.
HttpMcpTransportMcpServerConfig::HttpCommunicates over HTTP; supports optional bearer token authentication.
InProcessMcpTransportMcpServerConfig::InProcessIn-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.

ConstructorDescription
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:

FlagOperation
LSList directory
READRead files
WRITEWrite files
EDITEdit files
GREPSearch content
GLOBFind files
UPLOADUpload files
DOWNLOADDownload files
PWDGet working directory
CDChange working directory
RMRemove files
CPCopy files
MVMove files
EXECExecute 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]

VariantDescription
ContentReturn matching lines with context. Default.
FilesWithMatchesReturn only file paths that contain a match.
CountReturn 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]

VariantDescription
AllowPermit this invocation.
DenyDeny this invocation.
AllowAlwaysPermit this and all future invocations of the same operation.
AbortAbort the entire agent run.
AllowModified { modified_context }Permit with substituted context.

RiskLevel

Ordered (PartialOrd) risk classification. #[non_exhaustive]

VariantDescription
NoneNo meaningful risk (read-only).
LowReversible writes.
MediumFile deletions, overwrites.
HighSystem changes, process spawning.
CriticalIrreversible or destructive.

AutoApprove / AutoDeny Callbacks

TypeBehaviour
AutoApproveCallbackAlways returns ApprovalDecision::Allow.
AutoDenyCallbackAlways 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.

ConstructorDescription
new()Create an empty backend with / as the working directory.

MCP Types — synwire_core::mcp


McpServerConfig

Connection configuration for an MCP server. #[non_exhaustive]

VariantKey fieldsDescription
Stdiocommand, args, envLaunch a subprocess and communicate over stdin/stdout.
Httpurl, auth_token, timeout_secsConnect to an HTTP MCP server.
Sseurl, auth_token, timeout_secsConnect via Server-Sent Events transport.
InProcessnameIn-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]

VariantDescription
DisconnectedNot yet connected. Default.
ConnectingConnection attempt in progress.
ConnectedReady to accept tool calls.
ReconnectingReconnection in progress after a drop.
ShutdownServer 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]

VariantFieldsDescription
Providedrequest_id: String, value: ValueUser provided a valid response.
Cancelledrequest_id: StringUser 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-nextest for test execution
  • cargo-clippy for 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::pedantic and clippy::nursery at warn level
  • clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::todo are denied
  • missing_docs is denied -- all public items must have doc comments
  • unsafe_code is denied across all crates

Adding a new crate

  1. Create the crate directory under crates/
  2. Add it to the workspace members in the root Cargo.toml
  3. Add [lints] workspace = true to inherit workspace lints
  4. Add #![deny(unsafe_code)] or #![forbid(unsafe_code)] to lib.rs
  5. Add //! module-level documentation to lib.rs

Pull request checklist

  • cargo fmt --all passes
  • cargo clippy --workspace --all-targets --all-features -- -D warnings passes
  • cargo nextest run --workspace --all-features passes
  • cargo test --workspace --doc passes
  • New public items have doc comments
  • No unsafe code 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:

  1. What the module provides
  2. 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

SectionWhen
DescriptionAlways (first paragraph)
# ExamplesWhen the usage is not obvious
# ErrorsFor fallible functions
# PanicsIf the function can panic (should be rare)
# SafetyFor unsafe functions (should not exist)

Code examples

  • Use rust,ignore for examples that require runtime context (async, API keys)
  • Use plain rust for examples that should compile as doctests
  • Prefer FakeChatModel / FakeEmbeddings in examples to avoid API key requirements
  • Wrap async examples in tokio_test::block_on for 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,ignore for Rust code that should not be tested
  • Use toml for Cargo.toml snippets
  • Use sh for shell commands
  • Use mermaid for diagrams (inside fenced code blocks)

Tables

Use Markdown tables for structured information. Align columns for readability in source.

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

  1. When this error occurs
  2. 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