# motosan-agent-loop

> Standalone ReAct agent loop for Rust. LLM-agnostic iterative reasoning and tool execution — bring your own backend, register tools, let Engine handle the rest.

- Version: 0.36.0
- MSRV: Rust 1.88 (`rust-toolchain.toml` pins 1.95.0 for repo lint/test determinism)
- GitHub: https://github.com/motosan-dev/motosan-agent-loop
- crates.io: https://crates.io/crates/motosan-agent-loop
- docs.rs: https://docs.rs/motosan-agent-loop

## Install

```toml
[dependencies]
motosan-agent-loop = "0.36"
motosan-agent-tool = "0.7"
async-trait = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```

### Feature Flags

| Feature | Default | Description |
|---------|---------|-------------|
| `motosan-ai` | off | `LlmClient` bridge for motosan-ai SDK |
| `mcp-client` | off | MCP server support via `rmcp` |
| `redact` | off | Built-in `RedactInterceptor` demo for tool-result masking |
| `testing` | off | `motosan_agent_loop::testing::{CaptureSink, run_extension_hook, MockLlmClient}` helpers for extension authors |

### New in 0.36

- Deprecation sweep: removed the old `extensions` module alias, dead
  engine-level AskUser pair, inert channel-config surface, and the dead
  `build_interceptor_set` third parameter. See `MIGRATION.md`.
- All eight built-in interceptor structs are root-importable. Root
  `AskUserQuestion` / `AskUserOption` now bind the canonical ask_user
  interceptor types.
- `SessionStore::{append, load, as_any}` and inherent
  `FileSessionStore::load_meta` are deprecated; use entry-level persistence
  methods and the trait `load_meta`.
- Behavior change: hook failures under `ErrorPolicy::Abort` now surface as
  `AgentError::Hook { hook, source }` (was `AgentError::Internal(String)`),
  classified as `TurnResult::AbortedByHook` / `StopReason::AbortedByHook`
  (was `Failure` / `Error`). `AgentError` is `#[non_exhaustive]`, so this is
  semver-minor; only code that matched `Internal`/string-matched
  `"internal error:"` for hook aborts needs updating.
- Behavior change: on chunked runs, assistant preamble text streamed before
  tool calls is now persisted to `AgentResult.messages` / session history /
  the next request (previously dropped). Batch runs unchanged.
- `FileSessionStore` internals restructured (per-session lock, bounded LRU
  cache, first-line meta reads); on-disk format and public API unchanged. A
  mid-flush read race in `load_entries`/`load_meta` is fixed.

### Interceptor authoring API (stable across minor versions of 0.25+)

> **0.24 rename**: the trait you implement is `LoopInterceptor` (was `Extension`), and built-in `*Extension` structs were renamed to `*Interceptor` (`RedactInterceptor`, `FollowUpInterceptor`, `AutocompactInterceptor`, `AskUserInterceptor`, `TokenBudgetInterceptor`, `StuckDetectionInterceptor`); the builder helper `build_extension_set` is now `build_interceptor_set` (returns `InterceptorSet`). The snippets below use the current names. The wrapping `AgentEvent` variant was *also* renamed in 0.24: `AgentEvent::Extension` → `AgentEvent::LoopInterceptor` (so you now match `AgentEvent::LoopInterceptor(ExtensionEvent::...)`). **Intentionally kept** with the old `Extension` spelling: the registration method `EngineBuilder::extension(...)` (back-compat alias for `interceptor(...)`), the event payload type `ExtensionEvent` and its variants (`ExtensionEvent::AskUser`, `ExtensionEvent::Autocompact`, `ExtensionEvent::External { .. }`), `AgentOp::ExtensionResume`, the declarative `ExtensionsConfig`/`ExtensionWiring`/`ExtensionBuildError`, `ExtEvent`, and `HookCtx::extension_name()`.

- `LoopInterceptor` (was `Extension`), `HookCtx`, `ExtEvent`, `EventSink`, `ExtError`, `ErrorPolicy`
- `FlowDecision`, `ToolDecision`, `OpDecision`, `HaltReason`, `TurnResult`
- `AgentOp::ExtensionResume { call_id, result }` — resolve a deferred call directly
- `HookCtx::ops_sender() -> &mpsc::Sender<AgentOp>` — clone into background tasks
- `ExtensionEvent::External { name, payload: Box<dyn ExtEvent> }` — third-party events
- `LoopInterceptor::transforms_request() -> bool` (0.35) — default `true`; override to `false` when the interceptor never overrides `transform_request`, so the engine skips its per-iteration full-history clone (when NO registered interceptor transforms, the engine skips the whole request-transform pipeline)

Canonical guide: `docs/extension-authoring.md`.

---

## Core Concepts

Engine implements the ReAct (Reasoning + Acting) pattern:

1. Send messages + tool definitions to the LLM
2. If the LLM returns tool calls → execute tools → append results → go to 1
3. If the LLM returns text → return final answer

---

## LlmClient Trait

Implement this trait for your LLM backend:

```rust
use async_trait::async_trait;
use motosan_agent_loop::{ChatOutput, LlmClient, LlmResponse, Message, Result};
use motosan_agent_tool::ToolDef;

struct MyLlm;

#[async_trait]
impl LlmClient for MyLlm {
    async fn chat(&self, messages: &[Message], tools: &[ToolDef]) -> Result<ChatOutput> {
        // Call your LLM API here
        Ok(ChatOutput::new(LlmResponse::Message("Hello!".into())))
    }
}
```

### Required Methods

- `chat(&self, messages: &[Message], tools: &[ToolDef]) -> Result<ChatOutput>` — single turn

### Default Methods (override optional)

- `simple_call(&self, system: &str, prompt: &str) -> Result<String>` — simplified system+prompt interface for chain integration. Returns `Err` if the model produces tool calls.
- `chat_stream(messages, tools) -> Pin<Box<dyn Stream<Item = Result<StreamChunk>>>>` — streaming variant; default falls back to `chat()`

---

## Session Layer

New in 0.35:

- `SessionStore::append_entries(session_id, entries: &[StoredEntry]) -> Result<()>` — bulk append that persists pre-built entries (ids + parent_ids) verbatim and flushes once per batch. The default impl delegates linear message entries to `append_entry_with_parent` (mints fresh ids) and rejects branched/custom entries; `FileSessionStore`/`MemorySessionStore` override to preserve ids. External stores should override it — `SessionCatalog::fork` uses it to copy a session in one flush.

New in 0.17:

- `ContentPart::Image { image: ImageSource }` now supports image-bearing user/tool messages.
- `ImageSource` variants mirror motosan-ai 0.15: `Base64 { media_type, data }` and `Url { url }`.
- New constructors: `Message::user_with_parts`, `Message::tool_result_with_parts`, `Message::user_with_image_base64`, `Message::user_with_image_url`, `Message::tool_result_with_image_base64`, `Message::tool_result_with_image_url`.
- `Message::approx_visible_chars()` applies fixed per-image cost via `IMAGE_APPROX_CHAR_EQUIVALENT` (6400).
- `MessageMeta.cache_stable` now propagates to provider wire cache flags in the motosan-ai adapter.

New in 0.16:

- `Message` is now a role-variant enum with accessor-based API (`role()`, `text()`, `tool_calls()`, `tool_call_id()`), plus `MessageId` and `MessageMeta`.
- New content slots: `ContentPart` (user/tool side) and `AssistantContent` (assistant side) for forward-compatible multimodal expansion.

New in 0.21:

- AgentSession branching: `fork_turn(from, new_messages)` runs a turn that continues from any earlier entry, creating a branch. `branches()` returns a `BranchTree` for `/tree` UIs; `history_from(entry)` projects the branch ending at any entry. Stateless — the branch parent rides on `TurnHandle`.
- `AgentSession::record_turn_outcome` now takes `branch_parent: Option<EntryId>`; direct callers pass `None` for linear behavior. `TurnHandle` gains `branch_parent: Option<EntryId>`.
- Projection helpers `branch_indices_ending_at(entries, leaf)` and `project_branch_ending_at(entries, leaf)` render a branch ending at any entry.

New in 0.20:

- Session branching: a session log is a tree. Each entry carries an optional `parent_id`; `SessionStore::load_entries` yields `Vec<StoredEntry>`. The projection renders the active branch (last entry → root). Append a branch with `SessionStore::append_entry_with_parent`; render a `/tree` UI with `project_branches() -> BranchTree`. A flat (never-branched) session behaves and serializes exactly as before.
- `StoredEntry { id: EntryId, parent_id: Option<EntryId>, entry: SessionEntry }` — persisted record returned by `SessionStore::load_entries` and consumed by projection/compaction.
- `SessionStore::append_entry_with_parent(session_id, entry, parent: Option<EntryId>) -> Result<EntryId>` — explicit branch append; default impl rejects `Some(parent)` for stores that cannot persist branches.
- `active_branch(entries: &[StoredEntry]) -> Vec<StoredEntry>` and `active_branch_indices(entries: &[StoredEntry]) -> Vec<usize>` — parent-pointer walk from active leaf to root.
- `branch_indices_ending_at(entries: &[StoredEntry], leaf: usize) -> Vec<usize>` and `project_branch_ending_at(entries: &[StoredEntry], leaf: usize) -> Vec<Message>` — parent-pointer walk/projection for an arbitrary leaf.
- `project_branches(entries: &[StoredEntry]) -> BranchTree` with `BranchNode` — render the whole conversation tree for UI navigation.
- Breaking signatures: `SessionStore::load_entries -> Vec<StoredEntry>`, `MessageProjection::project(&[StoredEntry])`, `CompactionStrategy::select_cutoff(&[StoredEntry])`, and `AgentSession::entries -> Vec<StoredEntry>`.

Still available from 0.15:

- `SessionEntry` — storage payload primitive: `Message` or app-defined `Custom { kind, payload }` (wrapped in `StoredEntry` for persistence)
- `SessionMeta` — durable session header with `name`, `cwd`, `first_user_message`, `updated_at_ms`, `entry_count`, and app-defined `extra`
- `MessageProjection` — converts raw `&[StoredEntry]` into the LLM-visible `Vec<Message>`; `DefaultProjection` hides custom entries, renders only the active branch, and applies durable compaction markers on that branch
- `AgentSession::entries()` / `history()` now return `Result<...>` so store I/O and parse failures are surfaced instead of silently swallowed
- Legacy 0.14 message-only session files get deterministic synthetic `EntryId`s so compaction markers remain valid across restarts
- `SessionCatalog` — multi-session discovery helpers: `create`, `open`, `open_or_create`, `list`, `find_most_recent`, `continue_recent`, `fork`
- `AgentSession::maybe_compact` — stateful compaction that writes a durable marker instead of recomputing summaries on every restart
- Autocompact request planning preserves safe cut boundaries via `turn_boundary::find_cut_points`, so compaction never splits an assistant tool call from its matching tool result
- Autocompact also has public opt-in rewrite policies for older tool results / images plus latest-user replay after summary insertion, chained composition via `ChainedCompactionPolicy`, and reasoning eviction via `EvictOldReasoningPolicy` (0.19+)
- Default autocompact behavior remains unchanged in 0.18.1: `NoopCompactionPolicy` is still installed unless you call `AutocompactInterceptor::with_compaction_policy(...)`
- Example: `examples/chat_bot_demo.rs`

## Engine

### Builder

```rust
use std::sync::Arc;
use std::time::Duration;
use motosan_agent_loop::Engine;

let agent = Engine::builder()
    .tool(my_tool)                          // register a tool (Arc<dyn Tool>)
    .tools(vec![tool_a, tool_b])            // register multiple tools
    .system_prompt("You are helpful.")      // static system prompt
    .context(my_context_provider)           // dynamic context injection
    .max_iterations(10)                     // default: 10 (or .unlimited_iterations() to remove the cap — pair with TurnGuardInterceptor)
    .prompt_caching(true)                   // mark the last user message as a provider cache breakpoint (0.34)
    .tool_timeout(Duration::from_secs(30))  // per-tool call timeout
    .tool_context(my_tool_context)          // custom ToolContext for all tools
    .extension(Box::new(AskUserInterceptor::new(Duration::from_secs(30)))) // opt-in extensions (ErrorPolicy::Fallback)
    .extension_with_policy(                 // opt-in with explicit ErrorPolicy::Abort
        Box::new(StrictExt::new()),
        motosan_agent_loop::core::ErrorPolicy::Abort,
    )
    .extension(Box::new(AutocompactInterceptor::new(summarizer)))
    .channel_config(config)                 // bounded queue config for AgentSession
    .session_id("sess-abc123")              // host session id; threaded into every primitives *Ctx (0.26)
    .permission_context_window(10)          // PermissionContext.recent_messages window; default 10, 0 disables (0.26)
    .build();
```

`session_id` is auto-generated as a UUID v4 at `build()` when unset (0.26+;
previously the legacy `"default"` placeholder). Thread your own host-level id
through so audit logs correlate end-to-end.

`prompt_caching(true)` marks only the last `Role::User` message as
`MessageMeta.cache_stable = true`; providers that do not support cache
breakpoints can ignore the flag.

### RunBuilder — unified run API

Every agent turn goes through `Engine::run(llm, messages)`, which returns
a `RunBuilder`. Chain axis setters in any order, then pick exactly one
terminator. `RunBuilder` is `#[must_use]` — dropping it without a
terminator is a compile warning.

```rust
use std::sync::Arc;
use futures::StreamExt;
use motosan_agent_loop::{Engine, Message};

let agent = Arc::new(Engine::builder().build());
let llm = Arc::new(my_llm);

// 1. Simplest: just the final result
let result = Arc::clone(&agent)
    .run(llm.clone(), vec![Message::user("Hi!")])
    .result()
    .await?;

// 2. Push events through a callback (batch LLM)
Arc::clone(&agent)
    .run(llm.clone(), vec![Message::user("Hi!")])
    .callback(|event| println!("{event:?}"))
    .await?;

// 3. Live token-by-token with callback
Arc::clone(&agent)
    .run(llm.clone(), vec![Message::user("Hi!")])
    .chunked()
    .callback(|event| print_token(event))
    .await?;

// 4. Pull a stream (for adapters / complex consumers)
let mut stream = Box::pin(
    Arc::clone(&agent).run(llm, vec![Message::user("Hi!")]).stream()
);
while let Some(item) = stream.next().await {
    // process events and terminal
}
```

**Axis setters** (chain in any order, last-write-wins on duplicates):

- `.ops(rx: mpsc::Receiver<AgentOp>)` — inject `AgentOp` values mid-turn
  (`AskUserAnswer`, `ApprovePlan`, `Interrupt`, `InjectUserMessage`)
- `.cancel(token: CancellationToken)` — cooperative cancel (always on;
  the `cancellation` Cargo feature was removed in 0.33)
- `.chunked()` — use `LlmClient::chat_stream` so `TextChunk` events fire
  per LLM delta (equivalent to SDK `stream=True`)

**Terminators** (pick exactly one):

- `.result() -> Future<Result<AgentResult>>` — run to completion, no
  events delivered
- `.callback(cb) -> Future<Result<AgentResult>>` — push each `AgentEvent`
  through the callback
- `.stream() -> impl Stream<Item = AgentStreamItem>` — pulled stream of
  events followed by a single `Terminal`

All six axis combinations are supported (batch/chunked × three
terminators, each with optional ops and/or cancel). The two previously-
unreachable combinations — `batch + pulled + cancel` and
`chunked + callback + ops + cancel` — are now expressible.

```rust
// Mid-turn ops (answer ask_user, interrupt):
let (ops_tx, ops_rx) = tokio::sync::mpsc::channel(32);
let mut stream = Box::pin(
    Arc::clone(&agent).run(llm, messages).ops(ops_rx).stream()
);
// From another task: ops_tx.send(AgentOp::Interrupt).await?;
while let Some(item) = stream.next().await {
    match item {
        AgentStreamItem::Event(_ev) => { /* render */ }
        // Per-message commit (0.27): a message was finalized into the
        // session this turn — mirror it into your own view of history.
        AgentStreamItem::MessageCommitted(_m) => { /* persist / track */ }
        AgentStreamItem::Terminal(t) => { t.result?; break; }
        // AgentStreamItem is #[non_exhaustive].
        _ => {}
    }
}

// Cancellation (always on; `cancellation` Cargo feature removed in 0.33):
let token = CancellationToken::new();
Arc::clone(&agent)
    .run(llm, messages)
    .cancel(token.clone())
    .callback(|_| {})
    .await?;
// Returns Err(AgentError::Cancelled) if token is tripped mid-loop.
```

The terminal stream item is guaranteed to be the last item before the
stream closes. `AgentTerminal { result, messages }` carries both the
final `Result<AgentResult>` and the final conversation history.

### Per-message commit (new in 0.27)

The engine commits each message into the session **as it is finalized
during a turn** (not only at a successful Terminal), so a turn that fails
or is cancelled mid-stream leaves the already-completed messages in
history. Surfaced on three surfaces:

- `AgentStreamItem::MessageCommitted(Message)` — one per finalized
  message (input prefix, each in-turn assistant message, each tool
  result, and extension-injected/compaction messages). By the time it
  arrives the message is already in `session.history()`. Emitted via an
  **awaiting `send`** (not `try_send`) so a slow consumer applies
  backpressure instead of silently dropping a persistence-critical item.
- `AgentSession::commit_message(&self, epoch: u64, msg: Message) -> Result<()>`
  — append-one-message API; both `run_turn` and external stream consumers
  call it. No-ops on a stale epoch.
- `EngineBuilder::stream_capacity(usize)` — bounded capacity of the run
  stream channel (defaults apply otherwise); tune for backpressure tests
  or memory-constrained consumers.

> Ordering note: for an assistant message that carries tool calls,
> `MessageCommitted(assistant)` currently fires AFTER the tools' `ToolStarted`/
> `ToolCompleted` events (`ToolStarted ×N → ToolCompleted ×N →
> MessageCommitted(assistant) → MessageCommitted(tool_result) ×N`).

---

## AgentSession (Long-Lived Session)

```rust
use std::sync::Arc;
use futures::StreamExt;
use motosan_agent_loop::{
    AgentSession, AgentStreamItem, FileSessionStore, MemorySessionStore, Message,
};

// Basic session
let session = AgentSession::new(agent, Arc::new(llm));

// Persistent session
let store = Arc::new(MemorySessionStore::new());
let session = AgentSession::new_with_store("chat-1", store.clone(), agent, Arc::new(llm));

// Start a turn: returns a TurnHandle with { ops_tx, stream, epoch, previous_len, branch_parent }.
let mut messages = session.history().await?;
messages.push(Message::user("Search for Taipei rentals"));
let handle = session.start_turn(messages).await?;

// Convenience: drain + commit outcome. Returns the final Vec<Message>.
let final_messages = session.run_turn(handle).await?;

// Or drain manually and commit explicitly.
let mut handle = session.start_turn(session.history().await?).await?;
let mut terminal_messages = Vec::new();
while let Some(item) = handle.stream.next().await {
    match item {
        AgentStreamItem::Event(_ev) => { /* render */ }
        // MUST handle, or persistence drifts. Manual drains call
        // session.commit_message(handle.epoch, _m).await? to persist.
        AgentStreamItem::MessageCommitted(_m) => { /* persist */ }
        AgentStreamItem::Terminal(t) => {
            t.result?; // propagate turn error
            terminal_messages = t.messages;
            break;
        }
        // AgentStreamItem is #[non_exhaustive].
        _ => {}
    }
}
session
    .record_turn_outcome(handle.epoch, handle.previous_len, &terminal_messages, None)
    .await?;

// Lifecycle
session.flush().await?;  // explicit flush to store
session.reset().await;    // clear history, bump epoch, discard in-flight turns
let history = session.history().await?; // Vec<Message>
let tree = session.branches().await?; // BranchTree for /tree UIs
let branch_history = session.history_from(entry_id).await?; // branch ending at entry_id
let forked = session.fork_turn(entry_id, vec![Message::user("Alternative")]).await?;
session.close().await?;  // final flush; subsequent start_turn returns error
```

### Mid-turn ops (inject / interrupt / answer ask_user)

```rust
use motosan_agent_loop::AgentOp;

let handle = session.start_turn(messages).await?;
let ops_tx = handle.ops_tx.clone();

tokio::spawn(async move {
    let _ = ops_tx.send(AgentOp::InjectHint("Be concise".into())).await;
    let _ = ops_tx.send(AgentOp::AskUserAnswer {
        call_id: Some("call_id".into()),
        answer: "my answer".into(),
    }).await;
    let _ = ops_tx.send(AgentOp::Interrupt).await;
});

let _ = session.run_turn(handle).await?;
```

---

## Tool-result hooks + follow-up

Extensions now have two tool-result hooks:

- `rewrite_tool_result(call, result, ctx) -> Result<Option<ToolOutput>, ExtError>`
  rewrites a tool result before `ToolCompleted` is emitted or the tool message is appended.
- `after_tool_result(result, ctx) -> Result<FlowDecision, ExtError>`
  observes the final appended result and may continue, inject a message, or halt.

Ordering is:

```text
rewrite_tool_result -> ToolCompleted -> append tool messages -> after_tool_result
```

Built-ins added in 0.14.0:

- `FollowUpInterceptor` — drains queued user messages after natural text stops
- `RedactInterceptor` (feature `redact`) — regex-based masking example built on `rewrite_tool_result`

### Follow-up answer semantics

`FollowUpInterceptor` reuses the engine's existing `FlowDecision::Inject`
continuation path. That means `AgentResult.answer` is the concatenation of
assistant text segments produced across the follow-up chain. If you need exact
segment boundaries, read `AgentResult.messages` instead.

---

## ContextProvider

```rust
use async_trait::async_trait;
use motosan_agent_loop::{ContextProvider, Result};

struct RagProvider;

#[async_trait]
impl ContextProvider for RagProvider {
    async fn build(&self, query: &str) -> Result<String> {
        // Fetch relevant documents based on user query
        Ok(format!("Relevant docs for: {query}"))
    }
}
```

Returned string is injected as a System message. Empty string = skipped.
Multiple providers run in parallel via `try_join_all`.

---

## MCP Server Support

Requires `mcp-client` feature.

```toml
motosan-agent-loop = { version = "0.36", features = ["mcp-client"] }
```

```rust
use motosan_agent_loop::mcp::{McpServerStdio, McpServerHttp};

let agent = Engine::builder()
    .mcp_server(McpServerStdio::new("npx", ["-y", "@modelcontextprotocol/server-filesystem"]))
    .mcp_server(McpServerHttp::new("https://mcp.example.com/sse"))
    .build();

// MCP servers auto-connect when the RunBuilder terminator runs and disconnect after.
let result = Arc::new(agent).run(Arc::new(llm), messages).result().await?;
```

---


### ToolSchema cascade (0.28+)

`motosan-agent-primitives::ToolSchema` is the canonical LLM-facing tool
declaration. `motosan_agent_tool::ToolDef` composes it as `schema` plus
`internal_name` and keeps `def.name`/`def.description`/`def.input_schema`
field reads working via `Deref`. The motosan-ai bridge sends cloned
`ToolSchema` values to `ChatRequestBuilder::tool_schemas`; the SDK no longer
has an `agent-tool` feature.

## motosan-ai Bridge

Requires `motosan-ai` feature.

```toml
motosan-agent-loop = { version = "0.36", features = ["motosan-ai"] }
motosan-ai = { version = "0.21", features = ["anthropic"] }
```

```rust
use motosan_agent_loop::MotosanAiClient;
use motosan_ai::{Client, Provider};

let client = Client::builder()
    .provider(Provider::Anthropic)
    .api_key(std::env::var("ANTHROPIC_API_KEY")?)
    .build()?;

// Option A: bare Client implements LlmClient — use it directly.
let result = Arc::new(agent.clone())
    .run(Arc::new(client.clone()), messages.clone())
    .result()
    .await?;

// Option B: wrap in MotosanAiClient for per-adapter config. Required if you
// use TokenBudgetInterceptor — the wrapper forwards max_tokens on every chat()
// so the SDK returns StopReason::MaxTokens for continuation.
let llm = MotosanAiClient::new(client).with_max_tokens(2048);
let result = Arc::new(agent)
    .run(Arc::new(llm), messages)
    .result()
    .await?;
```

### `MotosanAiClient`

```
MotosanAiClient::new(inner: motosan_ai::Client) -> Self
MotosanAiClient::with_max_tokens(self, max_tokens: u32) -> Self  // builder-style
MotosanAiClient::inner(&self) -> &motosan_ai::Client             // escape hatch
MotosanAiClient::into_inner(self) -> motosan_ai::Client          // escape hatch
impl LlmClient for MotosanAiClient                                // full impl
```

Notes:
- The bare `impl LlmClient for motosan_ai::Client` is still supported for
  backward compatibility.
- `stop_reason` is surfaced on both `chat()` and `chat_stream()` as of the
  `motosan-ai 0.9` upgrade. Streaming yields `StreamChunk::StopReason`
  immediately before `StreamChunk::Done`.

---

## Core Types

| Type | Description |
|------|-------------|
| `LlmClient` | Trait — implement `chat()` for your LLM backend |
| `ChatOutput` | Response wrapper: `LlmResponse` + optional `TokenUsage` |
| `LlmResponse` | `Message(String)` or `ToolCalls(Vec<ToolCallItem>)` |
| `StreamChunk` | `TextDelta(String)`, `ThinkingDelta(String)`, `ThinkingDone(String)`, `ToolUse { id, name, args }`, `Done(LlmResponse)`, `Usage(TokenUsage)`, or `StopReason(StopReason)` |
| `TokenUsage` | `{ input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens }`; `context_tokens()` sums all four axes |
| `ToolCallItem` | `{ id: String, name: String, args: Value }` |
| `Engine` | The core ReAct loop |
| `EngineBuilder` | Builder pattern for Engine configuration (`.extension(ext)` = `ErrorPolicy::Fallback`; `.extension_with_policy(ext, policy)` for explicit control) |
| `core::ErrorPolicy` | `Fallback` (log + default) or `Abort` (propagate as `AgentError::Internal`) |
| `MotosanAiClient` | Wrapper around `motosan_ai::Client` with per-adapter config (`with_max_tokens`). Implements `LlmClient`. Behind `motosan-ai` feature. |
| `AgentResult` | `{ answer, tool_calls, iterations, usage, last_usage, messages }`; `usage` is cumulative, `last_usage` is the most recent LLM-call snapshot |
| `AgentEvent` | Two-layer enum: `Core(CoreEvent)` or `LoopInterceptor(ExtensionEvent)` |
| `CoreEvent` | Core loop events: tool, streaming, backpressure, interrupt |
| `ExtensionEvent` | Per-interceptor event wrapper — `Autocompact`, `TokenBudget`, `StuckDetection`, `AskUser`, `Planning`, `FollowUp`, `TurnGuard`, optional `Redact`, or third-party `External` |
| `AgentOp` | Interactive commands (`#[non_exhaustive]`): `Interrupt`, `InjectUserMessage`, `InjectHint`, `AskUserAnswer`, `ApprovePlan`, `ExtensionResume` |
| `AgentSession` | Long-lived background session with managed history; `entries() -> Result<Vec<StoredEntry>>`, `fork_turn`, `branches`, `history_from`, `commit_message_with_parent` for manually-drained fork streams |
| `SessionStore` | Persistence trait (`MemorySessionStore`, `FileSessionStore`); `load_entries() -> Vec<StoredEntry>`, `append_entry_with_parent(...)` for branching |
| `StoredEntry` | `{ id, parent_id, entry }` persisted session-log record |
| `MessageProjection` | Trait: `project(&self, entries: &[StoredEntry]) -> Vec<Message>` |
| `CompactionStrategy` | Trait: `select_cutoff(&self, entries: &[StoredEntry]) -> Option<EntryId>` |
| `BranchTree` / `BranchNode` | Read-only conversation tree from `project_branches(&[StoredEntry])` or `AgentSession::branches()` |
| `SessionMeta` | Session metadata for store operations |
| `ContextProvider` | Trait — inject dynamic context into conversations |
| `ChannelConfig` | Bounded queue config: `ops_capacity`, `stream_capacity` |
| `AgentError` | `Llm`, `Tool`, `Serde`, `Io`, `MaxIterations`, `Cancelled`, `Halted { code, message }`, `Mcp` |
| `CancellationToken` | Re-exported from `tokio-util` (always available; the `cancellation` Cargo feature was removed in 0.33) |
| `Reviewer` / `ReviewDecision` | Permission-approval seam (0.31+). `EngineBuilder::reviewer(Arc<dyn Reviewer>)`; `async fn review(&self, ApprovalRequest) -> ReviewDecision` returns `Approve` / `Deny { reason }`. Resolves a `PermissionPolicy` `AskUser` outcome. Default = fail-closed `DenyReviewer`. The `ask_user` interceptor/tool channel is separate and unchanged. |
| `McpServer` | Trait — MCP server abstraction (`McpServerStdio`, `McpServerHttp`) |
| `Message` | `system(s)`, `user(s)`, `assistant(s)`, `tool_result(id, content)` |

### Message/ContentPart additions in 0.17

```text
ContentPart (enum, #[non_exhaustive])
  ::Text { text: String }
  ::Image { image: ImageSource }
  fn text(s) -> Self
  fn image_base64(media_type, data) -> Self
  fn image_url(url) -> Self
  fn as_text() -> Option<&str>
  fn as_image() -> Option<&ImageSource>

ImageSource (enum, #[non_exhaustive])
  ::Base64 { media_type: String, data: String }
  ::Url { url: String }
  fn base64(media_type, data) -> Self
  fn url(url) -> Self

Message
  fn user_with_parts(Vec<ContentPart>) -> Self
  fn tool_result_with_parts(call_id, Vec<ContentPart>) -> Self
  fn user_with_image_base64(prompt, media_type, data) -> Self
  fn user_with_image_url(prompt, url) -> Self
  fn tool_result_with_image_base64(call_id, text, media_type, data) -> Self
  fn tool_result_with_image_url(call_id, text, url) -> Self

pub const IMAGE_APPROX_CHAR_EQUIVALENT: usize = 6400
```

### Message/ContentPart additions in 0.19

```text
ContentPart (enum, #[non_exhaustive]) — new variant
  ::Document { document: DocumentSource }
  fn document_base64(media_type, data) -> Self
  fn document_url(url) -> Self
  fn pdf_base64(data) -> Self                  // media_type = "application/pdf"
  fn pdf_url(url) -> Self
  fn as_document() -> Option<&DocumentSource>

DocumentSource (enum, #[non_exhaustive])
  ::Base64 { media_type: String, data: String }
  ::Url { url: String }
  fn base64(media_type, data) -> Self
  fn url(url) -> Self
  fn pdf_base64(data) -> Self

Message — new convenience constructors
  fn user_with_document_base64(prompt, media_type, data) -> Self
  fn user_with_document_url(prompt, url) -> Self
  fn user_with_pdf_base64(prompt, data) -> Self
  fn user_with_pdf_url(prompt, url) -> Self

pub const DOCUMENT_APPROX_CHAR_EQUIVALENT: usize = 16_000
```

### Autocompact policy additions in 0.19

```text
ChainedCompactionPolicy
  fn new(Vec<Arc<dyn RequestCompactionPolicy>>) -> Self
  fn from_policies<I: IntoIterator<Item = Arc<dyn RequestCompactionPolicy>>>(I) -> Self
  fn len() -> usize
  fn is_empty() -> bool
  // rewrite: piped in iteration order
  // apply_after_compaction: runs each policy with same original_messages

EvictOldReasoningPolicy { replacement_text }
  fn default() -> Self                          // marker "[reasoning removed by compaction]"
  fn new(text: impl Into<String>) -> Self
  // swaps AssistantContent::Reasoning for AssistantContent::Text
  // skips messages with meta.cache_stable == true
  // marks meta.compacted_at_ms
```

---

### ExtensionsConfig / ExtensionWiring / build_interceptor_set

Declarative construction of the built-in extension set. `ExtensionsConfig` holds `Option<Config>` fields (Some = enabled) for planning, ask_user, stuck_detection, token_budget, autocompact, and turn_guard. `ExtensionWiring` holds runtime handles that cannot be serialized: today this is `summarizer_llm: Option<Arc<dyn LlmClient>>` for autocompact.

`build_interceptor_set(&config, wiring) -> Result<InterceptorSet, ExtensionBuildError>` composes the two. Autocompact enabled without `summarizer_llm` returns `MissingSummarizerLlm`. `turn_guard: Some(TurnGuardConfig { max_duration, max_total_tokens })` enables the 0.34 safety-net interceptor for unbounded turns. Delegation was removed from this crate in 0.22 and now lives in `motosan-agent-subagent`; register subagent/delegation interceptors manually.

Autocompact is deliberately wire-safe: its request transform preserves tool-call/tool-result pairing boundaries and applies multimodal rewrite policies only when a turn actually enters the compacting path. Built-in policies are now also public opt-in API via `RequestCompactionPolicy`, `NoopCompactionPolicy`, `EvictOldToolResultsPolicy`, `EvictOldImagesPolicy`, `ReplayLastUserPolicy`, and `AutocompactInterceptor::with_compaction_policy(...)`.

`EngineBuilder::with_extensions_config(config, wiring)` is the ergonomic one-liner for apps — it reuses the builder's own `tool_context` (snapshotted at call time). It composes freely with manual `.extension(...)` calls.

---

### Unbounded iterations + TurnGuard (0.34)

- `EngineBuilder::unlimited_iterations()` removes the per-turn `max_iterations`
  cap. Use it only with a stop mechanism: `TurnGuardInterceptor` and/or
  `CancellationToken`.
- `TurnGuardConfig { max_duration, max_total_tokens }` halts in
  `before_iteration`, so it catches runaway tool-call loops as well as text
  loops. The turn ends as `AgentError::Halted { code, message }` and emits
  `ExtensionEvent::TurnGuard(TurnGuardEvent::Halted { code, detail })`.
- `HaltReason::new(code, message)` is the public constructor for custom
  interceptors returning `FlowDecision::Halt`; avoid struct literals because
  `HaltReason` is `#[non_exhaustive]`.

### Session id + permission context window (0.26)

D-M10 wires real per-session correlation and conversational context into the
primitives hook + permission layer:

- `EngineBuilder::session_id(impl Into<String>)` — threads a host-level session
  id into every primitives `*Ctx` (hook + `PermissionContext`) the engine
  emits. Unset → `build()` auto-generates a UUID v4 (was the legacy `"default"`
  placeholder). Read back via the context's `session_id`.
- `EngineBuilder::permission_context_window(usize)` (default `10`) — sliding
  window size for `PermissionContext.recent_messages`. Read back via
  `Engine::permission_context_window()`.
- `PermissionContext.recent_messages` (new field in primitives 0.2.0) is now
  populated by the engine from the **tail of the transcript as the LLM saw it
  before the current tool call** (the in-flight call is excluded). Window `0` →
  policies always observe an empty slice. The engine previously built
  `PermissionContext` without this field.
- loop→primitives `Message` conversion (for both hook + permission contexts) is
  now lossless via the shared `to_primitives_message` mapper instead of a lossy
  serde round-trip: `System` → `Role::System` (single text block), `Tool` →
  `Role::Tool` (single `ToolResult` block, `tool_call_id` preserved,
  `is_error = false`), id regenerated ULID → UUID.

**Dep cascade (0.26):** rides on `motosan-agent-tool` 0.5 (`ToolDef.internal_name`),
`motosan-agent-primitives` 0.2.0 (`PermissionContext.recent_messages` +
`PostToolUseFailureCtx.result`), and `motosan-ai` 0.17.

**Breaking:** policies that matched the `"default"` session id must use
`session_id(...)`; downstream exhaustive struct-literal constructors of
`ToolDef` / primitives `PermissionContext` / `PostToolUseFailureCtx` must add
the new fields.

---

## AgentEvent Variants

`AgentEvent` is a two-layer enum. Match on `Core(...)` for loop-level events and `LoopInterceptor(...)` for interceptor-level events.

### Core variants (`AgentEvent::Core(CoreEvent::X)`)

```
CoreEvent::IterationStarted { iteration: usize }  — start of each LLM round-trip
CoreEvent::ToolStarted { name: String, args: serde_json::Value }
                                                   — before tool execution, with LLM-supplied args
CoreEvent::ToolCompleted { name: String, result }  — after tool execution
CoreEvent::TextChunk(String)                       — streaming text delta
CoreEvent::TextDone(String)                        — full text after streaming completes
CoreEvent::ThinkingChunk(String)                   — streaming extended-thinking delta (advisory; never fed back)
CoreEvent::ThinkingDone(String)                    — end of thinking block, full concatenated text
CoreEvent::Interrupted                             — loop interrupted via AgentOp::Interrupt
CoreEvent::OpsSaturated { capacity: usize }        — ops channel full (emitted once)
CoreEvent::OpDropped { reason: String }            — op dropped due to backpressure
CoreEvent::OpRejected { reason: String }           — op rejected due to backpressure
CoreEvent::ExtensionFailed { name: &'static str, error: String }
                                                   — an interceptor hook failed under ErrorPolicy::Fallback; the turn continued with the hook's default decision (0.35)
```

### Extension variants (`AgentEvent::LoopInterceptor(ExtensionEvent::X(Y))`)

```
ExtensionEvent::Autocompact(AutocompactEvent::Compacted { turns_removed, summary_tokens })
ExtensionEvent::TokenBudget(TokenBudgetEvent::Continuation { turn })
ExtensionEvent::StuckDetection(StuckDetectionEvent::Detected { iteration })
ExtensionEvent::AskUser(AskUserEvent::Question { call_id, questions })
ExtensionEvent::AskUser(AskUserEvent::Timeout { call_id, question })
ExtensionEvent::Planning(PlanningEvent::ModeEntered { reason })
ExtensionEvent::Planning(PlanningEvent::Ready { plan, call_id })
ExtensionEvent::Planning(PlanningEvent::Approved)
ExtensionEvent::Planning(PlanningEvent::Rejected { feedback: Option<String> })
ExtensionEvent::Planning(PlanningEvent::Created { plan_id, title, step_count })
ExtensionEvent::Planning(PlanningEvent::StepUpdated { plan_id, step_index, status })
ExtensionEvent::FollowUp(FollowUpEvent::Injected { remaining })
ExtensionEvent::FollowUp(FollowUpEvent::Exhausted)
ExtensionEvent::TurnGuard(TurnGuardEvent::Halted { code, detail })
ExtensionEvent::External { name, payload }
```

### Event Ordering (per iteration)

1. `Core(CoreEvent::IterationStarted { iteration: n })`
2. `Core(CoreEvent::ToolStarted)` (per tool)
3. `Core(CoreEvent::ToolCompleted)` (per tool — may interleave in parallel batches)
4. `Core(CoreEvent::TextChunk)` / `Core(CoreEvent::TextDone)` (on final text response)

Extension events are emitted inline within this ordering. All tool completions in a batch precede the next `IterationStarted`.

---

## Session Lifecycle Guarantees

- **Epoch-based reset**: `reset()` bumps epoch; in-flight turns with stale epoch are silently discarded.
- **No duplicate persistence**: User message stored once before turn; post-turn skips it.
- **Close semantics**: Sets flag, interrupts current turn, final flush. New sends dropped.
- **Drop behavior**: Best-effort flush. Use `flush()` or `close()` for strict durability.

---

## Observability (0.35)

Three nested `tracing` spans wrap every turn:

```
agent_turn { session_id }        — entered in run_turn; wraps the whole turn
└── iteration { iteration }      — one per LLM round-trip (batch + streaming step paths)
    └── tool_call { tool, call_id } — one per dispatched tool call
```

Install any `tracing` subscriber; warn/error events from inside the loop
(including `ErrorPolicy::Fallback` interceptor-failure warnings) inherit
this span context automatically. Programmatic consumers can watch
`CoreEvent::ExtensionFailed { name, error }` instead of scraping logs.

---

## Dependency Tree

```
motosan-agent-loop
  +-- motosan-agent-tool   (Tool / ToolDef / ToolOutput)
  +-- async-trait
  +-- futures
  +-- serde / serde_json
  +-- thiserror
  +-- tokio                (time, sync, rt, fs)
  +-- [motosan-ai]         (optional: motosan-ai feature)
  +-- [rmcp]               (optional: mcp-client feature)
  +-- tokio-util           (CancellationToken — always on as of 0.33)
```

## License

MIT
