# 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.19.2
- 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.18"
motosan-agent-tool = "0.3"
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` |
| `cancellation` | off | `CancellationToken` for graceful loop cancellation |
| `redact` | off | Built-in `RedactExtension` demo for tool-result masking |

---

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

Still available from 0.15:

- `SessionEntry` — append-only storage primitive: `Message` or app-defined `Custom { kind, payload }`
- `SessionMeta` — durable session header with `name`, `cwd`, `first_user_message`, `updated_at_ms`, `entry_count`, and app-defined `extra`
- `MessageProjection` — converts raw session entries into the LLM-visible `Vec<Message>`; `DefaultProjection` hides custom entries and applies durable compaction markers
- `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 `AutocompactExtension::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
    .tool_timeout(Duration::from_secs(30))  // per-tool call timeout
    .tool_context(my_tool_context)          // custom ToolContext for all tools
    .extension(Box::new(AskUserExtension::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(AutocompactExtension::new(summarizer)))
    .channel_config(config)                 // bounded queue config for AgentSession
    .build();
```

### 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 (requires
  `cancellation` feature)
- `.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 */ }
        AgentStreamItem::Terminal(t) => { t.result?; break; }
    }
}

// Cancellation (requires `cancellation` feature):
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.

---

## 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 }.
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 */ }
        AgentStreamItem::Terminal(t) => {
            t.result?; // propagate turn error
            terminal_messages = t.messages;
            break;
        }
    }
}
session
    .record_turn_outcome(handle.epoch, handle.previous_len, &terminal_messages)
    .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>
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<ToolResult>, 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:

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

### Follow-up answer semantics

`FollowUpExtension` 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.14", 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?;
```

---

## motosan-ai Bridge

Requires `motosan-ai` feature.

```toml
motosan-agent-loop = { version = "0.14", features = ["motosan-ai"] }
motosan-ai = { version = "0.15", 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 TokenBudgetExtension — 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)`, `Done(LlmResponse)`, or `Usage(TokenUsage)` |
| `TokenUsage` | `{ input_tokens: u64, output_tokens: u64 }` |
| `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, messages }` |
| `AgentEvent` | Two-layer enum: `Core(CoreEvent)` or `Extension(ExtensionEvent)` |
| `CoreEvent` | Core loop events: tool, streaming, backpressure, interrupt |
| `ExtensionEvent` | Per-extension event wrapper — `Autocompact`, `TokenBudget`, `StuckDetection`, `AskUser`, `Delegation`, `Planning` |
| `AgentOp` | Interactive commands: `Interrupt`, `InjectUserMessage`, `InjectHint`, `AskUserAnswer` |
| `AgentSession` | Long-lived background session with managed history |
| `SessionStore` | Persistence trait (`MemorySessionStore`, `FileSessionStore`) |
| `SessionMeta` | Session metadata for store operations |
| `ContextProvider` | Trait — inject dynamic context into conversations |
| `BackpressurePolicy` | `Block` (default), `DropOldest`, `Reject` |
| `ChannelConfig` | Bounded queue config: `input_capacity`, `ops_capacity`, `ops_backpressure` |
| `AgentError` | `Llm`, `Tool`, `Serde`, `Io`, `MaxIterations`, `Cancelled`, `Mcp` |
| `CancellationToken` | Re-exported from `tokio-util` (behind `cancellation` feature) |
| `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_extension_set

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

`build_extension_set(&config, wiring, tool_context) -> Result<ExtensionSet, ExtensionBuildError>` composes the two. Autocompact enabled without `summarizer_llm` returns `MissingSummarizerLlm`; delegation enabled with an empty delegate list is silently skipped with a warning log.

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 `AutocompactExtension::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.

---

## AgentEvent Variants

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

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

```
CoreEvent::IterationStarted { iteration: usize }  — start of each LLM round-trip
CoreEvent::ToolStarted { name: String }            — before tool execution
CoreEvent::ToolCompleted { name: String, result }  — after tool execution
CoreEvent::TextChunk(String)                       — streaming text delta
CoreEvent::TextDone(String)                        — full text after streaming completes
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
```

### Extension variants (`AgentEvent::Extension(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::Delegation(DelegationEvent::Started { agent_name, task })
ExtensionEvent::Delegation(DelegationEvent::Completed { agent_name, answer_len })
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 })
```

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

---

## Dependency Tree

```
motosan-agent-loop
  +-- motosan-agent-tool   (Tool / ToolDef / ToolResult)
  +-- 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]         (optional: cancellation feature)
```

## License

MIT
