Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,30 @@ Behind the `openapi` Cargo feature. `OpenApiToolAdapter` parses an OpenAPI 3.0 s

`McpClient` communicates via `McpTransport` trait (stdio or HTTP). `McpToolAdapter` wraps MCP tools to implement `AgentTool`, making them transparent to the agent loop. Added via `Agent::with_mcp_server_stdio()` / `with_mcp_server_http()`.

### Shared State (`shared_state.rs`)

`SharedState` is a pluggable key-value store (`Arc<dyn SharedStateBackend>`) for sub-agent communication. It lets a parent store large artifacts once and have multiple sub-agents read/write by reference — no re-pasting into prompts.

- Two built-in backends: `MemoryBackend` (default, `HashMap` with 10MB cap) and `FileBackend` (one file per key, persistent)
- Custom backends implement the `SharedStateBackend` trait
- Opt-in via `SubAgentTool::with_shared_state(state)` — injects a `shared_state` tool and appends a state summary to the sub-agent's system prompt automatically
- Actions: `get`, `set`, `list`, `remove`
- Does **not** touch the core agent loop — wired entirely through `SubAgentTool`

### Sub-Agent Multi-Provider Support

`SubAgentTool` supports any provider via `with_model_config()`. Without it, sub-agents default to Anthropic. For non-Anthropic providers (OpenAI, xAI, Groq, etc.), pass the appropriate `ModelConfig`:

```rust
let config = ModelConfig::xai("grok-3-mini-fast", "Grok 3 Mini Fast");
let sub = SubAgentTool::new("analyst", Arc::new(OpenAiCompatProvider))
.with_model(&config.id)
.with_api_key(&key)
.with_model_config(config);
```

`AgentLoopConfig` also supports `turn_delay: Option<Duration>` — an inter-turn delay to throttle API calls for rate-limit-sensitive providers. Exposed on `SubAgentTool` via `with_turn_delay()`.

### Testing

All unit tests use `MockProvider` (`provider/mock.rs`) to simulate LLM responses without network. Test files are in `tests/` — `agent_test.rs`, `agent_loop_test.rs`, `tools_test.rs`. Follow the existing pattern of constructing a `MockProvider` with predetermined responses.
Expand Down
2 changes: 2 additions & 0 deletions docs/concepts/agent-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ pub struct AgentLoopConfig {
pub on_error: Option<OnErrorFn>,
pub input_filters: Vec<Arc<dyn InputFilter>>,
pub compaction_strategy: Option<Arc<dyn CompactionStrategy>>,
pub turn_delay: Option<Duration>,
}
```

Expand All @@ -114,6 +115,7 @@ pub struct AgentLoopConfig {
| `on_error` | Called on `StopReason::Error` with the error string (see [Callbacks](callbacks.md)) |
| `input_filters` | Input filters applied to user messages before the LLM call (see [Tools](tools.md)) |
| `compaction_strategy` | Custom compaction strategy (see [Custom Compaction](#custom-compaction) below) |
| `turn_delay` | Optional inter-turn delay to throttle API calls. Skips the first turn. Useful for rate-limit-sensitive providers (e.g., OAuth tokens with low RPM caps) |

## Steering & Follow-Ups

Expand Down
123 changes: 120 additions & 3 deletions docs/concepts/sub-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@ When the parent LLM calls multiple sub-agents in a single response, they run con
| `with_description()` | What the parent LLM sees (helps it decide when to delegate) |
| `with_system_prompt()` | The sub-agent's own instructions |
| `with_model()` / `with_api_key()` | Can use a different model than the parent |
| `with_model_config()` | Set `ModelConfig` for non-Anthropic providers (base URL, compat flags, etc.) |
| `with_tools()` | Tools available to the sub-agent (accepts `Vec<Arc<dyn AgentTool>>`) |
| `with_max_turns(N)` | Turn limit (default: 10). Primary guard against runaway execution. |
| `with_thinking()` | Enable extended thinking for the sub-agent |
| `with_cache_config()` | Prompt caching settings |
| `with_turn_delay()` | Inter-turn delay to throttle API calls (useful for rate-limit-sensitive providers) |
| `with_retry_config()` | Custom retry configuration for transient errors |
| `with_tool_execution()` | Tool execution strategy (`Parallel`, `Sequential`, `Batched`) |

## Event Forwarding

Expand All @@ -74,13 +78,126 @@ When the parent provides an `on_update` callback (standard for all tools), sub-a
- Text deltas from the sub-agent's LLM responses
- Tool call notifications from the sub-agent's tool usage

## Shared State

By default, each sub-agent invocation is isolated — to pass data between sub-agents, the parent must re-paste it into every prompt. For large artifacts (CI logs, codebases, analysis results), this wastes context tokens.

`SharedState` solves this: store an artifact once, and any number of sub-agents read/write it by reference.

```rust
use yoagent::shared_state::SharedState;

let state = SharedState::new();
state.set("ci_log", large_log_text).await.unwrap();

let analyzer = SubAgentTool::new("analyzer", provider.clone())
.with_system_prompt("Analyze the CI log for failures.")
.with_model("claude-sonnet-4-20250514")
.with_api_key(&api_key)
.with_shared_state(state.clone()); // opt-in
```

When `.with_shared_state()` is used, the sub-agent automatically gets:

1. A `shared_state` tool with `get`, `set`, `list`, and `remove` actions
2. A system prompt appendix listing available keys and their sizes

The sub-agent reads the artifact via tool call instead of having it pasted into the prompt:

```
Sub-agent calls: shared_state(action="get", key="ci_log")
Sub-agent calls: shared_state(action="set", key="summary", value="...")
```

The parent reads results back programmatically:

```rust
let summary = state.get("summary").await.expect("sub-agent wrote this");
```

### Parallel Sub-Agents with Shared State

Multiple sub-agents can share the same `SharedState` concurrently. Each gets its own clone of the `Arc` handle — reads are concurrent, writes are serialized by `tokio::sync::RwLock`.

```rust
let error_analyst = SubAgentTool::new("error_analyst", provider.clone())
.with_shared_state(state.clone());
let perf_analyst = SubAgentTool::new("perf_analyst", provider.clone())
.with_shared_state(state.clone());

// Both run in parallel, reading the same artifact and writing different keys
```

### Backends

`SharedState` is backed by a pluggable `SharedStateBackend` trait. Two built-in backends are provided:

**MemoryBackend** (default) — in-memory `HashMap` with a byte capacity limit:

```rust
let state = SharedState::new(); // 10MB default
let state = SharedState::with_max_bytes(50 * 1024 * 1024); // 50MB
```

A `set` call that would exceed capacity returns `Err(CapacityError)`.

**FileBackend** — one file per key, persistent across process restarts:

```rust
use yoagent::shared_state::FileBackend;

let state = SharedState::with_backend(FileBackend::new(".agent-state"));
```

Keys are percent-encoded to filenames (reversible, no collisions). Useful for debugging (inspect state with `ls` / `cat`) and for long-running workflows where memory limits matter.

**Custom backends** implement the `SharedStateBackend` trait:

```rust
use yoagent::shared_state::{SharedStateBackend, SharedStateError};

#[async_trait::async_trait]
impl SharedStateBackend for MyRedisBackend {
async fn get(&self, key: &str) -> Result<Option<String>, SharedStateError> { ... }
async fn set(&self, key: &str, value: String) -> Result<(), SharedStateError> { ... }
async fn remove(&self, key: &str) -> Result<bool, SharedStateError> { ... }
async fn keys(&self) -> Result<Vec<String>, SharedStateError> { ... }
async fn summary(&self) -> Result<String, SharedStateError> { ... }
}

let state = SharedState::with_backend(MyRedisBackend::new());
```

See [`examples/shared_state.rs`](../../examples/shared_state.rs) for a complete parallel analysis demo.

## Multi-Provider Support

Sub-agents can use any provider supported by yoagent — not just Anthropic. Pass a `ModelConfig` to configure the base URL, compat flags, and other provider-specific settings:

```rust
use yoagent::provider::{OpenAiCompatProvider, model::ModelConfig};

let provider = Arc::new(OpenAiCompatProvider);
let model_config = ModelConfig::xai("grok-3-mini-fast", "Grok 3 Mini Fast");

let analyst = SubAgentTool::new("analyst", provider)
.with_model(&model_config.id)
.with_api_key(&xai_api_key)
.with_model_config(model_config)
.with_tools(vec![...]);
```

This works with all providers: OpenAI, Groq, DeepSeek, Gemini, Mistral, xAI, and more. See [`ModelConfig`](../reference/configuration.md) for the full list of factory methods.

## Design Decisions

- **Context isolation**: Each invocation starts fresh. Sub-agents don't accumulate history across calls.
- **No nesting**: Sub-agents are not given other `SubAgentTool`s. This prevents infinite delegation chains.
- **Nesting supported**: Sub-agents can be given other `SubAgentTool`s for recursive delegation (see [`examples/rlm.rs`](../../examples/rlm.rs)). Use `with_max_turns()` to prevent infinite chains.
- **Cancellation propagation**: The parent's cancellation token is forwarded. Aborting the parent aborts all sub-agents.
- **Turn limiting**: The default 10-turn limit prevents runaway execution. The parent's execution limits also apply to total wall-clock time.

## Example
## Examples

See [`examples/sub_agent.rs`](../../examples/sub_agent.rs) for a complete coordinator with researcher and coder sub-agents.
- [`examples/sub_agent.rs`](../../examples/sub_agent.rs) — Coordinator with researcher and coder sub-agents
- [`examples/code_review.rs`](../../examples/code_review.rs) — 3 parallel sub-agents reviewing a file via shared state
- [`examples/rlm.rs`](../../examples/rlm.rs) — Recursive Language Model: nested sub-agents with autonomous file discovery
77 changes: 77 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,83 @@ All return `Self` for chaining (unless noted as `Result`).
| `abort()` | Cancel the current run via `CancellationToken` |
| `async reset()` | Cancel any pending loop, recover tools, clear all state (messages, queues, streaming flag) |

## SubAgentTool

Delegates tasks to a child agent loop.

### Construction

```rust
let sub = SubAgentTool::new("name", Arc::new(provider));
```

### Builder Methods

All return `Self` for chaining.

| Method | Description |
|--------|-------------|
| `with_description(desc) -> Self` | What the parent LLM sees (helps it decide when to delegate) |
| `with_system_prompt(prompt) -> Self` | The sub-agent's own instructions |
| `with_model(model) -> Self` | Set the model identifier |
| `with_api_key(key) -> Self` | Set the API key |
| `with_model_config(config: ModelConfig) -> Self` | Set model config for non-Anthropic providers (base URL, compat flags, etc.) |
| `with_tools(tools: Vec<Arc<dyn AgentTool>>) -> Self` | Tools available to the sub-agent |
| `with_shared_state(state: SharedState) -> Self` | Attach a shared key-value store (injects `shared_state` tool automatically) |
| `with_max_turns(N) -> Self` | Turn limit (default: 10) |
| `with_thinking(level: ThinkingLevel) -> Self` | Enable extended thinking |
| `with_max_tokens(max: u32) -> Self` | Set max output tokens |
| `with_cache_config(config: CacheConfig) -> Self` | Prompt caching settings |
| `with_tool_execution(strategy: ToolExecutionStrategy) -> Self` | Tool execution strategy (`Parallel`, `Sequential`, `Batched`) |
| `with_retry_config(config: RetryConfig) -> Self` | Custom retry configuration |
| `with_turn_delay(delay: Duration) -> Self` | Inter-turn delay to throttle API calls (skips first turn) |

## SharedState

Pluggable key-value store for sub-agent communication. Backed by a `SharedStateBackend` trait.

### Construction

```rust
use yoagent::shared_state::{SharedState, FileBackend};

let state = SharedState::new(); // MemoryBackend, 10MB cap
let state = SharedState::with_max_bytes(50 * 1024 * 1024); // MemoryBackend, 50MB cap
let state = SharedState::with_backend(FileBackend::new("./state-dir")); // FileBackend
```

### Methods

| Method | Description |
|--------|-------------|
| `async get(key) -> Option<String>` | Read a value by key |
| `async set(key, value) -> Result<(), SharedStateError>` | Store a value |
| `async remove(key) -> bool` | Delete a key, returns whether it existed |
| `async keys() -> Vec<String>` | List all keys |
| `async summary() -> String` | Human-readable summary of keys and sizes |

### Built-in Backends

| Backend | Description |
|---------|-------------|
| `MemoryBackend` | In-memory `HashMap` with byte capacity limit (default) |
| `FileBackend` | One file per key, percent-encoded filenames, persistent |

### Custom Backends

Implement the `SharedStateBackend` trait:

```rust
#[async_trait::async_trait]
pub trait SharedStateBackend: Send + Sync {
async fn get(&self, key: &str) -> Result<Option<String>, SharedStateError>;
async fn set(&self, key: &str, value: String) -> Result<(), SharedStateError>;
async fn remove(&self, key: &str) -> Result<bool, SharedStateError>;
async fn keys(&self) -> Result<Vec<String>, SharedStateError>;
async fn summary(&self) -> Result<String, SharedStateError>;
}
```

## Re-exports

The crate re-exports key types from `lib.rs`:
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub struct AgentLoopConfig {
pub after_turn: Option<AfterTurnFn>,
pub on_error: Option<OnErrorFn>,
pub input_filters: Vec<Arc<dyn InputFilter>>,
pub compaction_strategy: Option<Arc<dyn CompactionStrategy>>,
pub turn_delay: Option<Duration>,
}
```

Expand Down
16 changes: 16 additions & 0 deletions docs/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,19 @@ pub struct SearchTool {
```

Returns matching lines with file paths and line numbers.

## SharedStateTool

Read and write named variables in a shared key-value store. This tool is **not** included in `default_tools()` — it is automatically injected into sub-agents when you call `SubAgentTool::with_shared_state()`.

- **Name**: `shared_state`
- **Parameters**: `action` (required: `get`, `set`, `list`, `remove`), `key` (required for get/set/remove), `value` (required for set)

| Action | Description |
|--------|-------------|
| `get` | Returns the value for a key, or error if not found |
| `set` | Stores a value, returns confirmation with byte size |
| `list` | Lists all keys with their byte sizes |
| `remove` | Deletes a key |

See [Sub-Agents: Shared State](../concepts/sub-agents.md#shared-state) for usage details.
Loading
Loading