Compare commits

...
6 Commits
Author SHA1 Message Date
dguiducci 24ee5b89d7 agent-loop: projection, recovery, compaction into the crate (phase 3)
Nightly Build / build (push) Successful in 6m49s
The session handler is now a thin shell: three entry points in
kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call)
and the ChatSessionHandler. Everything that shaped a Value — projection,
recovery, compaction mechanics, the LLM loop, message building — lives
in agent-loop or behind a loop_adapters trait.

agent-loop:
- projection/ (mod + media): stored history -> wire messages, the one
  place provider divergence lives; well-formedness contract, DTL
  injections (append-only), media parts. LinearAssembler is now a
  Projection + ProjectionHooks config, not its own implementation
- recovery.rs: reap interrupted batches -> resolve the deepest frame's
  non-terminal calls (Running by policy + RestartHint, AwaitingHuman
  re-asked) -> un-wedge finished children -> cascade up, every frame on
  its own agent (B3)
- compaction.rs: split point (never assistant+tool group), transcript,
  SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row
- manager: resolve_pending (gate skipped, real ToolContext, then
  continue incl. sub-agent); start_loop used by recovery; LiveInput
- delegate: AsyncExecutor + StoreSink for mode:async (durable cron row,
  result delivered back into the parent conversation)
- kernel/context/store: support the above (TurnScope via Extensions,
  frame lookups, aligned result-text semantics)

skald-core:
- loop_adapters: UserLoopRuntime (D12 - one LoopManager per user),
  TurnScope (per-turn state in the Extensions type-map; no scope is
  denied), projection_cfg/media_source/tool_digest (Skald's projection
  knobs without owning projection code), async_task (CronExecutor +
  DurableSink)
- session/handler: stripped to mod.rs + kernel_turn.rs + config.rs +
  interface_tools.rs + media.rs; deleted agent_dispatch, approval,
  dispatch, emitter, gate, llm_call, llm_loop, message_builder,
  messages, outcome, resume
- compactor.rs: policy only (threshold, model pick, CompactionEvent);
  mechanics are the crate's

CLAUDE.md updated (recovery, compaction, sub-agents, approval gate,
projection sections now describe the crate-owned flow).
2026-07-26 17:09:01 +01:00
dguiducci 3fca7867fa agent-loop: drop leftover empty wiring module (phase 2) 2026-07-26 12:18:42 +01:00
dguiducci 0297fe71bd agent-loop: root turn driven by the library kernel (phase 2)
ChatSessionHandler now runs the root turn on the agent-loop kernel
instead of run_agent_turn; sub-agents follow on the same kernel via
DelegateTool. The old loop stays for resume/recovery until phase 3.

agent-loop:
- DelegateTool + AgentCatalog/AgentProfile (full toolset override,
  per-child selector/assembler, frame-scoped get), StaticCatalog,
  FilteredToolSet; sync flow with sticky child_token; batch via the
  generic fan-out
- manager: start_loop skips the registry (children are not
  double-driving; they ride the parent's token tree); LoopParams gains
  selector/token overrides
- store: get_frame, get_call, set_call_extras; HistoryStore result text
  aligned to raw-stored semantics (projection formats)
- events: ApprovalRequired.request_id, AgentSpawned/Finished parent
  info; AskUserTool with_name + suggested_answers alias + Question.frame

skald-core (loop_adapters + handler):
- SkaldAssembler (byte-parity port of MessageBuilder's projection:
  scratchpad/summary/window, DTL Kimi/Anthropic injection, media, user
  coalescing, reasoning echo) + AgentSystemContext (prompt layers,
  substitutions, MCP list, shared folders, user profile)
- SkaldAgentCatalog (build_sub_agent_config port), SkaldHumanChannel,
  scratchpad/todos tools, execute_task sync/async alias,
  LegacyInterfaceTool, PendingLiveInput
- ApprovalGate: PendingWrite diffs via LoopEvent::Host (memory/disk
  routed like the fs-tools); SkaldWritePreviewHook for executed-write
  diffs; EventTranslator LoopEvent→ServerEvent (display meta, preview,
  FileChanged, AgentStart/Done, root-only Done/Truncated/Cancelled)
- handle_message: builds TurnParams and drives the kernel; resume of
  pending tools runs first (results belong to the previous turn);
  ChatEvent publication stays handler-side; /stop cancels the live loop
- ToolRegistry.get_tool/all_tools; def builders made pub(crate)

Full workspace suite green (179 skald-core, 34 agent-loop, adapters
incl.); two pre-existing doc-test failures fixed along the way.
2026-07-26 12:15:53 +01:00
dguiducci d50abbb0fa agent-loop: Skald adapters behind the crate traits (phase 1)
New skald-core::loop_adapters module — implements the agent-loop trait
surface over existing infrastructure, unused by the current loop (wired
in phase 2):

- SqliteHistory: HistoryStore over chat_sessions_stack/chat_history/
  chat_llm_tools/chat_summaries, no schema change; CallState maps 1:1 on
  the existing status strings; wire call ids synthesized as tc_{id}
- SkaldSelector: ModelSelector over LlmManager with the agent's strength
  captured per-turn (D14); DtlMode → ToolRendering mapping (D15)
- SkaldActivationSource + SkaldToolActivator: DTL catalog + persistence
  (activated_tools, anchored at the triggering message) behind the
  crate's protocol traits; unifies the grants/persistence split
- ApprovalGate: port of run_approval_gate (pre-approved, engine, fs
  fast-path, auto-deny, AwaitingHuman + block on human); a closed human
  channel maps to the new GateDecision::Suspend in agent-loop
- SkaldToolSet + CoreToolBridge/McpToolBridge: core-api and MCP tools
  run inside the crate's kernel (execution bridged, execute_cmd keeps
  its teardown; D7 MarkInterrupted for shell)
- agent-loop: re-export async_trait at root; EventSink::new made public

17 adapter tests green (temp-DB integration); full workspace suite green
(pre-existing honcho-client doc-test failure untouched: missing dev-deps).
2026-07-26 07:15:36 +01:00
dguiducci 882a8c9cb9 llm: switch Skald to agent-loop Model clients; drop llm-client (phase 1, D13)
The LLM call path now runs on the agent-loop crate's clients and trait:

- core-api: BuiltLlmClient.client is Arc<dyn agent_loop::model::Model>;
  chatbot.rs (ChatbotClient + wire types) deleted; APP_NAME re-exported
  from agent-loop
- providers (openai/anthropic/ollama/openrouter/requesty/declared) build
  OpenAiModel/AnthropicModel/OllamaModel with the model's wire id
- LoggingModel decorator (llm/logging.rs) replaces LoggingChatbotClient;
  per-request correlation (session/stack/user) travels in the new
  ModelRequest.log field, never sent to providers
- llm_call/llm_loop/compactor speak Model::complete + ModelResponse;
  retriability via Model::is_retriable (structured status, B6 rule now
  the crate's default); payload persistence reads RawMeta off
  ModelResponse/ModelError
- crates/llm-client and skald-core/src/chatbot deleted

Full workspace test suite green (incl. 162 skald-core + 32 agent-loop).
2026-07-25 23:55:17 +01:00
dguiducci b8cc6d263b agent-loop: new crate — LLM loop kernel + Model clients (phase 0)
Extract the LLM agent loop into a standalone workspace crate with zero
deps on skald-core/core-api (blueprint project-loop.md, D13-D15):

- kernel: round loop, model fallback with rebuild, parallel tool fan-out
  (ordered id alloc / bounded concurrent exec / ordered record), streaming
  deltas drained before outcomes, sticky cancellation
- models: OpenAiModel/AnthropicModel/OllamaModel/LmStudioModel ported from
  llm-client onto the Model trait; ModelError carries the HTTP status;
  is_retriable default = the 401/403/404/422 rule
- DTL as crate protocol (ToolRendering Inline/DeferredToolReference/
  SystemToolBlock; Anthropic conversions + Kimi system+tools passthrough),
  host catalog behind ActivationSource/ToolActivator
- HistoryStore durability contract + InMemoryStore; LinearAssembler with
  well-formed projection (incl. DTL injection, summary, crash survivors)
- LoopManager singleton (broadcast bus + live registry), one live loop
  per conversation, orphan-marking on start_turn
- 32 tests green (kernel §13 suite, assembler DTL, SSE/Anthropic ports),
  clippy clean
2026-07-25 23:40:41 +01:00
119 changed files with 16630 additions and 6219 deletions
+54 -23
View File
@@ -69,7 +69,9 @@ Two rules keep the boundary real, and both are enforced by the compiler:
| ---- | ---- |
| `src/main.rs` | Thin entry point: tracing → `Skald::new``WebFrontend::start` → shutdown. Builds a tokio runtime and blocks on `async_main`, which runs the backend until a SIGINT/SIGTERM. Exposes `run_backend()` / `shutdown_backend()` |
| `crates/skald-core/src/skald/` | `Skald` — headless application core. `mod.rs` (struct + staged `new()` / `shutdown()`), `runtime.rs` (cross-cutting `Runtime` context), `bundles.rs` (8 domain bundles + `build()`), `wiring.rs` (`wire()` + `spawn_background()`), `supervisor.rs` (`TaskSupervisor`), `accessors.rs` (per-manager accessor facade — the API surface the frontend uses) |
| `crates/skald-core/src/session/handler/` | Core LLM loop `mod.rs`, `llm_loop.rs` (`run_agent_turn`), `agent_dispatch.rs`, `dispatcher.rs`, `approval.rs`, `resume.rs`, `messages.rs`, `config.rs`, `interface_tools.rs`, `media.rs` (multimodal attachments — see below) |
| `crates/agent-loop/` | **The LLM loop itself, as a standalone crate**: kernel (round loop, fallback, tool fan-out), `LoopManager`, `HistoryStore`, projection (history→wire), `DelegateTool` (sub-agents), `recovery.rs` (restart), `compaction.rs`, plus the shipped model clients (`models/`). Knows nothing about Skald — see the loop section below |
| `crates/skald-core/src/loop_adapters/` | Skald's side of that crate's traits: history store, model selector, approval gate, tool set + bridges, agent catalog, event translator, projection knobs, async executor. This is where "how Skald does it" lives |
| `crates/skald-core/src/session/handler/` | What is left of the session layer: `mod.rs` (`ChatSessionHandler` + `handle_message`), `kernel_turn.rs` (the three loop entry points), `config.rs`, `interface_tools.rs`, `media.rs` |
| `crates/skald-core/src/session/manager.rs` | Creates/retrieves `ChatSessionHandler` per session |
| `crates/skald-core/src/chat_hub/` | `ChatHub`: broadcast events to all connected WS clients |
| `crates/skald-core/src/chat_event_bus.rs` | Global async bus for cross-session events |
@@ -85,7 +87,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
| `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section |
| `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration, per-user access grants + per-user config |
| `crates/skald-core/src/cron/` | Scheduled job runner |
| `crates/skald-core/src/compactor.rs` | Context compaction (summarises history when token budget exceeded). Model for the summary call: the instance-wide Settings pick (`compaction_model`, a `PropertyType::LlmModel` config property declared by `compactor::config_set`) wins; else AUTO by `compaction.strength` (config.yml); a missing configured model degrades to the same AUTO path |
| `crates/skald-core/src/compactor.rs` | Context compaction **policy** — when to compact and with which model; the mechanics are `agent_loop::compaction`. Model for the summary call: the instance-wide Settings pick (`compaction_model`, a `PropertyType::LlmModel` config property declared by `compactor::config_set`) wins; else AUTO by `compaction.strength` (config.yml); a missing configured model degrades to the same AUTO path |
| `crates/skald-core/src/approval/` | Approval rules engine |
| `crates/skald-core/src/clarification/` | `ClarificationManager`: background-session question/answer |
| `crates/skald-core/src/elicitation/` | `ElicitationManager` + bridge: MCP server-initiated input (`elicitation/create`), surfaced in the Inbox; secrets never logged/persisted |
@@ -114,9 +116,9 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land
**Memory namespace (blueprint §5).** `memory_docs` (accessor `db/memory_docs.rs``get`/`upsert`/`list`/`search`(FTS)/`delete`) backs a virtual note store surfaced through the fs-tools, **not** the disk. Two sibling roots (not the blueprint's nested `memory/{userid}` + `memory/shared`): `user-memory/…` routes to the caller's own pool (`ToolContext::pool`), `shared-memory/…` to the system pool (a singleton captured in `fs::register_all`). `tools/fs/classify_memory()` decides on the raw first path component (a `..` in the tail clamps inside the store, never escapes to disk); `read_file`/`write_file`/`list_files`/`edit_file`/`insert_at_line`/`replace_lines`/`search_file` override `run_with` to route memory paths (each extracting a pure transform shared with its on-disk `execute`) and leave every other path on disk. The HTTP surface routes them the same way: `GET /api/file` classifies **before** `resolve_view_path` and serves the note from `memory_docs` (caller's pool / system pool), so the file viewer opens `user-memory/…` and `shared-memory/…` like any file, and `show_file_to_user` accepts memory paths too (existence-checked on the right pool). Approval (seeded in `seed_fs_path_rules`): `user-memory/*` is `@fs_any allow` (private, frictionless); `shared-memory/*` is `@fs_read allow` + `@fs_write require` — reads free, **writes need approval** so the agent can't silently push one person's data into shared memory. `grep_files` stays disk-only (regex-across-tree ≠ FTS); ranked full-text recall over notes is a separate tool, `memory_search` (`tools/fs/memory_search.rs`), over the `memory_docs` FTS index — allowed by a path-less rule (it takes `query`, not `path`).
**Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager`handler → `MessageBuilder`. `assistant` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`.
**Memory injection into the prompt**: `AgentSystemContext::load_inject_memory` (`loop_adapters/system.rs`) routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager``UserLoopRuntime``AgentSystemContext`. `assistant` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`.
**Prompt substitutions**: an `AGENT.md` may carry `<!-- KEY -->` placeholders; `agents::resolve_includes` turns each into a `__KEY__` sentinel, replaced at request time. Two are **builder-side**`MessageBuilder` resolves them itself from the session owner (`user_id`) + registry (`shared_pool`), so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: `__SHARED_FOLDERS__` (the user's shared-folders table) and `__USER_PROFILE__` (the owner's directory profile: `Name`, `Date of birth` with age computed at build time, `Sex`, `Preferred language`, admin `Notes` — unset values render as explicit `unknown` / `not specified`, the `Notes` line is omitted when empty). Any other key comes from the per-call `SendMessageOptions::system_substitutions` map.
**Prompt substitutions**: an `AGENT.md` may carry `<!-- KEY -->` placeholders; `agents::resolve_includes` turns each into a `__KEY__` sentinel, replaced at request time. Two are resolved by the system-context source itself (`loop_adapters/system.rs`) from the session owner (`user_id`) + registry (`shared_pool`), so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: `__SHARED_FOLDERS__` (the user's shared-folders table) and `__USER_PROFILE__` (the owner's directory profile: `Name`, `Date of birth` with age computed at build time, `Sex`, `Preferred language`, admin `Notes` — unset values render as explicit `unknown` / `not specified`, the `Notes` line is omitted when empty). Any other key comes from the per-call `SendMessageOptions::system_substitutions` map.
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and the `mcp_events` lifecycle log (`SecretsStore` and the global `McpManager` are built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets (plus the residual global `mcp_events` log), not on call-site migration.
@@ -197,7 +199,7 @@ For a per-user connector whose credential is produced by **pairing** (`auth.type
Uploads go through **one centralized seam**`ChatHub::save_upload` (behind `ChatHubApi::save_upload`, backed by `skald_core::uploads::save_to_home`) — so every surface persists identically and no two callers can drift on placement (the class of bug where the agent was handed a path it couldn't reach). The seam writes into the **caller's container home** under `uploads/{session_id}/` (agent path `uploads/{session}/{name}`, the `UPLOADS_SUBDIR` const in `core-api/user_fs.rs`), collision-dedupes the name, and prefers the sniffed magic-byte MIME over the client claim. The **web** handler (`POST /api/{source}/uploads`) buffers each field with a 256 MiB cap then calls the seam; the **Telegram** plugin downloads bytes then calls the same seam via `handle.chat_hub().save_upload("telegram", …)`. Because the file lands in the home (bind-mounted at `/root`), it is reachable by the fs-tools, `execute_cmd`, and the file viewer (`GET /api/file`, per-user via `resolve_view_path`) — there is **no** `/data` static route anymore (removed: it was `require_auth`-only, not ownership-scoped, and also exposed internal server state under `data/`). Attachment metadata travels as structured JSON in `chat_history.metadata` — never as persisted text.
At context-build time (`MessageBuilder`), attachments of the **current turn** (the user/agent rows following the last completed assistant reply, including across in-flight tool rounds) are partitioned by `session/handler/media.rs`: when the resolved model's `LlmEntry.capabilities` include the modality (`vision``image_url` parts, `video``video_url` parts), the file is inlined as a base64 data-URL content part — but only if it resolves (through the caller's `UserFs`, via `resolve_host_path`) under the home's `uploads/` dir, its sniffed MIME is in the allowlist, and it fits the budgets (4 files / 10 MiB image / 32 MiB video / 48 MiB total per turn). Everything else — older turns, other kinds, any failed check — keeps the textual `[SYSTEM INFO]` path block, so a non-vision model produces a byte-identical payload to before. `OpenAiClient` forwards parts verbatim; `AnthropicClient` translates `image_url` data URLs to `image` blocks (video unsupported; Anthropic models get `vision` by editing the model row's capabilities — no catalog refresh writes them). On LLM fallback mid-round, messages are rebuilt with the replacement model's capabilities.
At context-build time (the crate's projection), attachments of the **current turn** (the user/agent rows following the last completed assistant reply, including across in-flight tool rounds) are partitioned by `agent_loop::projection::media`, with `loop_adapters/media_source.rs` deciding **which** files may be handed over (§6 containment): when the resolved model's `LlmEntry.capabilities` include the modality (`vision``image_url` parts, `video``video_url` parts), the file is inlined as a base64 data-URL content part — but only if it resolves (through the caller's `UserFs`, via `resolve_host_path`) under the home's `uploads/` dir, its sniffed MIME is in the allowlist, and it fits the budgets (4 files / 10 MiB image / 32 MiB video / 48 MiB total per turn). Everything else — older turns, other kinds, any failed check — keeps the textual `[SYSTEM INFO]` path block, so a non-vision model produces a byte-identical payload to before. `OpenAiClient` forwards parts verbatim; `AnthropicClient` translates `image_url` data URLs to `image` blocks (video unsupported; Anthropic models get `vision` by editing the model row's capabilities — no catalog refresh writes them). On LLM fallback mid-round, messages are rebuilt with the replacement model's capabilities.
## Token streaming & reasoning display
@@ -209,32 +211,61 @@ The chat streams tokens live, as a **parallel best-effort side-channel** that ne
- **Reasoning surfacing**: `reasoning_content` rides `Done`/`Thinking` events (so buffered providers show it live too) and is projected as `reasoning` on assistant/thinking history items (`build_items`); persistence in `chat_history.reasoning_content` and the echo back into context predate this feature.
- **Frontend** (`chat-session.js` + `copilot-render.js`, shared by desktop copilot and mobile chat-page): `token_delta` accumulates into a pending assistant bubble (in-place mutation + ~15 Hz flush, blinking caret); `done`/`thinking` finalize it in place, `error`/`llm_failed`/`model_fallback` drop it, `tool_start`/`agent_done` finalize orphan bubbles (reasoning-only rounds, sub-agent final rounds that emit no `Done`). The reasoning block is a muted, collapsed-by-default native `<details>` (`renderReasoning`, `.reasoning-block` in `copilot-messages.css`, i18n key `chat.reasoning`) — open state survives re-renders, and it renders identically from live events and from history.
## Sub-agent system
- Synchronous sub-agents (`execute_task` mode=sync / `execute_subtask`) are **not** plain `Tool`s — they are intercepted in `run_agent_turn` before registry dispatch.
- `dispatch_sub_agent` (in `agent_dispatch.rs`) creates a child `chat_sessions_stack` row and runs `run_agent_turn` **recursively in the same task**, holding the same `processing` lock and sharing the same cancellation token. The child's result string becomes the parent tool call's result (completion lives in one place — the `run_agent_turn` tool-result match); then it terminates the child frame. There is no task-spawn / `WaitingChild` / resume cascade for the sync path.
- Max recursion depth: `MAX_AGENT_DEPTH = 5`.
- **Parallel batches:** when a single assistant response emits **≥2** sync sub-agent calls and *nothing else*, `run_agent_turn` fans them out concurrently via `handle_sub_agent_batch` (bounded by `max_parallel_subagents`, default `4`). Ordering is preserved by allocating every `chat_llm_tools` row up front in call order (the LLM reconstructs results by row id), then recording outcomes back in call order; only the middle dispatch is concurrent. Any other shape (a lone call, or a mix with regular tools) keeps the strictly sequential `handle_tool_call` loop — the two paths share the same lower-level seams. Siblings share the session's scratchpad blackboard (session-keyed): concurrent writes to the *same* key are last-writer-wins by design.
- **Restart recovery of a parallel batch** is intentionally lossy (single-user app): `resume_turn` first calls `reap_interrupted_parallel_batches`, which detects a batch by ≥2 active `chat_sessions_stack` frames at the same depth (impossible for a linear stack), fails their spawning tool calls and terminates the frames, then lets the normal linear cascade resume the parent. A lone interrupted sub-agent is untouched and still recovers via the cascade.
- Client resolution order: `args.client``meta.json client` → AUTO selection by strength.
- **The parent's resolved client is NOT inherited.** Passing a concrete model name to `resolve()` bypasses AUTO selection; sub-agents always auto-select unless overridden explicitly.
- `list_agents` is a plain tool; returns JSON of **task** agents only (excludes `chat`/`system` agents like the `assistant` entry agent).
- `resume_turn` (+ its cascade) is kept only for: app-restart recovery of an active child stack, async task result injection (`inject_async_result`), and the WS resume message — not for the normal sync dispatch.
- **The cascade runs each frame with ITS OWN agent's config, not the session root's.** `resume_turn` builds the root config from `self.agent_id`, but for any non-root frame (deepest seed + each parent it walks up) it derives a per-frame config via `build_recovery_frame_config``build_sub_agent_config` (keyed on `frame.agent_id`), so a resumed sub-agent runs with its own prompt/tools/client — not the root's (it would otherwise resume e.g. a `researcher` as the `assistant`). `build_sub_agent_config` is the **single** source of a sub-agent's config, shared by live `dispatch_sub_agent` and this recovery path so they can't drift; the per-dispatch `client` override isn't persisted, so recovery re-resolves the model from the frame's agent meta.
## The LLM loop (`agent-loop`)
The loop is a **standalone crate** (`crates/agent-loop/`) that knows nothing about Skald: it owns control flow (rounds, model fallback, tool fan-out, recording), the projection of history into wire messages, sub-agent delegation, restart recovery and compaction. Skald supplies content through the traits in `crates/skald-core/src/loop_adapters/`. Nothing in `session/handler/` shapes a `Value` anymore — there is exactly **one** projection in the workspace.
**One `LoopManager` per user** (`UserLoopRuntime`, `loop_adapters/runtime.rs`, blueprint D12), built by `ChatSessionManager`: it owns the event bus, the live-loop registry (which conversations are running, `/stop`, recovery, shutdown), the store, the approval gate, the hooks, the agent catalog and the delegate tool. A turn contributes only what is its own — the agent's prompt, its tool set, its model pin — via `turn_params`.
**Per-turn state rides the `Extensions` type-map** (`loop_adapters/scope.rs::TurnScope`): the gate and the catalog live as long as the user, so they cannot capture a session id or a permission group — they read the turn's scope from the call's extensions. **A call with no scope is denied**, never run with permissive defaults.
Three entry points, all in `session/handler/kernel_turn.rs`:
| entry | when | what it does |
| ---- | ---- | ---- |
| `run_kernel_turn` | a user message | repairs a dangling call from a crashed turn, then `manager.start_turn` |
| `recover_turn` | WS connect, async result delivery, background wake-up | `Recovery::run` — no new message, continue what was interrupted |
| `resolve_pending_call` | an approval answered after a restart | run the call with the gate skipped, then continue |
The event **translator** (`loop_adapters/translate.rs`) is the ONE bus subscriber turning `LoopEvent`s into the session's `ServerEvent`s; byte-parity with the pre-kernel event sequence is its contract.
### Sub-agents
- A sub-agent is a **tool**, not an interception: `DelegateTool` (registered under the legacy names `execute_task` / `execute_subtask`, D11, each keeping its exact legacy schema) opens a child frame and runs a normal loop in it. The parent simply awaits a slow tool call. Max depth `MAX_AGENT_DEPTH = 5`.
- **Parallel batches are the kernel's generic fan-out**: a round whose calls are all `concurrency_safe` (a sync delegate is) runs concurrently, bounded by `max_parallel_calls`. The ordering invariant is unchanged — ids allocated in call order (phase 1) → concurrent execution (phase 2) → recording in call order (phase 3) — so the model reconstructs results by id. Any mixed batch stays sequential. Siblings share the session scratchpad; concurrent writes to the same key are last-writer-wins by design.
- `mode: "async"` submits a durable `scheduled_jobs` row through `loop_adapters/async_task.rs::CronExecutor` and returns a receipt immediately; when the job finishes, `DurableSink` writes the result into the parent conversation (synthetic assistant + a completed `task_completed` call) and resumes it. `mode: "cron"` is scheduling, not delegation, and stays on the cron interface tool.
- A child's model is **never inherited** from the parent: passing a concrete name would bypass AUTO selection, so sub-agents auto-select unless explicitly overridden (`args.client``meta.json client` → AUTO by strength).
- `list_agents` returns **task** agents only (never `chat`/`system` ones like the entry agent).
### Restart recovery (`agent_loop::recovery`)
A crash loses RAM (the approval oneshot, the cancellation token), never truth: every state transition is a store write. So recovery does not have a mode of its own — it makes the history well-formed and then runs a **normal loop** on it:
1. **Reap** an interrupted parallel batch (≥2 active frames at one depth is impossible for a linear stack): fail their spawning calls, close the frames. Deliberately lossy.
2. **Resolve** the deepest frame's non-terminal calls. A `Running` one is re-gated and re-executed **unless the tool says otherwise**`execute_cmd` declares `RestartHint::MarkInterrupted` (D7), because a command may already have had its effect. An `AwaitingHuman` one is re-asked (the card reappears).
3. **Un-wedge**: a child that finished but whose result never reached its parent propagates without calling the model again.
4. **Cascade** to the root, resolving each parent call with its child's result — every frame running as **its own** agent, from the catalog, never the root's (B3).
`Cancelled` and `Rejected` are terminal and are never re-executed. Anti-double-driving goes through the manager's registry (a recovery claims the conversation like a live turn), not a host-side flag.
## Cancellation (stop)
- Each turn has a `CancellationToken` (`tokio_util`). `handle_message` mints a fresh one per user message and stores it in `current_cancel`; `resume_turn` mints one per resume. A **clone is threaded by value** through the whole (recursive) call tree — never re-read from the field mid-turn — so a `/stop` is **sticky** across sub-agent recursion.
- `cancel()` cancels the stored token. It is checked at each round boundary and before each tool call, wrapped around the in-flight LLM call (`tokio::select!`, aborting the request), and wrapped around `execute_cmd` (drops the future → `kill_on_drop` kills the shell process). Parent and child share the token, so a cancelled child stops the parent by construction.
- The turn's `CancellationToken` is minted by `LoopManager::start_turn` and **cloned by value** down the whole call tree; a delegate passes `ctx.cancel.child_token()`. It is never re-read from a field mid-turn, which is what makes `/stop` **sticky** across sub-agent recursion.
- `ChatSessionHandler::cancel()``manager.cancel(&conversation)`. The token is checked at each round boundary and before each tool call, wrapped around the in-flight LLM call (`tokio::select!`, aborting the request), and around `execute_cmd` (dropping the future → `kill_on_drop`). Parent and child share the tree, so a cancelled child stops the parent by construction.
## Compaction
`agent_loop::compaction` owns the mechanics: split point (never between an assistant turn and its tool results), transcript, prompt (`SUMMARY_PREFIX` / preamble / template live there now), the single no-tools model call, the saved summary row. `skald-core/src/compactor.rs` owns the **policy**: the token threshold, the ephemeral guard, which model summarises (`compaction_model` from Settings, else AUTO by `compaction.strength`), and publishing `CompactionEvent` on the chat bus. The DTL re-anchor is the `on_compacted` hook (`loop_adapters/hooks.rs::DtlReanchorHook`). The next turn needs nothing: the assembler reads the latest summary from the store.
## Approval gate
The rule engine `ApprovalManager::check` returns `Allow`/`Deny`/`Require` per tool call (default rules seeded on first boot; the catch-all `* require @999999` gates anything not explicitly allowed — e.g. `execute_cmd`, `execute_task`, writes outside whitelisted paths). A `Require` registers a `oneshot` in the in-memory `pending` map keyed by `request_id` and emits an approval event over WS.
The rule engine `ApprovalManager::check` returns `Allow`/`Deny`/`Require` per tool call (default rules seeded on first boot; the catch-all `* require @999999` gates anything not explicitly allowed — e.g. `execute_cmd`, `execute_task`, writes outside whitelisted paths). It is wired to the loop as `loop_adapters/gate.rs::ApprovalGate` (`agent_loop::gate::Gate`). A `Require` registers a `oneshot` in the in-memory `pending` map keyed by `request_id` and emits an approval event over WS.
Resolution is **source-agnostic**: the WS + Inbox paths resolve by `request_id`; the inline chat card resolves by the durable `tool_call_id` via `POST /api/tools/:tool_call_id/resolve` (`resolve_tool` in `src/frontend/api/sessions.rs`), which derives the owning session from the tool call's own stack row — never a hardcoded source. Live pending cards fire the `oneshot`; post-restart a simple tool runs directly on the owning session via `ChatSessionHandler::execute_tool`, which now goes through the **same canonical path as the live loop**`build_execution` (owner pool + per-user container `ToolContext`) driven by `drive_execution` — so a resolved `write_file`/`execute_cmd` acts on the user's workspace/container, never the server cwd/host (was a §6 escape; sub-agent tools are still handled by their own branch earlier in `resolve_tool`).
Resolution is **source-agnostic**: the WS + Inbox paths resolve by `request_id`; the inline chat card resolves by the durable `tool_call_id` via `POST /api/tools/:tool_call_id/resolve` (`resolve_tool` in `src/frontend/api/sessions.rs`), which derives the owning session from the tool call's own stack row — never a hardcoded source. Live pending cards fire the `oneshot`. Post-restart there is **one** path for every tool, `LoopManager::resolve_pending`: the call runs with the gate skipped (the human just decided) but with the session's real `ToolContext` owner pool, per-user container — so a resolved `write_file`/`execute_cmd` acts on the user's workspace, never the server cwd/host (this was a §6 escape); then the conversation continues, including a sub-agent dispatch, which simply opens its child frame like any other call. The endpoint returns as soon as the work is scheduled and the result streams over the bus.
The **diff preview** in a `PendingWrite` event (`handler/approval.rs::read_current_content`) routes exactly like the fs-tools: `user-memory/`/`shared-memory/``memory_docs` on the right pool, every other agent path → the caller's host workspace via `resolve_host_path(&self.fs, …)`. It must never use the cwd-relative `fs::resolve` — that showed a bogus "new file" on overwrites (or the diff of a same-named cwd file), so the user would approve the wrong diff.
The **diff preview** in a `PendingWrite` event (`loop_adapters/preview.rs::read_current_content`, driven by the `SkaldWritePreviewHook`) routes exactly like the fs-tools: `user-memory/`/`shared-memory/``memory_docs` on the right pool, every other agent path → the caller's host workspace via `resolve_host_path(&self.fs, …)`. It must never use the cwd-relative `fs::resolve` — that showed a bogus "new file" on overwrites (or the diff of a same-named cwd file), so the user would approve the wrong diff.
**Tool visibility in the Security-groups UI** (`GET /api/approval/tools`): tools injected outside the `ToolRegistry` (interface/plugin/provider tools) would otherwise be un-configurable. `ToolCatalog::list_all()` covers registry tools + a static `synthetic_tools()` list of core interface tools; everything else is captured by `crates/skald-core/src/tool_discovery.rs` (`ToolDiscovery`), which taps `all_tool_defs()` in `llm_loop.rs` each round and upserts every offered tool into the `known_tools` table (in-memory seen-set guard → background DB write). `list_tools` merges `known_tools` (deduped, `category: "dynamic"`) so any tool offered at least once becomes gate-able. Drift-proof by construction; core never hardcodes plugin tool names.
**Tool visibility in the Security-groups UI** (`GET /api/approval/tools`): tools injected outside the `ToolRegistry` (interface/plugin/provider tools) would otherwise be un-configurable. `ToolCatalog::list_all()` covers registry tools + a static `synthetic_tools()` list of core interface tools; everything else is captured by `crates/skald-core/src/tool_discovery.rs` (`ToolDiscovery`), which taps the tool set the loop offers each round (`SkaldToolSet::defs`) and upserts every offered tool into the `known_tools` table (in-memory seen-set guard → background DB write). `list_tools` merges `known_tools` (deduped, `category: "dynamic"`) so any tool offered at least once becomes gate-able. Drift-proof by construction; core never hardcodes plugin tool names.
## Restart
@@ -292,7 +323,7 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/
**Theme** (`web/css/variables.css`): warm "paper" palette (terracotta accent, light by default, warm-charcoal dark), generous radius (`--radius-sm/md/lg`), 16px-base chat type, WCAG-fixed contrasts, global `:focus-visible` ring and `prefers-reduced-motion` support. Everything consumes CSS variables — never hardcode a hex in a component stylesheet.
**i18n** (`web/lib/i18n.js` + `web/i18n/{en,it,fr}.js`): `t(key)` helper, `I18nMixin` re-renders on `locale-changed`. Resolution order: user preference (`users.locale`, editable on the profile page) → instance default (registry config key `ui_locale`, editable by the admin in Settings — declared in `skald_core::i18n::config_set`) → English. **Server-side, never re-implement that chain**: `skald_core::i18n::resolve_locale(pool, user_locale)` is the one function (with `default_locale(pool)` and `language_name(locale)` for prompt rendering); they read through `db::config` because the bus only matters for writes and callers like `MessageBuilder` hold pools, not the manager. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes `ui_locale` via `skald_core::i18n::set_default_locale` (no system bus exists there), the web setup page sends `locale` to `POST /api/setup/user`, which writes it through `GlobalConfigManager::set`. Supported locales are centralized in `skald_core::i18n::SUPPORTED_LOCALES` and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1).
**i18n** (`web/lib/i18n.js` + `web/i18n/{en,it,fr}.js`): `t(key)` helper, `I18nMixin` re-renders on `locale-changed`. Resolution order: user preference (`users.locale`, editable on the profile page) → instance default (registry config key `ui_locale`, editable by the admin in Settings — declared in `skald_core::i18n::config_set`) → English. **Server-side, never re-implement that chain**: `skald_core::i18n::resolve_locale(pool, user_locale)` is the one function (with `default_locale(pool)` and `language_name(locale)` for prompt rendering); they read through `db::config` because the bus only matters for writes and callers like the system-context source hold pools, not the manager. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes `ui_locale` via `skald_core::i18n::set_default_locale` (no system bus exists there), the web setup page sends `locale` to `POST /api/setup/user`, which writes it through `GlobalConfigManager::set`. Supported locales are centralized in `skald_core::i18n::SUPPORTED_LOCALES` and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1).
**Plugin & backend i18n** — two seams, both keyed the same way. A plugin **page fragment** (served from its own router) localizes client-side: it ships a `web/i18n.js` module (`export default { en, it, fr }`, keys namespaced `plugin.<id>.<key>`) and calls `addStrings(dicts)` (in `web/lib/i18n.js`) once at module load to merge into the host's shared `DICTS`, then uses the same `t()`/`I18nMixin` as the app (the fragment imports them from the absolute `/lib/i18n.js` — the *same* module instance the host uses, so `t()` and `locale-changed` are shared; no endpoint, no per-locale fetch — all locales ride in the fragment, so a language switch is instant). Mobile-connector is the reference: `common.js` registers the dict + re-exports `t`, and `MobileBase extends I18nMixin(LitElement)`. **Backend-generated strings** (a plugin's HTTP error/response text, notifications) go through `core_api::i18n`: a plugin declares `Plugin::i18n() -> Vec<LocaleBundle>` (mobile-connector loads them from embedded `i18n/{en,it,fr}.json` via `include_str!`), the `PluginManager` merges every plugin's bundles once at boot into an `I18nCatalog` (`skald_core::i18n`) and injects it as `PluginContext.i18n: Arc<dyn I18nApi>`. At request time the handler resolves the caller (`Caller.user_id` from the auth layer) and calls `i18n.for_user(user_id, key, args).await` — which reads `users.locale`, runs it through the same `resolve_locale` chain, and renders `locale → en → key` with `{name}` placeholders. The frontend surfaces these already-translated: `jf()` throws the server's response text verbatim. Front and back keep **separate** tables (UI labels ≠ error strings; overlap is minimal) but share the `plugin.<id>.` namespace convention. The mechanism is general (any plugin, and eventually the core, registers the same way); only mobile-connector uses it so far.
Generated
+22 -17
View File
@@ -43,6 +43,23 @@ dependencies = [
"subtle",
]
[[package]]
name = "agent-loop"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"base64",
"futures",
"futures-util",
"reqwest 0.13.4",
"serde",
"serde_json",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -583,6 +600,7 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
name = "core-api"
version = "0.1.0"
dependencies = [
"agent-loop",
"anyhow",
"async-trait",
"axum",
@@ -1587,9 +1605,11 @@ checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
name = "honcho-client"
version = "0.1.0"
dependencies = [
"anyhow",
"reqwest 0.13.4",
"serde",
"serde_json",
"tokio",
"tracing",
]
@@ -2182,21 +2202,6 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "llm-client"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"core-api",
"futures-util",
"reqwest 0.13.4",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -4182,7 +4187,6 @@ dependencies = [
"futures",
"honcho-client",
"indexmap 2.14.0",
"llm-client",
"mcp-client",
"notify",
"plugin-comfyui",
@@ -4216,6 +4220,7 @@ name = "skald-core"
version = "0.1.0"
dependencies = [
"aes-gcm",
"agent-loop",
"anyhow",
"argon2",
"async-trait",
@@ -4232,7 +4237,6 @@ dependencies = [
"indexmap 2.14.0",
"libc",
"libsqlite3-sys",
"llm-client",
"mcp-client",
"notify",
"os_info",
@@ -4241,6 +4245,7 @@ dependencies = [
"rand 0.10.1",
"regex",
"reqwest 0.13.4",
"rustls",
"serde",
"serde_json",
"serde_yaml",
+1 -2
View File
@@ -1,10 +1,10 @@
[workspace]
members = [
".",
"crates/agent-loop",
"crates/skald-core",
"crates/skald-setup",
"crates/honcho-client",
"crates/llm-client",
"crates/core-api",
"crates/mcp-client",
"crates/plugin-tailscale-remote",
@@ -73,7 +73,6 @@ tracing-appender = "0.2"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
notify = "8"
honcho-client = { path = "crates/honcho-client" }
llm-client = { path = "crates/llm-client" }
core-api = { path = "crates/core-api" }
mcp-client = { path = "crates/mcp-client" }
plugin-tailscale-remote = { path = "crates/plugin-tailscale-remote" }
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "agent-loop"
version = "0.1.0"
edition = "2024"
description = "Reusable LLM agent loop kernel: round loop, tool calling, fallback, streaming, durability traits — no database, no host types."
license = "MIT"
[dependencies]
tokio = { version = "1", features = ["sync", "rt", "time", "macros"] }
tokio-util = { version = "0.7" }
async-trait = "0.1"
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
anyhow = "1"
futures = "0.3"
futures-util = "0.3"
reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy", "json", "stream"] }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+124
View File
@@ -0,0 +1,124 @@
//! Dynamic tool loading (DTL) — the wire PROTOCOL lives in the crate
//! (blueprint D15), the catalog and persistence stay with the host.
//!
//! Three rendering modes ([`ToolRendering`]) decide how dynamically-activated
//! tools reach the model without invalidating the prompt-cache prefix:
//!
//! - `Inline`: active tools go in the `tools` array (every activation changes
//! the array — no cache).
//! - `DeferredToolReference`: all activatable tools are declared upfront with
//! `defer_loading: true`; an activation's tool result carries a
//! `_tool_references` marker the Anthropic client converts to
//! `tool_reference` blocks.
//! - `SystemToolBlock`: activated tools never touch the `tools` array; a
//! `{role:"system", tools:[…]}` message is appended after the activation's
//! tool-result group (Kimi/Moonshot speaks this natively).
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::ids::MessageId;
use crate::tool::{Tool, ToolCtx, ToolFailure, ToolOutput};
/// How dynamically-activated tools are rendered on the wire. On
/// [`crate::model::ModelInfo`]; read by `ToolSet::defs` and assemblers,
/// consumed by the shipped clients.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToolRendering {
/// Only the currently-active tools in the `tools` array.
#[default]
Inline,
/// Anthropic: all activatable tools `defer_loading: true` + tool_reference
/// blocks in activation results.
DeferredToolReference,
/// Kimi K3: `{role:"system", tools:[defs]}` appended after the activation
/// (append-only, cache-safe).
SystemToolBlock,
}
/// One activation: the defs of the groups activated at a given anchor message.
#[derive(Debug, Clone)]
pub struct Activation {
pub anchor: MessageId,
/// OpenAI-shaped tool defs of the groups activated at `anchor`.
pub defs: Vec<Value>,
}
/// Catalog + persistence of activations — implemented by the host. Consulted
/// by assemblers (injection) and by host `ToolSet`s (array rendering).
#[async_trait]
pub trait ActivationSource: Send + Sync {
/// The activations in force for a frame, ordered by anchor.
async fn activations(&self, frame: crate::ids::FrameId) -> crate::Result<Vec<Activation>>;
}
/// Backend of the shipped [`ActivateToolsTool`]: validates the groups, mutates
/// the grants, persists the activation (anchored at the current message via
/// `ctx`). Returns the confirmation text shown to the model.
#[async_trait]
pub trait ToolActivator: Send + Sync {
async fn activate(&self, groups: Vec<String>, ctx: &ToolCtx) -> Result<String, ToolFailure>;
}
/// The shipped `activate_tools` tool. To the kernel it's a tool like any
/// other — the defs re-read at the next round makes the new grants visible.
pub struct ActivateToolsTool {
activator: Arc<dyn ToolActivator>,
definition_override: Option<Value>,
}
impl ActivateToolsTool {
pub fn new(activator: Arc<dyn ToolActivator>) -> Self {
Self { activator, definition_override: None }
}
/// Override the advertised definition (legacy parity).
pub fn with_definition(mut self, def: Value) -> Self {
self.definition_override = Some(def);
self
}
}
#[async_trait]
impl Tool for ActivateToolsTool {
fn name(&self) -> &str { "activate_tools" }
fn definition(&self) -> Value {
if let Some(def) = &self.definition_override {
return def.clone();
}
json!({
"type": "function",
"function": {
"name": "activate_tools",
"description": "Load additional tool groups on demand. Activated tools \
become available from the next step of this conversation.",
"parameters": {
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": { "type": "string" },
"description": "Names of the tool groups to activate"
}
},
"required": ["groups"]
}
}
})
}
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
let groups: Vec<String> = args["groups"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
if groups.is_empty() {
return Err(ToolFailure::Failed("activate_tools: no groups given".into()));
}
let text = self.activator.activate(groups, ctx).await?;
Ok(ToolOutput::Text(text))
}
}
+546
View File
@@ -0,0 +1,546 @@
//! Compaction (blueprint §9, D6) — summarising the old part of a frame's
//! history so the context stops growing.
//!
//! It is **not a turn**: one model call, no tools, no rounds, no kernel. That
//! is the whole reason it is its own component — a host can compact a
//! conversation nothing is driving, and the loop never learns it happened.
//!
//! The result is a row, not a return value: the next loop reads
//! `latest_summary` through the assembler and projects
//! `system → summary → messages after covered_up_to`. Callers get a
//! [`CompactionOutcome`] for telemetry, not for threading anywhere.
//!
//! What the host still owns: **when** (see [`should_compact`]), which model,
//! and what to do afterwards ([`LoopHooks::on_compacted`] — re-anchoring
//! anything pinned to a message that just went away).
use std::sync::Arc;
use serde_json::{Value, json};
use tracing::{debug, info, warn};
use crate::events::{EventSink, LoopEvent};
use crate::hooks::LoopHooks;
use crate::ids::{ConversationId, FrameId, MessageId, SummaryId};
use crate::model::{ModelHint, ModelRequest, ModelResponse, ModelSelector, Usage};
use crate::store::{CallState, HistoryStore, NewSummary, Role, StoredMessage};
// ── The shipped prompt ───────────────────────────────────────────────────────
/// Prepended to the stored summary when it is projected back into the context.
/// It tells the model this is a handoff from a previous context window, not a
/// set of live instructions — without it, a model happily re-answers questions
/// the summary merely *mentions*.
pub const SUMMARY_PREFIX: &str = "\
[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted \
into the summary below. This is a handoff from a previous context \
window — treat it as background reference, NOT as active instructions. \
Do NOT answer questions or fulfill requests mentioned in this summary; \
they were already addressed. \
Your current task is identified in the '## Active Task' section of the \
summary — resume exactly from there. \
Your system prompt and any injected memory files are ALWAYS authoritative \
— never deprioritize them due to this compaction note. \
Respond ONLY to the latest user message that appears AFTER this summary. \
The current session state (files, config, etc.) may reflect work \
described here — avoid repeating it:";
/// Preamble shared by the first-compaction and the update prompts. The wording
/// is deliberately plain: a summariser is the one call most likely to trip a
/// content filter, since it restates whatever the conversation contained.
pub const SUMMARIZER_PREAMBLE: &str = "\
You are a summarization agent creating a context checkpoint. \
Treat the conversation turns below as source material for a \
compact record of prior work. \
Produce only the structured summary; do not add a greeting, \
preamble, or prefix. \
Write the summary in the same language the user was using in the \
conversation — do not translate or switch to English. \
NEVER include API keys, tokens, passwords, secrets, credentials, \
or connection strings in the summary — replace any that appear \
with [REDACTED]. Note that the user may have had credentials present, \
but do not preserve their values.";
/// The sections the summariser must fill in. Structure beats prose here: the
/// next context window is resumed from `## Active Task`, so that field is
/// worth more than everything else combined.
pub const SUMMARY_TEMPLATE: &str = "\
## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or \
task assignment verbatim — the exact words they used. If multiple tasks \
were requested and only some are done, list only the ones NOT yet completed. \
Continuation should pick up exactly here. Example: \
\"User asked: 'Now refactor the auth module to use JWT instead of sessions'\" \
If no outstanding task exists, write \"None.\"]
## Goal
[What the user is trying to accomplish overall]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
## Completed Actions
[Numbered list of concrete actions taken — include tool used, target, and outcome.
Format each as: N. ACTION target — outcome [tool: name]
Example:
1. READ config.rs:45 — found == should be != [tool: read_file]
2. EDIT config.rs:45 — changed == to != [tool: write_file]
3. BUILD `cargo build` — succeeded, 0 errors [tool: execute_cmd]
Be specific with file paths, commands, line numbers, and results.]
## Active State
[Current working state — include:
- Working directory and branch (if applicable)
- Modified/created files with brief note on each
- Build/test status
- Any running processes or servers
- Environment details that matter]
## In Progress
[Work currently underway — what was being done when compaction fired]
## Blocked
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
## Key Decisions
[Important technical decisions and WHY they were made]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write \"None.\"]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Remaining Work
[What remains to be done — framed as context, not instructions]
## Critical Context
[Any specific values, error messages, configuration details, or data that would \
be lost without explicit preservation. NEVER include API keys, tokens, passwords, \
or credentials — write [REDACTED] instead.]
Write only the summary body. Do not include any preamble or prefix.";
/// How the summariser is asked. Override to change the wording or the sections
/// without touching the mechanics.
pub trait CompactionPrompt: Send + Sync {
/// The single user message sent to the summariser. `prior` is the previous
/// summary's body (without [`SUMMARY_PREFIX`]) when this is an update, so
/// summaries never nest.
fn build(&self, transcript: &str, prior: Option<&str>) -> String;
}
/// The shipped prompt: preamble + transcript + template, in an update or a
/// first-time shape.
pub struct DefaultPrompt;
impl CompactionPrompt for DefaultPrompt {
fn build(&self, transcript: &str, prior: Option<&str>) -> String {
match prior {
Some(prev) => format!(
"{SUMMARIZER_PREAMBLE}\n\n\
You are updating a context compaction summary. A previous compaction produced \
the summary below. New conversation turns have occurred since then and need \
to be incorporated.\n\n\
PREVIOUS SUMMARY:\n{prev}\n\n\
NEW TURNS TO INCORPORATE:\n{transcript}\n\n\
Update the summary using this exact structure. PRESERVE all existing information \
that is still relevant. ADD new completed actions to the numbered list (continue \
numbering). Move items from \"In Progress\" to \"Completed Actions\" when done. \
Move answered questions to \"Resolved Questions\". Update \"Active State\" to \
reflect current state. Remove information only if it is clearly obsolete. \
CRITICAL: Update \"## Active Task\" to reflect the user's most recent unfulfilled \
request — this is the most important field for task continuity.\n\n\
{SUMMARY_TEMPLATE}"
),
None => format!(
"{SUMMARIZER_PREAMBLE}\n\n\
Create a structured checkpoint summary for the conversation after earlier turns \
are compacted. The summary should preserve enough detail for continuity without \
re-reading the original turns.\n\n\
TURNS TO SUMMARIZE:\n{transcript}\n\n\
Use this exact structure:\n\n\
{SUMMARY_TEMPLATE}"
),
}
}
}
// ── Mode / outcome ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
pub enum CompactionMode {
/// Summarise everything except the last `keep_tail` messages, cutting on a
/// user/agent boundary so an assistant turn is never split from its tool
/// results.
Auto { keep_tail: usize },
/// Summarise up to an explicit message (a UI that lets the user pick).
UpTo(MessageId),
}
impl Default for CompactionMode {
fn default() -> Self {
Self::Auto { keep_tail: 6 }
}
}
#[derive(Debug, Clone)]
pub struct CompactionOutcome {
pub summary_id: SummaryId,
pub covered_up_to: MessageId,
/// The first message the summary does NOT cover — what anything pinned to
/// a compacted message must be re-anchored onto.
pub first_surviving: MessageId,
pub summary_text: String,
pub messages_covered: usize,
pub usage: Usage,
}
/// Is it time? `usage` is the previous turn's reported input tokens; when the
/// provider reported none, `estimated` (the host's own count) decides.
pub fn should_compact(usage: Option<u32>, estimated: u32, threshold: u32) -> bool {
usage.filter(|t| *t > 0).unwrap_or(estimated) >= threshold
}
// ── Compaction ───────────────────────────────────────────────────────────────
/// One compaction, ready to run. Built via
/// [`LoopManager::new_compaction`](crate::manager::LoopManager::new_compaction)
/// so it shares the manager's store, hooks and event bus.
pub struct Compaction {
pub(crate) store: Arc<dyn HistoryStore>,
pub(crate) selector: Arc<dyn ModelSelector>,
pub(crate) hooks: Vec<Arc<dyn LoopHooks>>,
pub(crate) events: EventSink,
pub(crate) conversation: ConversationId,
pub(crate) frame: FrameId,
pub(crate) mode: CompactionMode,
pub(crate) hint: ModelHint,
pub(crate) prompt: Arc<dyn CompactionPrompt>,
pub(crate) temperature: Option<f32>,
/// Host free-form, forwarded on the request (payload logging).
pub(crate) log: Option<Value>,
}
impl Compaction {
pub fn mode(mut self, mode: CompactionMode) -> Self {
self.mode = mode;
self
}
/// Pin the summariser's model. Default: whatever the selector picks.
pub fn model(mut self, hint: ModelHint) -> Self {
self.hint = hint;
self
}
/// Override the selector for this call (a cheaper tier, say).
pub fn selector(mut self, selector: Arc<dyn ModelSelector>) -> Self {
self.selector = selector;
self
}
pub fn prompt(mut self, prompt: Arc<dyn CompactionPrompt>) -> Self {
self.prompt = prompt;
self
}
pub fn log(mut self, log: Value) -> Self {
self.log = Some(log);
self
}
/// Summarise and save. `Ok(None)` means there was nothing worth compacting
/// — not an error: too few messages, no clean split point, or a summariser
/// that came back empty.
pub async fn run(&self) -> crate::Result<Option<CompactionOutcome>> {
let prior = self.store.latest_summary(self.frame).await?;
let messages = match &prior {
Some(s) => self.store.load_since(self.frame, s.covered_up_to).await?,
None => self.store.load(self.frame).await?,
};
let Some(split) = self.split_point(&messages) else {
debug!(frame = %self.frame, "compaction: nothing to summarise");
return Ok(None);
};
let (to_summarise, surviving) = messages.split_at(split);
let covered_up_to = to_summarise.last().expect("split > 0").id;
let first_surviving = surviving.first().expect("split < len").id;
let transcript = transcript(to_summarise);
let body = self.prompt.build(&transcript, prior.as_ref().map(|s| s.text.as_str()));
let handle = self.selector.select(&self.hint, &[]).await?;
info!(
frame = %self.frame,
model = %handle.id,
messages = to_summarise.len(),
"compaction: summarising"
);
let request = ModelRequest {
messages: vec![json!({ "role": "user", "content": body })],
tools: Vec::new(),
model: handle.id.clone(),
max_tokens: None,
temperature: self.temperature,
request_id: uuid_like(),
conversation: self.conversation.clone(),
frame: self.frame,
extras: handle.info.extras.clone(),
log: self.log.clone(),
};
let response = handle.model.complete(&request, None).await.map_err(|e| {
warn!(frame = %self.frame, error = %e, "compaction: the summariser failed");
anyhow::anyhow!("compaction: {e}")
})?;
let (summary_text, usage) = match response {
ModelResponse::Message { content, usage, .. } => (content, usage),
// A summariser has no tools; if one hallucinates a call, its text is
// still the summary.
ModelResponse::ToolCalls { content, usage, .. } => {
warn!(frame = %self.frame, "compaction: unexpected tool calls, using the content");
(content, usage)
}
};
if summary_text.trim().is_empty() {
warn!(frame = %self.frame, "compaction: empty summary, nothing saved");
return Ok(None);
}
let summary_id = self
.store
.save_summary(self.frame, NewSummary { text: summary_text.clone(), covered_up_to })
.await?;
self.events.emit(self.frame, None, LoopEvent::Compacted {
frame: self.frame,
covered_up_to,
});
for h in &self.hooks {
h.on_compacted(self.frame, covered_up_to, first_surviving).await;
}
info!(frame = %self.frame, %summary_id, %covered_up_to, "compaction: summary saved");
Ok(Some(CompactionOutcome {
summary_id,
covered_up_to,
first_surviving,
summary_text,
messages_covered: to_summarise.len(),
usage,
}))
}
/// Where to cut. Never between an assistant message and its tool results —
/// the surviving half would be a tool result answering a call the model
/// cannot see, which strict APIs reject outright.
fn split_point(&self, messages: &[StoredMessage]) -> Option<usize> {
match self.mode {
CompactionMode::UpTo(id) => {
let idx = messages.iter().position(|m| m.id == id)? + 1;
(idx < messages.len()).then_some(idx)
}
CompactionMode::Auto { keep_tail } => {
if messages.len() <= keep_tail {
return None;
}
let raw = messages.len() - keep_tail;
let split = (0..=raw)
.rev()
.find(|&i| i == 0 || matches!(messages[i].role, Role::User | Role::Agent))
.unwrap_or(0);
(split > 0).then_some(split)
}
}
}
}
// ── Transcript ───────────────────────────────────────────────────────────────
/// Head+tail truncation: a summariser needs both how a long output started and
/// how it ended; a prefix cut throws the conclusion away.
fn truncate_head_tail(s: &str, head_chars: usize, tail_chars: usize) -> String {
let s = s.trim();
let char_count = s.chars().count();
if char_count <= head_chars + tail_chars {
return s.to_string();
}
let head_end = s.char_indices().nth(head_chars).map(|(i, _)| i).unwrap_or(s.len());
let tail_start = s
.char_indices()
.nth(char_count - tail_chars)
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}\n...[truncated]...\n{}", &s[..head_end], &s[tail_start..])
}
fn truncate(s: &str, max_chars: usize) -> String {
let s = s.trim();
if s.chars().count() <= max_chars {
return s.to_string();
}
let end = s.char_indices().nth(max_chars).map(|(i, _)| i).unwrap_or(s.len());
format!("{}", &s[..end])
}
/// The messages as labeled text. Not the wire projection: a summariser reads
/// better prose than JSON, and tool results are worth more than tool schemas.
fn transcript(messages: &[StoredMessage]) -> String {
let mut parts: Vec<String> = Vec::new();
for msg in messages {
match msg.role {
Role::User | Role::Agent => {
parts.push(format!("[USER]: {}", truncate_head_tail(&msg.content, 6000, 1500)));
}
Role::Assistant => {
let mut content = truncate_head_tail(&msg.content, 6000, 1500);
if !msg.calls.is_empty() {
let lines: Vec<String> = msg
.calls
.iter()
.map(|c| {
let args = c
.arguments_raw
.clone()
.unwrap_or_else(|| c.arguments.to_string());
format!(" {}({})", c.name, truncate(&args, 1200))
})
.collect();
content.push_str(&format!("\n[Tool calls:\n{}\n]", lines.join("\n")));
}
parts.push(format!("[ASSISTANT]: {content}"));
for call in &msg.calls {
let result = match call.state {
CallState::Done => call
.result
.as_deref()
.map(|r| truncate_head_tail(r, 4000, 1500))
.unwrap_or_default(),
_ => "(failed or interrupted)".to_string(),
};
parts.push(format!("[TOOL RESULT tc_{}]: {result}", call.id));
}
}
// System messages are built per turn, never stored (see `store`).
Role::System => {}
}
}
parts.join("\n\n")
}
/// Correlation id for the summariser call (the crate carries no uuid crate).
fn uuid_like() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("compaction-{nanos:032x}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{CallOutcome, NewCall, NewMessage};
use crate::store_memory::InMemoryStore;
use crate::tool::ToolOutput;
#[test]
fn the_threshold_falls_back_to_the_estimate_when_usage_is_missing() {
assert!(should_compact(Some(120), 0, 100));
assert!(!should_compact(Some(80), 999, 100));
// No usage reported (or zero) → the host's own estimate decides.
assert!(should_compact(None, 120, 100));
assert!(should_compact(Some(0), 120, 100));
assert!(!should_compact(None, 80, 100));
}
async fn seeded() -> (Arc<InMemoryStore>, FrameId, Vec<StoredMessage>) {
let store = Arc::new(InMemoryStore::new());
let conv = ConversationId::new("c");
let frame = store
.open_frame(&conv, None, crate::store::FrameSpec::root("a"))
.await
.unwrap();
for i in 0..4 {
store.append(frame, NewMessage::user(format!("q{i}"))).await.unwrap();
let m = store
.append(frame, NewMessage::assistant(format!("a{i}"), None))
.await
.unwrap();
let c = store
.append_call(m, NewCall::new("read_file", json!({ "path": "x" })))
.await
.unwrap();
store
.resolve_call(c, &CallOutcome::Completed(ToolOutput::Text("body".into())))
.await
.unwrap();
}
let msgs = store.load(frame).await.unwrap();
(store, frame, msgs)
}
fn compaction(store: Arc<InMemoryStore>, frame: FrameId, mode: CompactionMode) -> Compaction {
let (bus, _) = tokio::sync::broadcast::channel(16);
Compaction {
store,
// The split-point tests never reach the model.
selector: Arc::new(crate::model::SingleModel::new(crate::testing::FakeModel::new(
"unused",
Vec::new(),
))),
hooks: Vec::new(),
events: EventSink::new(ConversationId::new("c"), bus),
conversation: ConversationId::new("c"),
frame,
mode,
hint: ModelHint::default(),
prompt: Arc::new(DefaultPrompt),
temperature: None,
log: None,
}
}
#[tokio::test]
async fn the_cut_never_splits_an_assistant_turn_from_its_tool_results() {
let (store, frame, msgs) = seeded().await;
// 8 messages: user/assistant × 4. keep_tail = 3 would cut at index 5 —
// an assistant message — so it must walk back to the user before it.
let c = compaction(store, frame, CompactionMode::Auto { keep_tail: 3 });
let split = c.split_point(&msgs).unwrap();
assert!(matches!(msgs[split].role, Role::User), "cut at {split}: {:?}", msgs[split].role);
}
#[tokio::test]
async fn there_is_nothing_to_compact_in_a_short_conversation() {
let (store, frame, msgs) = seeded().await;
let c = compaction(store, frame, CompactionMode::Auto { keep_tail: 99 });
assert!(c.split_point(&msgs).is_none());
}
#[tokio::test]
async fn an_explicit_cut_point_covers_it_and_keeps_the_rest() {
let (store, frame, msgs) = seeded().await;
let c = compaction(store.clone(), frame, CompactionMode::UpTo(msgs[2].id));
assert_eq!(c.split_point(&msgs), Some(3));
// Cutting at the very last message would leave nothing surviving.
let c = compaction(store, frame, CompactionMode::UpTo(msgs.last().unwrap().id));
assert_eq!(c.split_point(&msgs), None);
}
#[tokio::test]
async fn the_transcript_carries_calls_and_their_results() {
let (_store, _frame, msgs) = seeded().await;
let text = transcript(&msgs[..2]);
assert!(text.contains("[USER]: q0"), "{text}");
assert!(text.contains("[ASSISTANT]: a0"), "{text}");
assert!(text.contains("read_file({\"path\":\"x\"})"), "{text}");
assert!(text.contains("[TOOL RESULT tc_1]: body"), "{text}");
}
}
+183
View File
@@ -0,0 +1,183 @@
//! The system context (layered) and the `ContextAssembler` — from system +
//! history to wire messages.
//!
//! The projection itself lives in [`crate::projection`], which owns the
//! well-formedness contract and every provider-shaped decision. This module is
//! the seam: hosts implement [`SystemContextSource`] to say *what* goes in the
//! system prompt, and [`LinearAssembler`] configures the projection.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use crate::activation::ActivationSource;
use crate::ids::{ConversationId, FrameId};
use crate::model::ModelInfo;
use crate::projection::{
MediaSource, Projection, ProjectionHooks, ResultLimit, ToolResultDigest,
};
use crate::store::HistoryStore;
// ── SystemContext ────────────────────────────────────────────────────────────
/// The system prompt as LAYERS (the static prefix is cacheable, the dynamic
/// tail is per-turn fresh).
#[derive(Debug, Clone, Default)]
pub struct SystemContext {
/// The agent's prompt (static, cacheable).
pub base: String,
/// Per-interface extras (e.g. output format rules).
pub extra_static: Vec<String>,
/// Per-turn: date/time, memory, run context.
pub dynamic_tail: Vec<String>,
pub tail_reminder: Option<String>,
}
impl SystemContext {
pub fn base(s: impl Into<String>) -> Self {
Self { base: s.into(), ..Default::default() }
}
pub fn with_dynamic(mut self, s: impl Into<String>) -> Self {
self.dynamic_tail.push(s.into());
self
}
pub fn with_static(mut self, s: impl Into<String>) -> Self {
self.extra_static.push(s.into());
self
}
pub fn with_reminder(mut self, s: impl Into<String>) -> Self {
self.tail_reminder = Some(s.into());
self
}
}
// ── SystemContextSource ──────────────────────────────────────────────────────
/// What the kernel knows about the current turn when asking for the system
/// context.
#[derive(Debug, Clone)]
pub struct TurnInfo {
pub conversation: ConversationId,
pub frame: FrameId,
pub agent: String,
/// The user message that opened the turn (None on resume).
pub user_message: Option<String>,
}
#[async_trait]
pub trait SystemContextSource: Send + Sync {
async fn system_context(&self, turn: &TurnInfo) -> crate::Result<SystemContext>;
}
/// A fixed system context (simple hosts, tests).
pub struct StaticSystemContext {
ctx: SystemContext,
}
impl StaticSystemContext {
pub fn new(base: impl Into<String>) -> Self {
Self { ctx: SystemContext::base(base) }
}
}
#[async_trait]
impl SystemContextSource for StaticSystemContext {
async fn system_context(&self, _turn: &TurnInfo) -> crate::Result<SystemContext> {
Ok(self.ctx.clone())
}
}
// ── ContextAssembler ─────────────────────────────────────────────────────────
pub struct AssembleInput {
pub frame: FrameId,
pub system: SystemContext,
pub model: ModelInfo,
pub round: usize,
}
#[async_trait]
pub trait ContextAssembler: Send + Sync {
async fn build(
&self,
store: &Arc<dyn HistoryStore>,
input: &AssembleInput,
) -> crate::Result<Vec<Value>>;
}
// ── LinearAssembler ──────────────────────────────────────────────────────────
/// The shipped assembler: a [`Projection`] plus the host hooks it may use.
///
/// Out of the box it produces a correct OpenAI-shaped conversation. A host with
/// stricter models overrides the projection (`with_projection`) and plugs in its
/// media authorization and result-digest policy.
pub struct LinearAssembler {
pub projection: Projection,
pub hooks: ProjectionHooks,
}
impl LinearAssembler {
pub fn new() -> Self {
Self { projection: Projection::default(), hooks: ProjectionHooks::default() }
}
/// Replace the whole protocol configuration.
pub fn with_projection(mut self, projection: Projection) -> Self {
self.projection = projection;
self
}
/// Keep at most this many history messages (cut boundary-safely).
pub fn with_max_messages(mut self, n: usize) -> Self {
self.projection.max_messages = Some(n);
self
}
/// Shrink every tool result longer than `n` chars.
pub fn with_tool_result_limit(mut self, n: usize) -> Self {
self.projection.max_tool_result =
Some(ResultLimit { max_chars: n, previous_turns_only: false });
self
}
/// DTL activations (consulted only when `tool_rendering != Inline`).
pub fn with_activation(mut self, src: Arc<dyn ActivationSource>) -> Self {
self.hooks.activation = Some(src);
self
}
/// Which media a message may inline.
pub fn with_media(mut self, src: Arc<dyn MediaSource>) -> Self {
self.hooks.media = Some(src);
self
}
/// How an over-long tool result is condensed.
pub fn with_digest(mut self, digest: Arc<dyn ToolResultDigest>) -> Self {
self.hooks.digest = Some(digest);
self
}
}
impl Default for LinearAssembler {
fn default() -> Self { Self::new() }
}
/// Re-exported for hosts that only need the default summary header.
pub use crate::projection::SUMMARY_PREFIX;
#[async_trait]
impl ContextAssembler for LinearAssembler {
async fn build(
&self,
store: &Arc<dyn HistoryStore>,
input: &AssembleInput,
) -> crate::Result<Vec<Value>> {
crate::projection::project(store, input, &self.projection, &self.hooks).await
}
}
+755
View File
@@ -0,0 +1,755 @@
//! Sub-agents as a tool (blueprint §7, D2): the kernel never intercepts
//! anything — `delegate` is a tool like any other, dispatched through the
//! normal gate/hooks/execution path. A sync child is just a slow tool call the
//! parent awaits; a homogeneous batch of sync delegates fans out through the
//! kernel's generic concurrency (`concurrency_safe`).
//!
//! Both flows ship. A SYNC child is awaited in place; an ASYNC one is handed to
//! the host's [`AsyncExecutor`] and its result comes back later through an
//! [`AsyncResultSink`] — a tool call the model already has an id for, resolved
//! whenever the work finishes.
use std::sync::Arc;
use serde_json::{Value, json};
use crate::async_trait;
use crate::context::SystemContextSource;
use crate::events::{EventSink, LoopEvent};
use crate::ids::{ConversationId, FrameId, TaskId, ToolCallId};
use crate::manager::{LoopManager, LoopParams, TurnMeta};
use crate::model::{ModelHint, ModelSelector};
use crate::store::{CallOutcome, FrameSpec, HistoryStore, NewCall, NewMessage};
use crate::tool::{Extensions, SharedToolSet, Tool, ToolCtx, ToolFailure, ToolOutput, ToolSet};
// ── AgentCatalog ─────────────────────────────────────────────────────────────
/// The agent's kind (from the host's meta). Only `Task` agents are
/// dispatchable via `delegate`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentKind {
Chat,
Task,
System,
}
/// A dispatchable agent.
#[derive(Clone)]
pub struct AgentProfile {
pub id: String,
pub kind: AgentKind,
/// The child's system context (its own prompt — never the parent's, B3).
pub context: Arc<dyn SystemContextSource>,
/// How the child's tool set derives from the parent's (ignored when
/// `toolset` is set).
pub tools: ToolSelection,
/// Full tool-set override (hosts whose children need a fresh registry
/// rather than a filtered view of the parent's — e.g. fresh grant sets).
pub toolset: Option<Arc<dyn ToolSet>>,
/// Model pin (bypasses AUTO). Strength is resolved by the host's selector.
pub model: Option<ModelHint>,
/// Per-child selector override (e.g. a different required strength, D14).
pub selector: Option<Arc<dyn ModelSelector>>,
/// Per-child assembler override (e.g. scoped DTL activation).
pub assembler: Option<Arc<dyn crate::context::ContextAssembler>>,
}
/// How a child's tool set derives from the parent's: strip `remove` by name,
/// then append `add`.
#[derive(Clone, Default)]
pub struct ToolSelection {
pub remove: Vec<String>,
pub add: Vec<Arc<dyn Tool>>,
}
impl ToolSelection {
pub fn inherit() -> Self { Self::default() }
pub fn minus(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self { remove: names.into_iter().map(Into::into).collect(), add: Vec::new() }
}
pub fn plus(tools: Vec<Arc<dyn Tool>>) -> Self {
Self { remove: Vec::new(), add: tools }
}
}
/// Summary for catalog listings (a future `list_agents` tool).
#[derive(Debug, Clone)]
pub struct AgentSummary {
pub id: String,
pub kind: AgentKind,
pub description: String,
}
#[async_trait]
pub trait AgentCatalog: Send + Sync {
/// Load a dispatchable profile, built for `child_frame` (already opened by
/// the DelegateTool — frame-scoped pieces like grants/activation anchor to
/// it). MUST reject non-`Task` kinds and unknown ids.
///
/// `ctx` is the delegating call's context: a catalog that lives as long as
/// the tenant reads the turn's own state (session, source, permissions)
/// from `ctx.extensions` instead of having captured it at construction.
async fn get(
&self,
id: &str,
child_frame: FrameId,
ctx: &ToolCtx,
) -> crate::Result<AgentProfile>;
async fn list(&self, kind: AgentKind) -> Vec<AgentSummary>;
/// Frame-exit hook (host cleanup, e.g. deleting stack-scoped activations).
async fn on_child_closed(&self, _frame: crate::ids::FrameId) {}
}
// ── FilteredToolSet ──────────────────────────────────────────────────────────
/// The child's tool set: parent's minus `remove`, plus `add`.
pub struct FilteredToolSet {
inner: Arc<dyn ToolSet>,
remove: Vec<String>,
add: Vec<Arc<dyn Tool>>,
}
impl FilteredToolSet {
/// A child's set derived from the parent's. Used by the delegate at
/// dispatch and by [`crate::recovery`] when it rebuilds a resumed frame.
pub fn derive(inner: Arc<dyn ToolSet>, selection: &ToolSelection) -> Self {
Self {
inner,
remove: selection.remove.clone(),
add: selection.add.clone(),
}
}
}
impl ToolSet for FilteredToolSet {
fn defs(&self, model: &crate::model::ModelInfo) -> Vec<Value> {
let mut defs: Vec<Value> = self
.inner
.defs(model)
.into_iter()
.filter(|d| {
let name = d["function"]["name"].as_str().unwrap_or("");
!self.remove.iter().any(|r| r == name)
})
.collect();
defs.extend(self.add.iter().map(|t| t.definition()));
defs
}
fn find(&self, name: &str) -> Option<Arc<dyn Tool>> {
if let Some(t) = self.add.iter().find(|t| t.name() == name) {
return Some(t.clone());
}
if self.remove.iter().any(|r| r == name) {
return None;
}
self.inner.find(name)
}
}
// ── Async delegation ─────────────────────────────────────────────────────────
/// What the host is asked to run out of band (blueprint §7.2).
///
/// The parent's turn does **not** wait for it: `delegate` returns a receipt and
/// the loop moves on. Everything needed to run the work later is in here, so an
/// executor backed by a durable queue can pick it up after a restart.
#[derive(Clone)]
pub struct AsyncSpec {
pub conversation: ConversationId,
/// The delegating frame — where the result is delivered.
pub parent_frame: FrameId,
/// The delegating call, so a host can correlate its own record with ours.
pub parent_call: ToolCallId,
/// The agent that delegated (the child's is `agent`).
pub parent_agent: String,
pub agent: String,
pub prompt: String,
pub title: Option<String>,
pub description: Option<String>,
/// The delegating turn's extensions (the host's own context).
pub extensions: Extensions,
}
/// The host's receipt for a submitted task.
#[derive(Debug, Clone)]
pub struct TaskHandle {
pub id: TaskId,
pub title: String,
}
/// Runs a delegated task out of band. **Durability is the host's**: the crate's
/// [`InProcessExecutor`] is lossy across restarts, a queue-backed one is not.
#[async_trait]
pub trait AsyncExecutor: Send + Sync {
async fn submit(&self, spec: AsyncSpec) -> crate::Result<TaskHandle>;
}
/// A task that finished, whatever ran it.
#[derive(Debug, Clone)]
pub struct CompletedTask {
pub id: TaskId,
pub title: String,
pub result: String,
}
/// Where a finished task's result goes.
#[async_trait]
pub trait AsyncResultSink: Send + Sync {
async fn deliver(&self, parent: ConversationId, task: CompletedTask) -> crate::Result<()>;
}
/// The wire name of the synthetic call carrying a delivered result. The model
/// sees it as a tool call it never made — which is exactly what it is: the
/// system reporting back.
pub const DELIVERY_CALL: &str = "task_completed";
/// The shipped sink: writes the delivery into the store, as a synthetic
/// assistant message plus one completed call.
///
/// Durable by construction — it is a normal state transition, so the result is
/// in the history the instant it lands, whether or not anything is driving the
/// conversation. **Waking the parent is the host's job**: a live loop picks the
/// result up on its own (it reads the store each round), and an idle
/// conversation needs a resume, which only the host knows how to trigger for
/// its surfaces. Wrap this sink to add that.
pub struct StoreSink {
store: Arc<dyn HistoryStore>,
call_name: String,
}
impl StoreSink {
pub fn new(store: Arc<dyn HistoryStore>) -> Self {
Self { store, call_name: DELIVERY_CALL.to_string() }
}
/// Rename the synthetic call (hosts with their own legacy name).
pub fn with_call_name(mut self, name: impl Into<String>) -> Self {
self.call_name = name.into();
self
}
}
#[async_trait]
impl AsyncResultSink for StoreSink {
async fn deliver(&self, parent: ConversationId, task: CompletedTask) -> crate::Result<()> {
// The deepest active frame is where the conversation currently is: a
// result delivered to a closed frame would never be read.
let frame = self
.store
.deepest_active(&parent)
.await?
.ok_or_else(|| anyhow::anyhow!("deliver: no active frame on conversation {parent}"))?;
let reasoning = format!(
"The system is notifying me that async task #{} ('{}') has completed. \
Let me process the result via {}.",
task.id, task.title, self.call_name,
);
let msg = self
.store
.append(
frame.id,
NewMessage {
role: crate::store::Role::Assistant,
content: String::new(),
synthetic: true,
reasoning: Some(reasoning),
metadata: None,
},
)
.await?;
let call = self
.store
.append_call(msg, NewCall::new(&self.call_name, json!({ "task_id": task.id.get() })))
.await?;
let payload = json!({
"task_id": task.id.get(),
"title": task.title,
"result": task.result,
});
self.store
.resolve_call(call, &CallOutcome::Completed(ToolOutput::Text(payload.to_string())))
.await?;
Ok(())
}
}
/// The lossy executor: runs the task on the current process, on the same
/// manager, and delivers through the given sink.
///
/// **A restart loses in-flight tasks** — nothing records that the work was
/// owed. Fine for a single-process host that treats async delegation as
/// best-effort; a host that must not lose one wires an executor over its own
/// durable queue (Skald: a `scheduled_jobs` row).
pub struct InProcessExecutor {
manager: Arc<LoopManager>,
catalog: Arc<dyn AgentCatalog>,
store: Arc<dyn HistoryStore>,
sink: Arc<dyn AsyncResultSink>,
tools: Arc<dyn ToolSet>,
next_id: std::sync::atomic::AtomicI64,
}
impl InProcessExecutor {
pub fn new(
manager: Arc<LoopManager>,
catalog: Arc<dyn AgentCatalog>,
store: Arc<dyn HistoryStore>,
sink: Arc<dyn AsyncResultSink>,
tools: Arc<dyn ToolSet>,
) -> Self {
Self { manager, catalog, store, sink, tools, next_id: std::sync::atomic::AtomicI64::new(1) }
}
}
#[async_trait]
impl AsyncExecutor for InProcessExecutor {
async fn submit(&self, spec: AsyncSpec) -> crate::Result<TaskHandle> {
let id = TaskId(self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
let title = spec.title.clone().unwrap_or_else(|| spec.agent.clone());
// Its own frame, child of the delegating one: the task is a sub-agent
// that nobody awaits.
let parent = self
.store
.get_frame(spec.parent_frame)
.await?
.ok_or_else(|| anyhow::anyhow!("submit: parent frame not found"))?;
let frame = self
.store
.open_frame(&spec.conversation, Some(spec.parent_frame), FrameSpec {
agent: spec.agent.clone(),
prompt: Some(spec.prompt.clone()),
depth: parent.spec.depth + 1,
// NOT the delegating call: that one is already resolved with the
// receipt, and recovery must not try to complete it twice.
parent_call: None,
meta: Value::Null,
})
.await?;
// The delegating call's context, minus its cancellation: the profile is
// resolved against the turn that asked for the work.
let ctx = ToolCtx {
conversation: spec.conversation.clone(),
frame: spec.parent_frame,
agent: spec.parent_agent.clone(),
call_id: spec.parent_call,
cancel: tokio_util::sync::CancellationToken::new(),
extensions: spec.extensions.clone(),
};
let profile = self.catalog.get(&spec.agent, frame, &ctx).await?;
self.store.append(frame, NewMessage::agent(&spec.prompt)).await?;
let manager = self.manager.clone();
let store = self.store.clone();
let catalog = self.catalog.clone();
let sink = self.sink.clone();
let tools = profile.toolset.clone().unwrap_or_else(|| self.tools.clone());
let task_title = title.clone();
tokio::spawn(async move {
let outcome = match manager
.start_loop(LoopParams {
conversation: spec.conversation.clone(),
frame,
parent_frame: Some(spec.parent_frame),
agent: spec.agent.clone(),
system: profile.context,
tools,
model_hint: profile.model.unwrap_or_default(),
selector: profile.selector,
// Detached from the parent turn: the point of async is that
// the parent's /stop does not kill the background work.
token: None,
live_input: None,
extensions: spec.extensions.clone(),
meta: TurnMeta::default(),
assembler: profile.assembler,
})
.await
{
Ok(handle) => handle.join().await,
Err(e) => Err(anyhow::anyhow!("{e}")),
};
catalog.on_child_closed(frame).await;
let _ = store.close_frame(frame).await;
let result = match outcome {
Ok(crate::kernel::TurnOutcome::Final { content, .. }) => content,
Ok(crate::kernel::TurnOutcome::Cancelled) => "(cancelled)".to_string(),
Ok(crate::kernel::TurnOutcome::Exhausted) => {
"(no output: tool-call round budget exhausted)".to_string()
}
Err(e) => format!("(failed: {e})"),
};
if let Err(e) = sink
.deliver(spec.conversation.clone(), CompletedTask { id, title: task_title, result })
.await
{
tracing::error!(task = %id, "async task delivery failed: {e}");
}
});
Ok(TaskHandle { id, title })
}
}
// ── DelegateTool ─────────────────────────────────────────────────────────────
/// The shipped `delegate` tool. The parent loop simply awaits a slow tool —
/// nesting is reconstructed by subscribers from the `parent_frame` event tags.
#[derive(Clone)]
pub struct DelegateTool {
manager: Arc<LoopManager>,
catalog: Arc<dyn AgentCatalog>,
store: Arc<dyn HistoryStore>,
max_depth: u32,
name: String,
definition_override: Option<Value>,
/// `None` → `mode: "async"` is refused instead of silently running sync.
async_exec: Option<Arc<dyn AsyncExecutor>>,
}
impl DelegateTool {
pub fn new(
manager: Arc<LoopManager>,
catalog: Arc<dyn AgentCatalog>,
store: Arc<dyn HistoryStore>,
max_depth: u32,
) -> Self {
Self {
manager,
catalog,
store,
max_depth,
name: "delegate".to_string(),
definition_override: None,
async_exec: None,
}
}
/// Wire `mode: "async"` to an executor. Without one the mode is refused —
/// running it synchronously instead would block a turn that asked not to
/// wait.
pub fn with_async(mut self, exec: Arc<dyn AsyncExecutor>) -> Self {
self.async_exec = Some(exec);
self
}
/// Register under a different wire name (Skald's legacy aliases
/// `execute_task` / `execute_subtask`, blueprint D11).
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
/// Override the advertised definition (legacy aliases keep their exact
/// legacy schema byte-for-byte).
pub fn with_definition(mut self, def: Value) -> Self {
self.definition_override = Some(def);
self
}
/// The schema: `agent_id` + `prompt` required; `title`, `description`,
/// `mode` ("sync" — async rides the host executor), `client` accepted for
/// legacy compatibility.
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"agent_id": { "type": "string", "description": "Id of the task agent to delegate to" },
"prompt": { "type": "string", "description": "The full brief for the sub-agent" },
"title": { "type": "string", "description": "Optional short title for the task" },
"description": { "type": "string", "description": "Optional longer description" },
"mode": { "type": "string", "enum": ["sync", "async"],
"description": "sync: wait for the result. async: host-scheduled (if wired)" },
"client": { "type": "string", "description": "Optional model override" }
},
"required": ["agent_id", "prompt"]
})
}
/// Hands the work to the host and returns the receipt immediately. The
/// result arrives later as its own call (see [`AsyncResultSink`]), so the
/// model is told plainly not to poll for it.
async fn run_async(
&self,
agent_id: &str,
prompt: &str,
args: &Value,
ctx: &ToolCtx,
) -> Result<ToolOutput, ToolFailure> {
let Some(exec) = &self.async_exec else {
return Err(ToolFailure::Failed(
"delegate: async mode is not available in this session".to_string(),
));
};
if agent_id == ctx.agent {
return Err(ToolFailure::Failed(format!(
"delegate: an agent cannot call itself (`{agent_id}`)"
)));
}
let handle = exec
.submit(AsyncSpec {
conversation: ctx.conversation.clone(),
parent_frame: ctx.frame,
parent_call: ctx.call_id,
parent_agent: ctx.agent.clone(),
agent: agent_id.to_string(),
prompt: prompt.to_string(),
title: args["title"].as_str().map(str::to_string),
description: args["description"].as_str().map(str::to_string),
extensions: ctx.extensions.clone(),
})
.await
.map_err(|e| ToolFailure::Failed(format!("delegate: async submit failed: {e}")))?;
Ok(ToolOutput::Text(
json!({
"task_id": handle.id.get(),
"status": "started",
"message": format!(
"Task {} ('{}') is running in the background. \
The system will automatically deliver the result to this conversation when complete. \
Do NOT poll for it. Continue the conversation normally.",
handle.id, handle.title,
),
})
.to_string(),
))
}
async fn run_sync(&self, agent_id: &str, prompt: &str, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
if agent_id == ctx.agent {
return Err(ToolFailure::Failed(format!(
"delegate: an agent cannot call itself (`{agent_id}`)"
)));
}
// Depth check (max recursion, from the parent frame).
let parent_frame = self
.store
.get_frame(ctx.frame)
.await
.map_err(|e| ToolFailure::Failed(format!("delegate: frame lookup failed: {e}")))?
.ok_or_else(|| ToolFailure::Failed("delegate: parent frame not found".into()))?;
let new_depth = parent_frame.spec.depth + 1;
if new_depth > self.max_depth {
return Err(ToolFailure::Failed(format!(
"delegate: maximum agent depth ({}) exceeded — refusing to recurse further",
self.max_depth
)));
}
let child_frame = self
.store
.open_frame(&ctx.conversation, Some(ctx.frame), FrameSpec {
agent: agent_id.to_string(),
prompt: Some(prompt.to_string()),
depth: new_depth,
parent_call: Some(ctx.call_id),
meta: Value::Null,
})
.await
.map_err(|e| ToolFailure::Failed(format!("delegate: open frame failed: {e}")))?;
// Profile AFTER the frame exists (frame-scoped pieces anchor to it).
// On rejection the frame is closed so nothing dangles.
let profile = match self.catalog.get(agent_id, child_frame, ctx).await {
Ok(p) => p,
Err(e) => {
let _ = self.store.close_frame(child_frame).await;
return Err(ToolFailure::Failed(format!("delegate: {e}")));
}
};
if profile.kind != AgentKind::Task {
let _ = self.store.close_frame(child_frame).await;
return Err(ToolFailure::Failed(format!(
"delegate: agent `{agent_id}` is not dispatchable (only task agents are)"
)));
}
self.store
.append(child_frame, NewMessage::agent(prompt))
.await
.map_err(|e| ToolFailure::Failed(format!("delegate: append failed: {e}")))?;
let events = EventSink::from_extensions(&ctx.extensions);
if let Some(ev) = &events {
ev.emit(child_frame, Some(ctx.frame), LoopEvent::AgentSpawned {
frame: child_frame,
agent: agent_id.to_string(),
depth: new_depth,
prompt_preview: preview_truncate(prompt, 500),
parent_call: ctx.call_id,
parent_agent: ctx.agent.clone(),
});
}
// The child's tool set: the profile's full override, or the parent's
// filtered per its ToolSelection.
let child_tools: Arc<dyn ToolSet> = match profile.toolset.clone() {
Some(ts) => ts,
None => {
let parent_tools = ctx
.extensions
.get::<SharedToolSet>()
.ok_or_else(|| ToolFailure::Failed("delegate: no ToolSet in extensions".into()))?;
Arc::new(FilteredToolSet::derive(parent_tools.0.clone(), &profile.tools))
}
};
let child = self
.manager
.start_loop(LoopParams {
conversation: ctx.conversation.clone(),
frame: child_frame,
parent_frame: Some(ctx.frame),
agent: agent_id.to_string(),
system: profile.context,
tools: child_tools,
model_hint: profile.model.unwrap_or_default(),
selector: profile.selector,
// Sticky /stop: the child rides the parent's cancellation tree.
token: Some(ctx.cancel.child_token()),
live_input: None,
extensions: ctx.extensions.clone(),
meta: TurnMeta::default(),
assembler: profile.assembler,
})
.await
.map_err(|e| ToolFailure::Failed(format!("delegate: start loop failed: {e}")))?;
let outcome = child.join().await;
self.catalog.on_child_closed(child_frame).await;
let _ = self.store.close_frame(child_frame).await;
let result_preview = |s: &str| preview_truncate(s, 500);
let emit_done = |text: &str| {
if let Some(ev) = &events {
ev.emit(child_frame, Some(ctx.frame), LoopEvent::AgentFinished {
frame: child_frame,
agent: agent_id.to_string(),
result_preview: result_preview(text),
parent_agent: ctx.agent.clone(),
});
}
};
match outcome {
Ok(crate::kernel::TurnOutcome::Final { content, .. }) => {
emit_done(&content);
Ok(ToolOutput::Text(content))
}
Ok(crate::kernel::TurnOutcome::Cancelled) => {
emit_done("⚠️ Cancelled.");
Ok(ToolOutput::Text(format!("Sub-agent `{agent_id}` was cancelled.")))
}
Ok(crate::kernel::TurnOutcome::Exhausted) => {
emit_done("⚠️ Exhausted tool-call rounds.");
Ok(ToolOutput::Text(format!(
"Sub-agent `{agent_id}` exceeded the tool-call round budget without producing a final answer."
)))
}
Err(e) => {
emit_done(&format!("⚠️ Error: {e}"));
Err(ToolFailure::Failed(format!("Sub-agent `{agent_id}` failed: {e}")))
}
}
}
}
#[async_trait]
impl Tool for DelegateTool {
fn name(&self) -> &str { &self.name }
fn definition(&self) -> Value {
if let Some(def) = &self.definition_override {
return def.clone();
}
json!({
"type": "function",
"function": {
"name": self.name,
"description": "Delegate a task to a sub-agent and wait for its result. \
Use for focused, well-scoped work that benefits from a clean context.",
"parameters": self.schema(),
}
})
}
/// Sync delegates batch: a homogeneous fan-out runs them concurrently
/// (the kernel allocates ids in order first — results never mix).
fn concurrency_safe(&self, args: &Value) -> bool {
args["mode"].as_str() != Some("async")
}
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
let agent_id = args["agent_id"]
.as_str()
.ok_or_else(|| ToolFailure::Failed("delegate: missing required argument `agent_id`".into()))?;
let prompt = args["prompt"]
.as_str()
.ok_or_else(|| ToolFailure::Failed("delegate: missing required argument `prompt`".into()))?;
match args["mode"].as_str() {
Some("async") => self.run_async(agent_id, prompt, &args, ctx).await,
_ => self.run_sync(agent_id, prompt, ctx).await,
}
}
}
/// Truncate to `max` chars with an ellipsis (previews).
pub fn preview_truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let cut: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{cut}")
}
/// A static catalog for tests and simple hosts.
pub struct StaticCatalog {
profiles: Vec<AgentProfile>,
}
impl StaticCatalog {
pub fn new() -> Self { Self { profiles: Vec::new() } }
pub fn with(mut self, profile: AgentProfile) -> Self {
self.profiles.push(profile);
self
}
}
impl Default for StaticCatalog {
fn default() -> Self { Self::new() }
}
#[async_trait]
impl AgentCatalog for StaticCatalog {
async fn get(
&self,
id: &str,
_child_frame: FrameId,
_ctx: &ToolCtx,
) -> crate::Result<AgentProfile> {
self.profiles
.iter()
.find(|p| p.id == id)
.cloned()
.ok_or_else(|| anyhow::anyhow!("unknown agent `{id}`"))
}
async fn list(&self, kind: AgentKind) -> Vec<AgentSummary> {
self.profiles
.iter()
.filter(|p| p.kind == kind)
.map(|p| AgentSummary { id: p.id.clone(), kind: p.kind, description: String::new() })
.collect()
}
}
+179
View File
@@ -0,0 +1,179 @@
//! The loop event taxonomy and the broadcast bus.
//!
//! Every event is wrapped in [`Event`], tagged with the emitting conversation,
//! frame and parent frame — subscribers (a UI translator, a logger) reconstruct
//! nesting from the tags. Transport: `tokio::sync::broadcast` (multi-subscriber,
//! lag-tolerant).
use serde_json::Value;
use tokio::sync::broadcast;
use crate::ids::{ConversationId, FrameId, MessageId, ModelId, TaskId, ToolCallId};
use crate::model::{ToolCall, Usage};
use crate::store::CallOutcome;
/// Whether a [`LoopEvent::TokenDelta`] carries visible answer text or reasoning.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeltaKind {
Content,
Reasoning,
}
/// Events emitted by a running loop. Every variant is wrapped in [`Event`]
/// before hitting the bus, so conversation/frame tags are never optional.
#[derive(Debug, Clone)]
pub enum LoopEvent {
// ── turn ──
TurnStarted,
RoundStarted {
round: usize,
},
UserMessage {
message_id: MessageId,
content: String,
synthetic: bool,
metadata: Option<Value>,
},
TokenDelta {
kind: DeltaKind,
text: String,
},
Thinking {
message_id: MessageId,
content: String,
usage: Usage,
reasoning: Option<String>,
},
Done {
message_id: MessageId,
content: String,
usage: Usage,
reasoning: Option<String>,
},
// ── tools ──
ToolCallStarted {
id: ToolCallId,
message_id: MessageId,
name: String,
args: Value,
},
ToolCallFinished {
id: ToolCallId,
outcome: CallOutcome,
},
ApprovalRequired {
id: ToolCallId,
name: String,
args: Value,
/// The approval request id in the host's registry (for UI resolution).
request_id: i64,
},
// ── sub-agents (emitted by child loops; parent_frame in the tag) ──
AgentSpawned {
frame: FrameId,
agent: String,
depth: u32,
prompt_preview: String,
/// The parent frame's tool call that spawned this agent.
parent_call: ToolCallId,
parent_agent: String,
},
AgentFinished {
frame: FrameId,
agent: String,
result_preview: String,
parent_agent: String,
},
AsyncResultReady {
task: TaskId,
},
// ── infrastructure ──
ModelFallback {
from: ModelId,
to: ModelId,
reason: String,
},
LlmFailed {
tried: Vec<ModelId>,
last_error: String,
},
Compacted {
frame: FrameId,
covered_up_to: MessageId,
},
Truncated {
output_tokens: Option<u32>,
},
Error(String),
Cancelled,
/// Escape hatch for host-specific events (Skald: PendingWrite with diff,
/// SecurityGroupSelected, …). Other subscribers ignore it.
Host(Value),
}
/// An event tagged with its emitting scope.
#[derive(Debug, Clone)]
pub struct Event<E> {
pub conversation: ConversationId,
pub frame: FrameId,
pub parent_frame: Option<FrameId>,
pub inner: E,
}
/// Thin wrapper over the manager's broadcast sender, handed to the kernel,
/// gates, tools and hooks for out-of-band emission. Cheap to clone.
#[derive(Clone)]
pub struct EventSink {
pub(crate) conversation: ConversationId,
pub(crate) tx: broadcast::Sender<Event<LoopEvent>>,
}
impl EventSink {
/// Wrap a bus sender for one conversation. Public so hosts can build
/// sinks in their own tests and adapters; the kernel builds them via the
/// manager.
pub fn new(conversation: ConversationId, tx: broadcast::Sender<Event<LoopEvent>>) -> Self {
Self { conversation, tx }
}
/// Emit an event for a frame. Best-effort: with no subscribers the send
/// fails silently — events are never load-bearing for the loop's outcome.
pub fn emit(&self, frame: FrameId, parent_frame: Option<FrameId>, inner: LoopEvent) {
let _ = self.tx.send(Event {
conversation: self.conversation.clone(),
frame,
parent_frame,
inner,
});
}
pub fn conversation(&self) -> &ConversationId { &self.conversation }
/// Recover the sink from a tool's extensions (the kernel inserts one into
/// every `ToolCtx` it builds, so shipped tools can emit out-of-band).
pub fn from_extensions(ext: &crate::tool::Extensions) -> Option<EventSink> {
ext.get::<EventSink>().map(|s| (*s).clone())
}
}
/// A running tool call, as passed to `LoopHooks::pre_tool_call` (mutable) and
/// `post_tool_call`. Distinct from the model's [`crate::model::ToolCall`]:
/// this one carries the store id allocated before execution.
#[derive(Debug, Clone)]
pub struct PendingToolCall {
pub id: ToolCallId,
pub message_id: MessageId,
pub provider_id: Option<String>,
pub name: String,
pub arguments: Value,
}
impl PendingToolCall {
pub fn wire_call(&self) -> ToolCall {
ToolCall {
id: self.provider_id.clone().unwrap_or_default(),
name: self.name.clone(),
arguments: self.arguments.clone(),
}
}
}
+82
View File
@@ -0,0 +1,82 @@
//! `Gate` — the pre-execution decision point (policy and/or human). It MAY
//! block waiting for a human: the implementation decides (oneshot, UI, …).
//! Before suspending, an implementation marks the call `AwaitingHuman` via the
//! store (durability) and emits `LoopEvent::ApprovalRequired`.
use async_trait::async_trait;
use serde_json::Value;
use crate::events::EventSink;
use crate::ids::{FrameId, ToolCallId};
use crate::tool::Extensions;
/// A tool call awaiting a gate decision.
#[derive(Debug, Clone)]
pub struct PendingCall {
pub id: ToolCallId,
pub name: String,
pub args: Value,
pub frame: FrameId,
pub parent_frame: Option<FrameId>,
pub agent: String,
/// Host free-form (source, permission group, …).
pub extensions: Extensions,
}
/// The gate's verdict.
#[derive(Debug, Clone)]
pub enum GateDecision {
Allow,
Reject { reason: String },
/// The gate was waiting for a human and the channel closed: the turn ends
/// and the call STAYS `AwaitingHuman` (the gate marked it before
/// suspending) — the same semantics as `ToolFailure::Suspend`.
Suspend,
}
#[async_trait]
pub trait Gate: Send + Sync {
/// Decide on a call. MAY block awaiting a human — in that case the
/// implementation marks the call `AwaitingHuman` first (via the store the
/// host gave it) and emits `ApprovalRequired` on `events`.
async fn check(&self, call: &PendingCall, events: &EventSink) -> GateDecision;
}
/// Everything runs. The default for simple hosts and tests.
pub struct AllowAll;
#[async_trait]
impl Gate for AllowAll {
async fn check(&self, _call: &PendingCall, _events: &EventSink) -> GateDecision {
GateDecision::Allow
}
}
/// Reject calls whose name matches a pattern: exact, or `prefix*`.
pub struct DenyList {
patterns: Vec<String>,
}
impl DenyList {
pub fn new(patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self { patterns: patterns.into_iter().map(Into::into).collect() }
}
fn matches(&self, name: &str) -> bool {
self.patterns.iter().any(|p| match p.strip_suffix('*') {
Some(prefix) => name.starts_with(prefix),
None => name == p,
})
}
}
#[async_trait]
impl Gate for DenyList {
async fn check(&self, call: &PendingCall, _events: &EventSink) -> GateDecision {
if self.matches(&call.name) {
GateDecision::Reject { reason: format!("tool '{}' denied by policy", call.name) }
} else {
GateDecision::Allow
}
}
}
+50
View File
@@ -0,0 +1,50 @@
//! `LoopHooks` — the passive/active interception seam. Every host special-case
//! (diff-preview bracketing, per-tool arg normalization, telemetry, discovery)
//! lives here, not in the kernel. All methods default to no-op.
use std::sync::Arc;
use async_trait::async_trait;
use crate::events::{EventSink, PendingToolCall};
use crate::ids::{ConversationId, FrameId, MessageId};
use crate::kernel::TurnOutcome;
use crate::store::{CallOutcome, HistoryStore};
/// Verdict of `pre_tool_call`.
#[derive(Debug, Clone)]
pub enum HookVerdict {
Allow,
Reject { reason: String },
}
/// Context handed to every hook.
pub struct HookCtx {
pub conversation: ConversationId,
pub frame: FrameId,
pub agent: String,
pub store: Arc<dyn HistoryStore>,
pub events: EventSink,
}
#[async_trait]
pub trait LoopHooks: Send + Sync {
async fn before_round(&self, _round: usize, _ctx: &HookCtx) {}
async fn after_round(&self, _round: usize, _ctx: &HookCtx) {}
/// May MUTATE the call's arguments or veto it (Reject). Covers diff-preview
/// bracketing and per-tool normalizations.
async fn pre_tool_call(&self, _call: &mut PendingToolCall, _ctx: &HookCtx) -> HookVerdict {
HookVerdict::Allow
}
/// Covers persistence of activated tools, discovery, file-change
/// notifications, telemetry.
async fn post_tool_call(&self, _call: &PendingToolCall, _outcome: &CallOutcome, _ctx: &HookCtx) {}
async fn on_turn_end(&self, _outcome: &TurnOutcome, _ctx: &HookCtx) {}
/// Fired after a compaction (blueprint §9): hosts re-anchor DTL
/// activations to the first surviving message here.
async fn on_compacted(&self, _frame: FrameId, _covered: MessageId, _first_surviving: MessageId) {}
}
+119
View File
@@ -0,0 +1,119 @@
//! `HumanChannel` + the shipped `ask_user` tool: synchronous
//! question-to-a-human from inside a tool call.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::events::EventSink;
use crate::ids::ToolCallId;
use crate::store::{CallState, HistoryStore};
use crate::tool::{Tool, ToolCtx, ToolFailure, ToolOutput};
/// A question posed to a human.
#[derive(Debug, Clone)]
pub struct Question {
pub title: String,
pub question: String,
pub suggested: Vec<String>,
/// The tool call asking (for UI correlation).
pub call: ToolCallId,
/// The frame asking (for event tagging).
pub frame: crate::ids::FrameId,
}
/// The human channel closed while waiting (WS down, user gone).
#[derive(Debug, Clone, Copy)]
pub struct HumanGone;
impl std::fmt::Display for HumanGone {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("human channel closed")
}
}
impl std::error::Error for HumanGone {}
#[async_trait]
pub trait HumanChannel: Send + Sync {
/// Block until an answer arrives. `Err(HumanGone)` = the channel closed:
/// the tool returns [`ToolFailure::Suspend`] and the call stays
/// `AwaitingHuman` for a later resume.
async fn ask(&self, q: Question, events: &EventSink) -> Result<String, HumanGone>;
}
/// The shipped `ask_user` tool. Marks the call `AwaitingHuman` BEFORE
/// suspending (durability rule: a crash mid-question must be recoverable),
/// then blocks on the channel.
pub struct AskUserTool {
channel: Arc<dyn HumanChannel>,
store: Arc<dyn HistoryStore>,
name: String,
}
impl AskUserTool {
pub fn new(channel: Arc<dyn HumanChannel>, store: Arc<dyn HistoryStore>) -> Self {
Self { channel, store, name: "ask_user".to_string() }
}
/// Register under a legacy name (Skald's `ask_user_clarification`, D11).
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
}
#[async_trait]
impl Tool for AskUserTool {
fn name(&self) -> &str { &self.name }
fn definition(&self) -> Value {
json!({
"type": "function",
"function": {
"name": self.name,
"description": "Ask the user a clarifying question and wait for the answer.",
"parameters": {
"type": "object",
"properties": {
"title": { "type": "string", "description": "Short title of the question" },
"question": { "type": "string", "description": "The question to ask" },
"suggested": { "type": "array", "items": { "type": "string" },
"description": "Optional suggested answers" },
"suggested_answers": { "type": "array", "items": { "type": "string" },
"description": "Optional suggested answers (legacy alias of `suggested`)" }
},
"required": ["question"]
}
}
})
}
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
let suggested = args["suggested"]
.as_array()
.or_else(|| args["suggested_answers"].as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
let q = Question {
title: args["title"].as_str().unwrap_or("Question").to_string(),
question: args["question"].as_str().unwrap_or("").to_string(),
suggested,
call: ctx.call_id,
frame: ctx.frame,
};
// Durability FIRST: the call must survive a crash as AwaitingHuman.
self.store
.set_call_state(ctx.call_id, CallState::AwaitingHuman)
.await
.map_err(|e| ToolFailure::Failed(format!("ask_user: store error: {e}")))?;
let events = EventSink::from_extensions(&ctx.extensions)
.ok_or_else(|| ToolFailure::Failed("ask_user: no EventSink in extensions".into()))?;
match self.channel.ask(q, &events).await {
Ok(answer) => Ok(ToolOutput::Text(answer)),
Err(HumanGone) => Err(ToolFailure::Suspend),
}
}
}
+54
View File
@@ -0,0 +1,54 @@
//! Opaque id newtypes. The store contract requires `MessageId` and `ToolCallId`
//! to be **monotonically increasing per frame**: a concurrent fan-out allocates
//! ids in call order BEFORE execution, and the model reconstructs results by id.
use std::fmt;
/// Identifies a conversation (Skald: `"session:42"`; InMemory: any string).
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ConversationId(pub String);
impl ConversationId {
pub fn new(s: impl Into<String>) -> Self { Self(s.into()) }
pub fn as_str(&self) -> &str { &self.0 }
}
impl fmt::Display for ConversationId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) }
}
impl From<&str> for ConversationId {
fn from(s: &str) -> Self { Self(s.to_string()) }
}
impl From<String> for ConversationId {
fn from(s: String) -> Self { Self(s) }
}
macro_rules! int_id {
($name:ident, $doc:literal) => {
#[doc = $doc]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct $name(pub i64);
impl $name {
pub fn get(self) -> i64 { self.0 }
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) }
}
impl From<i64> for $name {
fn from(v: i64) -> Self { Self(v) }
}
};
}
int_id!(FrameId, "A conversation frame (root frame = the conversation; children = sub-agents).");
int_id!(MessageId, "A stored message. Monotonically increasing per frame.");
int_id!(ToolCallId, "A stored tool call. Monotonically increasing per frame.");
int_id!(TaskId, "An async delegated task.");
int_id!(SummaryId, "A compaction summary.");
/// Key of a model inside a `ModelSelector` ("kimi-k3", "claude-sonnet-4", …).
pub type ModelId = String;
+610
View File
@@ -0,0 +1,610 @@
//! The kernel — `LlmLoop`. It owns ONLY control flow: round loop, model
//! fallback, tool fan-out, recording. It knows nothing about agents, approval
//! rules, MCP, compaction or recovery (blueprint §5).
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::anyhow;
use futures::StreamExt as _;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::context::{AssembleInput, ContextAssembler};
use crate::events::{EventSink, LoopEvent, PendingToolCall};
use crate::gate::{Gate, GateDecision, PendingCall};
use crate::hooks::{HookCtx, HookVerdict, LoopHooks};
use crate::ids::{FrameId, MessageId, ModelId};
use crate::manager::LoopParams;
use crate::model::{
ModelHandle, ModelRequest, ModelResponse, ModelSelector, RetryPolicy, StreamDelta, Usage,
};
use crate::store::{CallOutcome, HistoryStore, NewCall, NewMessage};
use crate::tool::{ExecutionOutcome, ToolCtx, drive_execution};
/// The terminal outcome of a turn.
#[derive(Debug, Clone)]
pub enum TurnOutcome {
Final {
content: String,
message_id: MessageId,
usage: Usage,
reasoning: Option<String>,
},
Cancelled,
/// Round budget exhausted.
Exhausted,
}
/// Shared dependencies the manager hands to every loop.
pub(crate) struct KernelDeps {
pub(crate) models: Arc<dyn ModelSelector>,
pub(crate) store: Arc<dyn HistoryStore>,
pub(crate) gate: Arc<dyn Gate>,
pub(crate) hooks: Vec<Arc<dyn LoopHooks>>,
pub(crate) assembler: Arc<dyn ContextAssembler>,
pub(crate) max_rounds: usize,
pub(crate) max_parallel_calls: usize,
pub(crate) retry: RetryPolicy,
}
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Correlation id for host-side payload logging (one per attempt).
fn mint_request_id() -> String {
let n = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{nanos:032x}-{n:08x}")
}
/// Run one loop to completion. Spawned by the manager; the `token` is cloned
/// by value through the whole call tree — never re-read from a field mid-turn.
pub(crate) async fn run(
deps: Arc<KernelDeps>,
params: LoopParams,
token: CancellationToken,
events: EventSink,
) -> crate::Result<TurnOutcome> {
let frame = params.frame;
let parent = params.parent_frame;
let store = deps.store.clone();
let assembler = params.assembler.clone().unwrap_or_else(|| deps.assembler.clone());
let hook_ctx = || HookCtx {
conversation: params.conversation.clone(),
frame,
agent: params.agent.clone(),
store: store.clone(),
events: events.clone(),
};
// ToolCtx extensions: host extensions + the event sink + the turn's tool
// set, so shipped tools (ask_user, activate_tools, delegate) reach what
// they need.
let tool_extensions = || tool_extensions(&params, &events);
events.emit(frame, parent, LoopEvent::TurnStarted);
// Per-loop selector override (sub-agents with their own strength, D14).
let selector: &Arc<dyn ModelSelector> = params.selector.as_ref().unwrap_or(&deps.models);
// First selection of the turn.
let mut handle: ModelHandle = match selector.select(&params.model_hint, &[]).await {
Ok(h) => h,
Err(e) => {
events.emit(frame, parent, LoopEvent::Error(format!("model selection failed: {e}")));
return Err(e);
}
};
for round in 0..deps.max_rounds {
if token.is_cancelled() {
return finish(TurnOutcome::Cancelled, &deps, &hook_ctx(), &events, frame, parent).await;
}
for h in &deps.hooks {
h.before_round(round, &hook_ctx()).await;
}
events.emit(frame, parent, LoopEvent::RoundStarted { round });
// Live input (pull-based, blueprint D10): user messages queued mid-turn.
if let Some(input) = &params.live_input {
for msg in input.drain().await {
let id = store.append(frame, msg.clone()).await?;
events.emit(frame, parent, LoopEvent::UserMessage {
message_id: id,
content: msg.content,
synthetic: msg.synthetic,
metadata: msg.metadata,
});
}
}
let turn_info = crate::context::TurnInfo {
conversation: params.conversation.clone(),
frame,
agent: params.agent.clone(),
user_message: params.meta.user_message.clone(),
};
let system = params.system.system_context(&turn_info).await?;
let mut messages = assembler
.build(&store, &AssembleInput {
frame,
system: system.clone(),
model: handle.info.clone(),
round,
})
.await?;
let mut defs = params.tools.defs(&handle.info);
// ── one LLM call with fallback ──
let mut tried: Vec<ModelId> = vec![handle.id.clone()];
let response: ModelResponse = loop {
let (delta_tx, forwarder) = spawn_delta_forwarder(&events, frame, parent);
let req = ModelRequest {
messages: messages.clone(),
tools: defs.clone(),
model: handle.id.clone(),
max_tokens: None,
temperature: None,
request_id: mint_request_id(),
conversation: params.conversation.clone(),
frame,
extras: handle.info.extras.clone(),
log: None,
};
let result = tokio::select! {
biased;
_ = token.cancelled() => {
drop(forwarder);
return finish(TurnOutcome::Cancelled, &deps, &hook_ctx(), &events, frame, parent).await;
}
r = handle.model.complete(&req, Some(delta_tx)) => r,
};
// Drain deltas BEFORE the round's outcome events (ordering).
let _ = forwarder.await;
match result {
Ok(resp) => {
selector.report_success(&handle.id).await;
break resp;
}
Err(e) => {
selector.report_failure(&handle.id, &e.to_string()).await;
let retriable = handle.model.is_retriable(&e);
warn!(model = %handle.id, error = %e, retriable, "llm call failed");
if !retriable || tried.len() >= deps.retry.max_attempts {
events.emit(frame, parent, LoopEvent::LlmFailed {
tried: tried.clone(),
last_error: e.to_string(),
});
return Err(anyhow!("llm call failed on {}: {e}", handle.id));
}
match selector.select(&params.model_hint, &tried).await {
Ok(next) => {
events.emit(frame, parent, LoopEvent::ModelFallback {
from: handle.id.clone(),
to: next.id.clone(),
reason: e.to_string(),
});
handle = next;
tried.push(handle.id.clone());
// Rebuild for the new model: prompt_cache /
// capabilities / DTL mode may differ.
messages = assembler
.build(&store, &AssembleInput {
frame,
system: system.clone(),
model: handle.info.clone(),
round,
})
.await?;
defs = params.tools.defs(&handle.info);
}
Err(sel_err) => {
events.emit(frame, parent, LoopEvent::LlmFailed {
tried: tried.clone(),
last_error: format!("{e}; no fallback: {sel_err}"),
});
return Err(anyhow!("llm call failed on {} and no fallback: {e}", handle.id));
}
}
}
}
};
match response {
ModelResponse::Message { content, reasoning, usage, .. } => {
let id = store
.append(frame, NewMessage::assistant(content.clone(), reasoning.clone()))
.await?;
store.set_usage(id, &usage).await?;
if usage.truncated {
events.emit(frame, parent, LoopEvent::Truncated { output_tokens: usage.output_tokens });
}
events.emit(frame, parent, LoopEvent::Done {
message_id: id,
content: content.clone(),
usage: usage.clone(),
reasoning: reasoning.clone(),
});
let outcome = TurnOutcome::Final { content, message_id: id, usage, reasoning };
return finish(outcome, &deps, &hook_ctx(), &events, frame, parent).await;
}
ModelResponse::ToolCalls { content, calls, reasoning, usage, .. } => {
let msg_id = store
.append(frame, NewMessage::assistant(content.clone(), reasoning.clone()))
.await?;
store.set_usage(msg_id, &usage).await?;
if !content.is_empty() || usage.is_present() {
events.emit(frame, parent, LoopEvent::Thinking {
message_id: msg_id,
content,
usage,
reasoning,
});
}
let fan_out =
calls.len() >= 2 && calls.iter().all(|c| {
params
.tools
.find(&c.name)
.is_some_and(|t| t.concurrency_safe(&c.arguments))
});
if fan_out {
if let Some(outcome) = run_fan_out(
&deps, &params, &events, &token, msg_id, &calls, tool_extensions(),
)
.await?
{
return finish(outcome, &deps, &hook_ctx(), &events, frame, parent).await;
}
} else if let Some(outcome) = run_sequential(
&deps, &params, &events, &token, msg_id, &calls, tool_extensions(),
)
.await?
{
return finish(outcome, &deps, &hook_ctx(), &events, frame, parent).await;
}
}
}
for h in &deps.hooks {
h.after_round(round, &hook_ctx()).await;
}
}
finish(TurnOutcome::Exhausted, &deps, &hook_ctx(), &events, frame, parent).await
}
/// What a tool call sees: the host's extensions plus the event sink and the
/// turn's tool set (shipped tools — ask_user, activate_tools, delegate — reach
/// what they need through them). Shared with [`crate::recovery`], which
/// re-executes a call outside a round and must hand it the same context.
pub(crate) fn tool_extensions(
params: &LoopParams,
events: &EventSink,
) -> crate::tool::Extensions {
let mut ext = params.extensions.clone();
ext.insert(Arc::new(events.clone()));
ext.insert(Arc::new(crate::tool::SharedToolSet(params.tools.clone())));
ext
}
/// Terminal helper: hooks.on_turn_end (+ Cancelled event) then return.
async fn finish(
outcome: TurnOutcome,
deps: &Arc<KernelDeps>,
ctx: &HookCtx,
events: &EventSink,
frame: FrameId,
parent: Option<FrameId>,
) -> crate::Result<TurnOutcome> {
if matches!(outcome, TurnOutcome::Cancelled) {
events.emit(frame, parent, LoopEvent::Cancelled);
}
for h in &deps.hooks {
h.on_turn_end(&outcome, ctx).await;
}
Ok(outcome)
}
/// Map streamed deltas to bus events; drained before the round's outcomes.
fn spawn_delta_forwarder(
events: &EventSink,
frame: FrameId,
parent: Option<FrameId>,
) -> (mpsc::Sender<StreamDelta>, tokio::task::JoinHandle<()>) {
let (tx, mut rx) = mpsc::channel::<StreamDelta>(256);
let events = events.clone();
let handle = tokio::spawn(async move {
while let Some(delta) = rx.recv().await {
let (kind, text) = match delta {
StreamDelta::Text(t) => (crate::events::DeltaKind::Content, t),
StreamDelta::Reasoning(t) => (crate::events::DeltaKind::Reasoning, t),
};
events.emit(frame, parent, LoopEvent::TokenDelta { kind, text });
}
});
(tx, handle)
}
/// Sequential tool-call path (a lone call, or any mixed batch). Returns
/// `Ok(Some(outcome))` when the turn must end (cancel/suspend).
async fn run_sequential(
deps: &Arc<KernelDeps>,
params: &LoopParams,
events: &EventSink,
token: &CancellationToken,
msg_id: MessageId,
calls: &[crate::model::ToolCall],
ext: crate::tool::Extensions,
) -> crate::Result<Option<TurnOutcome>> {
let store = deps.store.clone();
for call in calls {
if token.is_cancelled() {
return Ok(Some(TurnOutcome::Cancelled));
}
let ptc = record_call(&store, events, params, msg_id, call).await?;
let pre = pre_execution(deps, params, events, token, &ptc).await?;
let tool = match pre {
PreExecution::Run(tool) => tool,
PreExecution::Resolved(outcome) => {
record_outcome(deps, params, events, &store, &ptc, outcome).await?;
continue;
}
PreExecution::TurnCancelled => return Ok(Some(TurnOutcome::Cancelled)),
PreExecution::Suspended => return Ok(Some(TurnOutcome::Cancelled)),
};
let ctx = ToolCtx {
conversation: params.conversation.clone(),
frame: params.frame,
agent: params.agent.clone(),
call_id: ptc.id,
cancel: token.clone(),
extensions: ext.clone(),
};
let exec = tool.start(ptc.arguments.clone(), &ctx);
match drive_execution(&*exec, token).await {
ExecutionOutcome::Suspended => {
// The call STAYS AwaitingHuman (the tool marked it) — no resolve.
return Ok(Some(TurnOutcome::Cancelled));
}
outcome => {
record_outcome(deps, params, events, &store, &ptc, outcome.into_call_outcome())
.await?;
}
}
}
Ok(None)
}
/// The concurrent fan-out (generalized sub-agent batch, blueprint §5): ids
/// allocated in order (phase 1), execution concurrent and bounded (phase 2),
/// recording in order (phase 3).
async fn run_fan_out(
deps: &Arc<KernelDeps>,
params: &LoopParams,
events: &EventSink,
token: &CancellationToken,
msg_id: MessageId,
calls: &[crate::model::ToolCall],
ext: crate::tool::Extensions,
) -> crate::Result<Option<TurnOutcome>> {
let store = deps.store.clone();
// ── Phase 1: sequential, in call order ──
let mut ptcs = Vec::with_capacity(calls.len());
for call in calls {
ptcs.push(record_call(&store, events, params, msg_id, call).await?);
}
// ── Phase 2: concurrent, bounded ──
let futs: Vec<_> = ptcs
.iter()
.enumerate()
.map(|(idx, ptc)| phase2_one(deps, params, events, token.clone(), ext.clone(), idx, ptc))
.collect();
let results: HashMap<usize, Phase2> = futures::stream::iter(futs)
.buffer_unordered(deps.max_parallel_calls.max(1))
.collect()
.await;
// ── Phase 3: sequential, in call order ──
let mut suspended = false;
for (idx, ptc) in ptcs.iter().enumerate() {
match results.get(&idx) {
Some(Phase2::Suspended) => {
// Stays AwaitingHuman; the turn ends after recording the rest.
suspended = true;
}
Some(Phase2::Done(outcome)) => {
record_outcome(deps, params, events, &store, ptc, outcome.clone()).await?;
}
None => {
record_outcome(
deps, params, events, &store, ptc,
CallOutcome::Failed("internal: fan-out result missing".into()),
)
.await?;
}
}
}
if suspended {
return Ok(Some(TurnOutcome::Cancelled));
}
if token.is_cancelled() {
return Ok(Some(TurnOutcome::Cancelled));
}
Ok(None)
}
enum Phase2 {
Done(CallOutcome),
Suspended,
}
/// One fanned-out call: gate → hooks.pre → execute. An explicit async fn (not
/// a closure) so the futures are uniform and the borrows are higher-ranked.
async fn phase2_one<'a>(
deps: &'a Arc<KernelDeps>,
params: &'a LoopParams,
events: &'a EventSink,
token: CancellationToken,
ext: crate::tool::Extensions,
idx: usize,
ptc: &'a PendingToolCall,
) -> (usize, Phase2) {
let phase = match pre_execution(deps, params, events, &token, ptc).await {
Ok(PreExecution::Run(tool)) => {
let ctx = ToolCtx {
conversation: params.conversation.clone(),
frame: params.frame,
agent: params.agent.clone(),
call_id: ptc.id,
cancel: token.clone(),
extensions: ext,
};
let exec = tool.start(ptc.arguments.clone(), &ctx);
match drive_execution(&*exec, &token).await {
ExecutionOutcome::Suspended => Phase2::Suspended,
outcome => Phase2::Done(outcome.into_call_outcome()),
}
}
Ok(PreExecution::Resolved(outcome)) => Phase2::Done(outcome),
Ok(PreExecution::TurnCancelled) => Phase2::Done(CallOutcome::Cancelled),
Ok(PreExecution::Suspended) => Phase2::Suspended,
Err(e) => Phase2::Done(CallOutcome::Failed(format!("pre-execution error: {e}"))),
};
(idx, phase)
}
/// Phase-1 shared by both paths: allocate the id and emit `ToolCallStarted`.
async fn record_call(
store: &Arc<dyn HistoryStore>,
events: &EventSink,
params: &LoopParams,
msg_id: MessageId,
call: &crate::model::ToolCall,
) -> crate::Result<PendingToolCall> {
let id = store
.append_call(msg_id, NewCall {
provider_id: if call.id.is_empty() { None } else { Some(call.id.clone()) },
name: call.name.clone(),
arguments: call.arguments.clone(),
})
.await?;
events.emit(params.frame, params.parent_frame, LoopEvent::ToolCallStarted {
id,
message_id: msg_id,
name: call.name.clone(),
args: call.arguments.clone(),
});
Ok(PendingToolCall {
id,
message_id: msg_id,
provider_id: Some(call.id.clone()).filter(|s| !s.is_empty()),
name: call.name.clone(),
arguments: call.arguments.clone(),
})
}
pub(crate) enum PreExecution {
Run(Arc<dyn crate::tool::Tool>),
Resolved(CallOutcome),
TurnCancelled,
/// The gate suspended awaiting a human: the call STAYS `AwaitingHuman`
/// (never resolved) and the turn ends.
Suspended,
}
/// Gate + hooks.pre + tool lookup — shared by the sequential path, the
/// fan-out and [`crate::recovery`]'s re-execution of an interrupted call.
pub(crate) async fn pre_execution(
deps: &Arc<KernelDeps>,
params: &LoopParams,
events: &EventSink,
token: &CancellationToken,
ptc: &PendingToolCall,
) -> crate::Result<PreExecution> {
let pending = PendingCall {
id: ptc.id,
name: ptc.name.clone(),
args: ptc.arguments.clone(),
frame: params.frame,
parent_frame: params.parent_frame,
agent: params.agent.clone(),
extensions: params.extensions.clone(),
};
let decision = tokio::select! {
biased;
_ = token.cancelled() => return Ok(PreExecution::TurnCancelled),
d = deps.gate.check(&pending, events) => d,
};
match decision {
GateDecision::Reject { reason } => {
return Ok(PreExecution::Resolved(CallOutcome::Rejected { reason }));
}
GateDecision::Suspend => return Ok(PreExecution::Suspended),
GateDecision::Allow => {}
}
let mut ptc_mut = ptc.clone();
let hook_ctx = HookCtx {
conversation: params.conversation.clone(),
frame: params.frame,
agent: params.agent.clone(),
store: deps.store.clone(),
events: events.clone(),
};
for h in &deps.hooks {
if let HookVerdict::Reject { reason } = h.pre_tool_call(&mut ptc_mut, &hook_ctx).await {
return Ok(PreExecution::Resolved(CallOutcome::Rejected { reason }));
}
}
match params.tools.find(&ptc.name) {
Some(tool) => Ok(PreExecution::Run(tool)),
None => Ok(PreExecution::Resolved(CallOutcome::Failed(format!(
"unknown tool '{}' (not in this turn's tool set)",
ptc.name
)))),
}
}
/// Phase-3 shared by both paths (and by recovery): hooks.post → resolve → emit.
pub(crate) async fn record_outcome(
deps: &Arc<KernelDeps>,
params: &LoopParams,
events: &EventSink,
store: &Arc<dyn HistoryStore>,
ptc: &PendingToolCall,
outcome: CallOutcome,
) -> crate::Result<()> {
let hook_ctx = HookCtx {
conversation: params.conversation.clone(),
frame: params.frame,
agent: params.agent.clone(),
store: store.clone(),
events: events.clone(),
};
for h in &deps.hooks {
h.post_tool_call(ptc, &outcome, &hook_ctx).await;
}
store.resolve_call(ptc.id, &outcome).await?;
events.emit(params.frame, params.parent_frame, LoopEvent::ToolCallFinished {
id: ptc.id,
outcome,
});
Ok(())
}
+96
View File
@@ -0,0 +1,96 @@
//! `agent-loop` — a reusable LLM agent-loop kernel.
//!
//! The crate owns the **control flow** of a tool-calling agent loop (round loop,
//! model fallback, parallel tool fan-out, streaming deltas, cancellation) and the
//! **LLM clients + protocols** (OpenAI-compatible, Anthropic, Ollama, LM Studio;
//! SSE; dynamic tool loading wire semantics). It knows nothing about databases,
//! agents, MCP, approval rules or Docker: the host implements the trait surface
//! (`Model`, `ModelSelector`, `HistoryStore`, `ContextAssembler`,
//! `SystemContextSource`, `Tool`, `ToolSet`, `Gate`, `LoopHooks`, `HumanChannel`,
//! `ActivationSource`, `ToolActivator`) or uses the shipped defaults.
//!
//! Design document: `blueprint/project-loop.md` (Skald workspace).
pub mod activation;
pub mod compaction;
pub mod context;
pub mod delegate;
pub mod events;
pub mod gate;
pub mod hooks;
pub mod human;
pub mod ids;
pub mod kernel;
pub mod manager;
pub mod model;
pub mod models;
pub mod projection;
pub mod recovery;
pub mod store;
pub mod store_memory;
pub mod testing;
pub mod tool;
/// Re-exported so implementors of the crate's async traits can write
/// `#[agent_loop::async_trait]` without a direct dependency.
pub use async_trait::async_trait;
/// Application name sent as the `X-Title` header by the shipped clients
/// (OpenRouter rankings). Clients accept an override.
pub const APP_NAME: &str = "Skald";
/// Crate-wide result type for host-implemented traits.
pub type Result<T> = anyhow::Result<T>;
pub mod prelude {
pub use crate::activation::{
ActivateToolsTool, Activation, ActivationSource, ToolActivator, ToolRendering,
};
pub use crate::compaction::{
Compaction, CompactionMode, CompactionOutcome, CompactionPrompt, should_compact,
};
pub use crate::context::{
AssembleInput, ContextAssembler, LinearAssembler, StaticSystemContext, SystemContext,
SystemContextSource, TurnInfo,
};
pub use crate::delegate::{
AgentCatalog, AgentKind, AgentProfile, AgentSummary, AsyncExecutor, AsyncResultSink,
AsyncSpec, CompletedTask, DelegateTool, FilteredToolSet, InProcessExecutor, StaticCatalog,
StoreSink, TaskHandle, ToolSelection,
};
pub use crate::events::{DeltaKind, Event, EventSink, LoopEvent};
pub use crate::gate::{AllowAll, DenyList, Gate, GateDecision, PendingCall};
pub use crate::hooks::{HookCtx, HookVerdict, LoopHooks};
pub use crate::human::{AskUserTool, HumanChannel, HumanGone, Question};
pub use crate::ids::{
ConversationId, FrameId, MessageId, ModelId, SummaryId, TaskId, ToolCallId,
};
pub use crate::manager::{
LiveInput, LoopManager, LoopManagerBuilder, LoopParams, StartError, TurnHandle, TurnMeta,
TurnParams,
};
pub use crate::model::{
Model, ModelError, ModelHandle, ModelHint, ModelInfo, ModelRequest, ModelResponse,
ModelSelector, RawMeta, RetryPolicy, SingleModel, StaticModels, StreamDelta, ToolCall,
Usage,
};
pub use crate::recovery::{
HumanDecision, PendingPolicy, Recovery, RecoveryPolicy, RecoveryReport, RunningPolicy,
};
pub use crate::projection::{
MediaBlob, MediaBudget, MediaKind, MediaSource, Projection, ProjectionHooks,
ReasoningEcho, ResultLimit, ToolResultDigest,
};
pub use crate::store::{
CallOutcome, CallState, FrameRecord, FrameSpec, HistoryStore, NewCall, NewMessage,
NewSummary, Role, StoredCall, StoredMessage, StoredSummary,
};
pub use crate::tool::{
Extensions, MediaRef, RestartHint, SimpleExecution, Tool, ToolCtx, ToolExecution,
ToolFailure, ToolOutput, ToolSet, Visibility, drive_execution,
};
pub use crate::{APP_NAME, Result};
pub use async_trait::async_trait;
pub use serde_json::{Value, json};
pub use tokio_util::sync::CancellationToken;
}
+560
View File
@@ -0,0 +1,560 @@
//! `LoopManager` — the singleton (per tenant/user) that owns the event bus and
//! the registry of live loops, and spawns disposable `LlmLoop`s (blueprint D1).
//!
//! Policy: **one live loop per conversation** — `start_turn` rejects a second
//! one (anti double-driving). Serialization/queueing of user messages stays
//! with the host.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::context::{ContextAssembler, LinearAssembler, SystemContextSource};
use crate::events::{Event, EventSink, LoopEvent};
use crate::gate::{AllowAll, Gate};
use crate::hooks::LoopHooks;
use crate::human::HumanChannel;
use crate::ids::{ConversationId, FrameId};
use crate::kernel::{KernelDeps, TurnOutcome};
use crate::model::{ModelHint, ModelSelector, RetryPolicy};
use crate::store::{FrameSpec, HistoryStore, NewMessage, Role};
use crate::tool::{Extensions, ToolSet};
// ── LiveInput ────────────────────────────────────────────────────────────────
/// Pull-based live user input (blueprint D10): drained at round boundaries.
#[async_trait]
pub trait LiveInput: Send + Sync {
async fn drain(&self) -> Vec<NewMessage>;
}
// ── TurnMeta ─────────────────────────────────────────────────────────────────
/// Per-turn metadata.
#[derive(Debug, Clone, Default)]
pub struct TurnMeta {
/// Synthetic turn (TIC/notify) — no user echo semantics.
pub synthetic: bool,
/// Interactive surface (web chat, telegram, …).
pub interactive: bool,
/// Label for UI/logging ("session 42", "cron job X").
pub context_label: Option<String>,
/// The user message that opened the turn (for `TurnInfo`).
pub user_message: Option<String>,
}
// ── TurnParams / LoopParams ──────────────────────────────────────────────────
/// Parameters of a user turn (root frame).
pub struct TurnParams {
/// Root frame (opened by the host or via `LoopManager::open_root`).
pub frame: FrameId,
pub agent: String,
pub system: Arc<dyn SystemContextSource>,
/// Already filtered (visibility/approval).
pub tools: Arc<dyn ToolSet>,
pub model_hint: ModelHint,
/// Per-turn selector override — e.g. this agent's required strength, which
/// is host policy (D14) and varies turn to turn while the manager lives as
/// long as the tenant. `None` = the manager's.
pub selector: Option<Arc<dyn ModelSelector>>,
/// None for sub-agents / cron / resume.
pub live_input: Option<Arc<dyn LiveInput>>,
/// Flows into `ToolCtx.extensions`.
pub extensions: Extensions,
pub meta: TurnMeta,
/// Per-turn assembler override (default: the manager's).
pub assembler: Option<Arc<dyn ContextAssembler>>,
}
/// Parameters of a raw loop (DelegateTool, recovery, background runners).
pub struct LoopParams {
pub conversation: ConversationId,
pub frame: FrameId,
pub parent_frame: Option<FrameId>,
pub agent: String,
pub system: Arc<dyn SystemContextSource>,
pub tools: Arc<dyn ToolSet>,
pub model_hint: ModelHint,
/// Per-loop selector override (e.g. a sub-agent with its own strength,
/// blueprint D14). `None` = the manager's selector.
pub selector: Option<Arc<dyn crate::model::ModelSelector>>,
/// Parent-linked cancellation (DelegateTool passes `ctx.cancel.child_token()`):
/// `None` = a fresh scope. Cancellation stays sticky down the tree.
pub token: Option<CancellationToken>,
pub live_input: Option<Arc<dyn LiveInput>>,
pub extensions: Extensions,
pub meta: TurnMeta,
pub assembler: Option<Arc<dyn ContextAssembler>>,
}
// ── TurnHandle ───────────────────────────────────────────────────────────────
/// Handle of a spawned turn.
pub struct TurnHandle {
pub conversation: ConversationId,
pub frame: FrameId,
/// Clone; cancels THIS turn (sticky down the whole call tree).
pub cancel: CancellationToken,
join: JoinHandle<crate::Result<TurnOutcome>>,
}
impl TurnHandle {
pub async fn join(self) -> crate::Result<TurnOutcome> {
self.join.await.map_err(|e| anyhow::anyhow!("loop task panicked: {e}"))?
}
}
// ── StartError ───────────────────────────────────────────────────────────────
#[derive(Debug)]
pub enum StartError {
/// A loop is already live on this conversation (anti double-driving).
AlreadyRunning,
Store(anyhow::Error),
}
impl std::fmt::Display for StartError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyRunning => write!(f, "a loop is already running on this conversation"),
Self::Store(e) => write!(f, "store error: {e}"),
}
}
}
impl std::error::Error for StartError {}
// ── RunningInfo ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct RunningInfo {
pub conversation: ConversationId,
pub frame: FrameId,
pub agent: String,
}
struct RunningEntry {
frame: FrameId,
agent: String,
cancel: CancellationToken,
}
/// Holds a conversation in the live registry for work that is not one spawned
/// loop (see [`LoopManager::claim`]). Releases on drop, including on an early
/// return or a panic — a leaked claim would lock the conversation for the
/// process's lifetime.
pub(crate) struct ConversationClaim {
conversation: ConversationId,
registry: Arc<Mutex<HashMap<ConversationId, RunningEntry>>>,
token: CancellationToken,
}
impl ConversationClaim {
/// The claim's cancellation token — `/stop` cancels it through the registry.
pub(crate) fn token(&self) -> CancellationToken {
self.token.clone()
}
}
impl Drop for ConversationClaim {
fn drop(&mut self) {
self.registry.lock().unwrap().remove(&self.conversation);
}
}
// ── LoopManager ──────────────────────────────────────────────────────────────
pub struct LoopManager {
deps: Arc<KernelDeps>,
bus: broadcast::Sender<Event<LoopEvent>>,
registry: Arc<Mutex<HashMap<ConversationId, RunningEntry>>>,
human: Option<Arc<dyn HumanChannel>>,
}
impl LoopManager {
pub fn builder() -> LoopManagerBuilder { LoopManagerBuilder::default() }
/// Subscribe to the global event bus (every event tagged with
/// conversation/frame/parent_frame).
pub fn events(&self) -> broadcast::Receiver<Event<LoopEvent>> { self.bus.subscribe() }
/// The host-provided human channel, if any.
pub fn human(&self) -> Option<Arc<dyn HumanChannel>> { self.human.clone() }
/// Convenience: open a root frame on the store.
pub async fn open_root(&self, conv: &ConversationId, spec: FrameSpec) -> crate::Result<FrameId> {
self.deps.store.open_frame(conv, None, spec).await
}
pub fn store(&self) -> Arc<dyn HistoryStore> { self.deps.store.clone() }
// ── user turns ──
/// High-level entry point:
/// 1. rejects when a loop is already live on the conversation;
/// 2. marks a trailing orphan User/Agent message failed (alternation rule
/// for strict APIs);
/// 3. appends the user message + echo event;
/// 4. spawns the loop; returns the handle immediately.
pub async fn start_turn(
&self,
conv: ConversationId,
msg: NewMessage,
mut params: TurnParams,
) -> Result<TurnHandle, StartError> {
{
let registry = self.registry.lock().unwrap();
if registry.contains_key(&conv) {
return Err(StartError::AlreadyRunning);
}
}
// Orphan rule: a trailing User/Agent message with no assistant reply
// breaks strict alternation — mark it failed before appending.
if let Some(last) = self.deps.store.last(params.frame).await.map_err(StartError::Store)?
&& matches!(last.role, Role::User | Role::Agent)
{
self.deps.store.mark_failed(last.id).await.map_err(StartError::Store)?;
}
let events = self.sink(conv.clone());
let id = self.deps.store.append(params.frame, msg.clone()).await.map_err(StartError::Store)?;
events.emit(params.frame, None, LoopEvent::UserMessage {
message_id: id,
content: msg.content.clone(),
synthetic: msg.synthetic,
metadata: msg.metadata.clone(),
});
params.meta.user_message = Some(msg.content);
self.spawn(LoopParams {
conversation: conv,
frame: params.frame,
parent_frame: None,
agent: params.agent,
system: params.system,
tools: params.tools,
model_hint: params.model_hint,
selector: params.selector,
token: None,
live_input: params.live_input,
extensions: params.extensions,
meta: params.meta,
assembler: params.assembler,
})
}
// ── raw loops (DelegateTool, recovery, background runners) ──
/// Spawn a raw loop. Unlike `start_turn` this does NOT enforce the
/// one-loop-per-conversation rule and does NOT register in the live
/// registry: child loops (sub-agents, including concurrent batches) run
/// on the same conversation as their parent and are cancelled through
/// the parent's token tree (`child_token()`), not the registry.
pub async fn start_loop(&self, params: LoopParams) -> Result<TurnHandle, StartError> {
self.spawn_detached(params)
}
fn spawn_detached(&self, params: LoopParams) -> Result<TurnHandle, StartError> {
let conv = params.conversation.clone();
let frame = params.frame;
let token = params.token.clone().unwrap_or_default();
let events = self.sink(conv.clone());
let deps = self.deps.clone();
let turn_token = token.clone();
let join = tokio::spawn(async move { crate::kernel::run(deps, params, turn_token, events).await });
Ok(TurnHandle { conversation: conv, frame, cancel: token, join })
}
fn spawn(&self, params: LoopParams) -> Result<TurnHandle, StartError> {
let conv = params.conversation.clone();
let frame = params.frame;
let agent = params.agent.clone();
let token = CancellationToken::new();
let events = self.sink(conv.clone());
{
let mut registry = self.registry.lock().unwrap();
registry.insert(conv.clone(), RunningEntry {
frame,
agent,
cancel: token.clone(),
});
}
let deps = self.deps.clone();
let registry = self.registry.clone();
let turn_token = token.clone();
let join_conv = conv.clone();
let join = tokio::spawn(async move {
let outcome = crate::kernel::run(deps, params, turn_token, events).await;
registry.lock().unwrap().remove(&join_conv);
outcome
});
Ok(TurnHandle { conversation: conv, frame, cancel: token, join })
}
// ── control ──
/// `/stop`: cancel the live loop on a conversation, if any.
pub fn cancel(&self, conv: &ConversationId) {
if let Some(entry) = self.registry.lock().unwrap().get(conv) {
entry.cancel.cancel();
}
}
pub fn is_running(&self, conv: &ConversationId) -> bool {
self.registry.lock().unwrap().contains_key(conv)
}
/// Take the conversation for something that is not a single spawned loop —
/// a recovery pass, an out-of-band tool resolution. `None` when another
/// loop already holds it (anti double-driving, same rule as `start_turn`).
///
/// The claim registers in the live registry, so `/stop` cancels it and
/// `list_running` shows it; dropping the guard releases it.
pub(crate) fn claim(
&self,
conv: &ConversationId,
frame: FrameId,
agent: &str,
) -> Option<ConversationClaim> {
let token = CancellationToken::new();
let mut registry = self.registry.lock().unwrap();
if registry.contains_key(conv) {
return None;
}
registry.insert(conv.clone(), RunningEntry {
frame,
agent: agent.to_string(),
cancel: token.clone(),
});
Some(ConversationClaim {
conversation: conv.clone(),
registry: self.registry.clone(),
token,
})
}
// ── recovery (blueprint §8) ──
/// A [`Recovery`](crate::recovery::Recovery) bound to this manager.
pub fn recovery(
self: &Arc<Self>,
catalog: Arc<dyn crate::delegate::AgentCatalog>,
policy: crate::recovery::RecoveryPolicy,
) -> crate::recovery::Recovery {
crate::recovery::Recovery::new(self.clone(), catalog, policy)
}
/// Resume a conversation left mid-turn: recovery with the default policy.
pub async fn resume(
self: &Arc<Self>,
conv: &ConversationId,
catalog: Arc<dyn crate::delegate::AgentCatalog>,
root: &TurnParams,
) -> crate::Result<crate::recovery::RecoveryReport> {
self.recovery(catalog, crate::recovery::RecoveryPolicy::default())
.run(conv, root)
.await
}
/// Resolve a call a human answered out of band — the approval card clicked
/// after a restart, when no loop is left holding the oneshot.
///
/// On approval the tool runs with the **gate skipped**: the human just
/// decided, and asking the rules again would either re-prompt or overturn
/// them. The conversation is then recovered, so the model sees the result
/// and continues.
pub async fn resolve_pending(
self: &Arc<Self>,
call: crate::ids::ToolCallId,
decision: crate::recovery::HumanDecision,
catalog: Arc<dyn crate::delegate::AgentCatalog>,
root: &TurnParams,
) -> crate::Result<crate::recovery::RecoveryReport> {
crate::recovery::resolve_pending(self, call, decision, catalog, root).await
}
// ── compaction (blueprint §9) ──
/// A [`Compaction`](crate::compaction::Compaction) on one frame, sharing
/// this manager's store, hooks and event bus. Configure it with the
/// builder methods, then `run()`.
pub fn new_compaction(
&self,
conv: ConversationId,
frame: FrameId,
) -> crate::compaction::Compaction {
crate::compaction::Compaction {
store: self.deps.store.clone(),
selector: self.deps.models.clone(),
hooks: self.deps.hooks.clone(),
events: self.sink(conv.clone()),
conversation: conv,
frame,
mode: crate::compaction::CompactionMode::default(),
hint: ModelHint::default(),
prompt: Arc::new(crate::compaction::DefaultPrompt),
temperature: None,
log: None,
}
}
pub(crate) fn deps(&self) -> &Arc<KernelDeps> {
&self.deps
}
pub(crate) fn sink_for(&self, conv: ConversationId) -> EventSink {
self.sink(conv)
}
/// Global view (UI "running agents").
pub fn list_running(&self) -> Vec<RunningInfo> {
self.registry
.lock()
.unwrap()
.iter()
.map(|(conversation, e)| RunningInfo {
conversation: conversation.clone(),
frame: e.frame,
agent: e.agent.clone(),
})
.collect()
}
/// Cancel all live loops. Joins are detached — callers wanting a drain
/// should hold the handles.
pub async fn shutdown(&self) {
let tokens: Vec<CancellationToken> = self
.registry
.lock()
.unwrap()
.values()
.map(|e| e.cancel.clone())
.collect();
for t in tokens {
t.cancel();
}
}
fn sink(&self, conv: ConversationId) -> EventSink {
EventSink::new(conv, self.bus.clone())
}
}
// ── Builder ──────────────────────────────────────────────────────────────────
pub struct LoopManagerBuilder {
models: Option<Arc<dyn ModelSelector>>,
store: Option<Arc<dyn HistoryStore>>,
gate: Option<Arc<dyn Gate>>,
hooks: Vec<Arc<dyn LoopHooks>>,
human: Option<Arc<dyn HumanChannel>>,
assembler: Option<Arc<dyn ContextAssembler>>,
max_rounds: usize,
max_parallel_calls: usize,
retry: RetryPolicy,
bus_capacity: usize,
}
impl Default for LoopManagerBuilder {
fn default() -> Self {
Self {
models: None,
store: None,
gate: None,
hooks: Vec::new(),
human: None,
assembler: None,
max_rounds: 20,
max_parallel_calls: 4,
retry: RetryPolicy::default(),
bus_capacity: 512,
}
}
}
impl LoopManagerBuilder {
pub fn models(mut self, models: Arc<dyn ModelSelector>) -> Self {
self.models = Some(models);
self
}
pub fn store(mut self, store: Arc<dyn HistoryStore>) -> Self {
self.store = Some(store);
self
}
pub fn gate(mut self, gate: impl Gate + 'static) -> Self {
self.gate = Some(Arc::new(gate));
self
}
pub fn gate_arc(mut self, gate: Arc<dyn Gate>) -> Self {
self.gate = Some(gate);
self
}
pub fn hook(mut self, hook: Arc<dyn LoopHooks>) -> Self {
self.hooks.push(hook);
self
}
pub fn human(mut self, human: Arc<dyn HumanChannel>) -> Self {
self.human = Some(human);
self
}
pub fn assembler(mut self, assembler: Arc<dyn ContextAssembler>) -> Self {
self.assembler = Some(assembler);
self
}
pub fn max_rounds(mut self, n: usize) -> Self {
self.max_rounds = n;
self
}
pub fn max_parallel_calls(mut self, n: usize) -> Self {
self.max_parallel_calls = n;
self
}
pub fn retry(mut self, retry: RetryPolicy) -> Self {
self.retry = retry;
self
}
pub fn bus_capacity(mut self, n: usize) -> Self {
self.bus_capacity = n;
self
}
pub fn build(self) -> crate::Result<LoopManager> {
let deps = Arc::new(KernelDeps {
models: self.models.ok_or_else(|| anyhow::anyhow!("LoopManager: models required"))?,
store: self.store.ok_or_else(|| anyhow::anyhow!("LoopManager: store required"))?,
gate: self.gate.unwrap_or_else(|| Arc::new(AllowAll)),
hooks: self.hooks,
assembler: self.assembler.unwrap_or_else(|| Arc::new(LinearAssembler::new())),
max_rounds: self.max_rounds,
max_parallel_calls: self.max_parallel_calls,
retry: self.retry,
});
let (bus, _) = broadcast::channel(self.bus_capacity);
Ok(LoopManager {
deps,
bus,
registry: Arc::new(Mutex::new(HashMap::new())),
human: self.human,
})
}
}
+444
View File
@@ -0,0 +1,444 @@
//! The `Model` trait (a stateless LLM client), the `ModelSelector` seam
//! (selection + health), and the shipped selectors.
//!
//! `Model` is the boundary the kernel talks to; the shipped clients live in
//! [`crate::models`]. The wire format at this boundary is OpenAI-shaped
//! `serde_json::Value` (blueprint D4) — the Anthropic client translates
//! internally.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use serde_json::Value;
use tokio::sync::mpsc;
use crate::activation::ToolRendering;
use crate::ids::{ConversationId, FrameId, ModelId};
// ── Usage ────────────────────────────────────────────────────────────────────
/// Token/cost accounting of one model call. All fields optional: providers
/// report different subsets (or nothing, e.g. Ollama cost).
#[derive(Debug, Default, Clone)]
pub struct Usage {
pub input_tokens: Option<u32>,
pub output_tokens: Option<u32>,
pub cache_read: Option<u32>,
pub cache_write: Option<u32>,
pub cost_usd: Option<f64>,
/// The model stopped at the token limit (`finish_reason == "length"` /
/// `stop_reason == "max_tokens"`).
pub truncated: bool,
}
impl Usage {
pub fn is_present(&self) -> bool {
self.input_tokens.is_some() || self.output_tokens.is_some()
}
}
// ── ToolCall ─────────────────────────────────────────────────────────────────
/// A tool call requested by the model (wire level).
#[derive(Debug, Clone)]
pub struct ToolCall {
/// The provider's call id ("call_abc", "toolu_01…"). May be empty for
/// providers that don't assign one — the assembler then synthesizes one.
pub id: String,
pub name: String,
pub arguments: Value,
}
// ── StreamDelta ──────────────────────────────────────────────────────────────
/// An incremental piece of a streaming completion. Best-effort UI feedback:
/// senders use `try_send` and drop deltas when the channel is full — streaming
/// must never backpressure the HTTP read. The returned [`ModelResponse`]
/// remains the only authoritative result.
#[derive(Debug, Clone)]
pub enum StreamDelta {
Text(String),
Reasoning(String),
}
// ── RawMeta ──────────────────────────────────────────────────────────────────
/// Raw HTTP metadata captured during a provider call, for host-side payload
/// logging (a `LoggingModel` decorator persists it). Sensitive header values
/// are redacted by the clients before capture.
#[derive(Debug, Default, Clone)]
pub struct RawMeta {
pub request_headers: Option<Value>,
pub request_body: Option<Value>,
pub response_headers: Option<Value>,
pub response_body: Option<Value>,
}
// ── ModelResponse ────────────────────────────────────────────────────────────
/// The authoritative outcome of one model call.
#[derive(Debug, Clone)]
pub enum ModelResponse {
Message {
content: String,
reasoning: Option<String>,
usage: Usage,
raw: Option<RawMeta>,
},
ToolCalls {
content: String,
calls: Vec<ToolCall>,
reasoning: Option<String>,
usage: Usage,
raw: Option<RawMeta>,
},
}
impl ModelResponse {
pub fn message(content: impl Into<String>) -> Self {
Self::Message { content: content.into(), reasoning: None, usage: Usage::default(), raw: None }
}
pub fn tool_calls(content: impl Into<String>, calls: Vec<ToolCall>) -> Self {
Self::ToolCalls { content: content.into(), calls, reasoning: None, usage: Usage::default(), raw: None }
}
pub fn usage(&self) -> &Usage {
match self {
Self::Message { usage, .. } | Self::ToolCalls { usage, .. } => usage,
}
}
pub fn usage_mut(&mut self) -> &mut Usage {
match self {
Self::Message { usage, .. } | Self::ToolCalls { usage, .. } => usage,
}
}
pub fn content(&self) -> &str {
match self {
Self::Message { content, .. } | Self::ToolCalls { content, .. } => content,
}
}
pub fn reasoning(&self) -> Option<&str> {
match self {
Self::Message { reasoning, .. } | Self::ToolCalls { reasoning, .. } => {
reasoning.as_deref()
}
}
}
pub fn raw(&self) -> Option<&RawMeta> {
match self {
Self::Message { raw, .. } | Self::ToolCalls { raw, .. } => raw.as_ref(),
}
}
}
// ── ModelError ───────────────────────────────────────────────────────────────
/// A structured model-call failure. The HTTP status lives in the type, never
/// in a substring of the message — a model id or token count containing
/// "404" must not mis-classify retriability.
#[derive(Debug, Clone)]
pub struct ModelError {
/// HTTP status, when the failure came from an HTTP response. `None` for
/// network/parse/cancellation failures — callers treat those as retriable.
pub status: Option<u16>,
pub message: String,
/// Request/response payload captured at the failing call, so the host's
/// debug log can show what was actually sent even when the provider
/// rejected it. `None` when there was no HTTP round-trip.
pub raw: Option<RawMeta>,
}
impl ModelError {
pub fn new(status: Option<u16>, message: impl Into<String>) -> Self {
Self { status, message: message.into(), raw: None }
}
pub fn with_raw(mut self, raw: RawMeta) -> Self {
self.raw = Some(raw);
self
}
pub fn from_reqwest(err: reqwest::Error) -> Self {
let status = err.status().map(|s| s.as_u16());
Self { status, message: err.to_string(), raw: None }
}
}
impl std::fmt::Display for ModelError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.status {
Some(s) => write!(f, "[HTTP {s}] {}", self.message),
None => f.write_str(&self.message),
}
}
}
impl std::error::Error for ModelError {}
// ── ModelRequest ─────────────────────────────────────────────────────────────
/// One model call. `messages`/`tools` are OpenAI-shaped wire values (D4).
#[derive(Debug, Clone)]
pub struct ModelRequest {
pub messages: Vec<Value>,
pub tools: Vec<Value>,
/// Concrete model name ("kimi-k3", "claude-sonnet-4-5", …).
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
/// Correlation id minted by the kernel at every attempt — for host-side
/// logging/telemetry only, ignored by the kernel itself.
pub request_id: String,
pub conversation: ConversationId,
pub frame: FrameId,
/// Host free-form per-request extras (e.g. reasoning knobs resolved for
/// this model). Merged last by the shipped clients INTO THE REQUEST BODY.
pub extras: Value,
/// Host logging/telemetry correlation (session ids, user id, …).
/// **Never** merged into the request body by the shipped clients — it
/// exists for host decorators (e.g. a `LoggingModel`) only.
pub log: Option<Value>,
}
// ── Model ────────────────────────────────────────────────────────────────────
/// A stateless LLM client. Implementations hold only connection config (base
/// URL, API key). No memory, no database, no session state.
#[async_trait]
pub trait Model: Send + Sync {
/// One completion. `deltas` is a best-effort side-channel for streaming:
/// implementations push [`StreamDelta`]s via `try_send` and never block on
/// it. The returned [`ModelResponse`] is the only authoritative result.
///
/// Shipped clients retry the call buffered when the stream fails before
/// any delta was emitted (providers rejecting `stream` keep working); a
/// mid-stream failure propagates to the caller's fallback logic.
async fn complete(
&self,
req: &ModelRequest,
deltas: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError>;
/// Retriability classification **for this model**. Default — the crate
/// owns the protocols (blueprint D13): 401/403/404/422 are NOT retriable;
/// 400/429/5xx and status-less failures (network, parse, cancel) are.
/// Hosts may override via a wrapping `Model`.
fn is_retriable(&self, err: &ModelError) -> bool {
!matches!(err.status, Some(401 | 403 | 404 | 422))
}
}
// ── ModelInfo / ModelHandle ──────────────────────────────────────────────────
/// Metadata influencing build/serialization. Read by assemblers and `ToolSet`,
/// NEVER interpreted by the kernel (it passes them through).
#[derive(Debug, Clone, Default)]
pub struct ModelInfo {
/// Anthropic-style prompt-cache hints.
pub prompt_cache: bool,
/// "vision", "video", "tool_search", …
pub capabilities: Vec<String>,
/// Dynamic-tool-loading wire protocol (blueprint §4.10). Default `Inline`.
pub tool_rendering: ToolRendering,
/// Host free-form (Skald: context_length, extra_params).
pub extras: Value,
}
impl ModelInfo {
pub fn has_capability(&self, cap: &str) -> bool {
self.capabilities.iter().any(|c| c == cap)
}
}
/// A selected model plus its metadata, as returned by a `ModelSelector`.
#[derive(Clone)]
pub struct ModelHandle {
pub id: ModelId,
pub model: Arc<dyn Model>,
pub info: ModelInfo,
}
// ── ModelHint ────────────────────────────────────────────────────────────────
/// Selection hint: only the explicit pin (blueprint D14). Strength/tiering/
/// priority are host logic, resolved inside the host's `ModelSelector`.
#[derive(Debug, Clone, Default)]
pub struct ModelHint {
/// Explicit model pin — bypasses the host's AUTO selection.
pub name: Option<ModelId>,
}
impl ModelHint {
pub fn name(name: impl Into<ModelId>) -> Self {
Self { name: Some(name.into()) }
}
}
// ── ModelSelector ────────────────────────────────────────────────────────────
/// The selection seam. The kernel calls `select` once per round and again on
/// every fallback (`exclude` = models already tried in this round).
#[async_trait]
pub trait ModelSelector: Send + Sync {
async fn select(&self, hint: &ModelHint, exclude: &[ModelId]) -> crate::Result<ModelHandle>;
/// Health reporting — default no-op. Hosts back these with circuit
/// breakers / status dashboards (Skald: LlmManager mark_success/failure).
async fn report_success(&self, _id: &ModelId) {}
async fn report_failure(&self, _id: &ModelId, _err: &str) {}
}
// ── RetryPolicy ──────────────────────────────────────────────────────────────
/// Fallback budget per round: how many DISTINCT models to try before
/// `LlmFailed`. Retriability classification lives on `Model::is_retriable`.
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_attempts: usize,
}
impl Default for RetryPolicy {
fn default() -> Self { Self { max_attempts: 3 } }
}
// ── Shipped selectors ────────────────────────────────────────────────────────
/// One model, no fallback. Pair it with a shipped client
/// (`models::OpenAiModel::new(...)`) for a complete agent in ~50 lines.
pub struct SingleModel {
handle: ModelHandle,
}
impl SingleModel {
pub fn new(model: impl NamedModel) -> Self {
Self { handle: model.into_handle() }
}
pub fn with_info(model: impl NamedModel, info: ModelInfo) -> Self {
let mut handle = model.into_handle();
handle.info = info;
Self { handle }
}
pub fn from_handle(handle: ModelHandle) -> Self { Self { handle } }
}
#[async_trait]
impl ModelSelector for SingleModel {
async fn select(&self, _hint: &ModelHint, _exclude: &[ModelId]) -> crate::Result<ModelHandle> {
Ok(self.handle.clone())
}
}
/// A model with a self-assigned selector id — implemented by every shipped
/// client (the id defaults to the client's `default_model()`).
pub trait NamedModel: Model + 'static {
/// Selector id and default wire model name for this client.
fn default_model(&self) -> &str;
fn into_handle(self) -> ModelHandle
where
Self: Sized,
{
ModelHandle {
id: self.default_model().to_string(),
model: Arc::new(self),
info: ModelInfo::default(),
}
}
}
/// An ordered list of models: the first non-excluded entry wins, so the list
/// order IS the fallback order (blueprint D14 — "an ordered list given at
/// construction"). `hint.name` pins a list entry by id.
pub struct StaticModels {
handles: Vec<ModelHandle>,
cursor: AtomicUsize,
}
impl StaticModels {
pub fn new(handles: Vec<ModelHandle>) -> Self {
assert!(!handles.is_empty(), "StaticModels requires at least one model");
Self { handles, cursor: AtomicUsize::new(0) }
}
pub fn from_clients(models: Vec<impl NamedModel>) -> Self {
Self::new(models.into_iter().map(|m| m.into_handle()).collect())
}
}
#[async_trait]
impl ModelSelector for StaticModels {
async fn select(&self, hint: &ModelHint, exclude: &[ModelId]) -> crate::Result<ModelHandle> {
// Explicit pin on the first selection of a round: resolve by id.
// (A non-empty `exclude` means the pinned model already failed:
// fall through to the ordered list.)
if let Some(name) = &hint.name
&& exclude.is_empty()
{
return self
.handles
.iter()
.find(|h| &h.id == name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("unknown pinned model '{name}'"));
}
// Rotation start so concurrent conversations don't pile onto handle[0].
let start = self.cursor.fetch_add(1, Ordering::Relaxed) % self.handles.len();
self.handles
.iter()
.cycle()
.skip(start)
.take(self.handles.len())
.find(|h| !exclude.iter().any(|e| e == &h.id))
.cloned()
.ok_or_else(|| anyhow::anyhow!("no alternative models available (all excluded)"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_retriability_classifies_on_status() {
struct M;
#[async_trait]
impl Model for M {
async fn complete(
&self,
_req: &ModelRequest,
_d: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
unreachable!()
}
}
let m = M;
for non_retriable in [401, 403, 404, 422] {
assert!(
!m.is_retriable(&ModelError::new(Some(non_retriable), "x")),
"{non_retriable} must not retry"
);
}
for retriable in [400, 429, 500, 502, 503] {
assert!(
m.is_retriable(&ModelError::new(Some(retriable), "x")),
"{retriable} must retry"
);
}
assert!(m.is_retriable(&ModelError::new(None, "network down")));
}
#[test]
fn model_hint_is_only_a_pin() {
let h = ModelHint::name("kimi-k3");
assert_eq!(h.name.as_deref(), Some("kimi-k3"));
assert!(ModelHint::default().name.is_none());
}
}
@@ -1,3 +1,8 @@
//! Anthropic client (`/v1/messages`). Ported from `llm-client/src/anthropic.rs`
//! onto the `Model` trait — including the DTL conversions (blueprint §4.10):
//! `defer_loading`, `_tool_references` → `tool_reference` blocks, and the
//! `cache_control` breakpoint moved onto the last non-deferred tool.
use std::collections::BTreeMap;
use async_trait::async_trait;
@@ -6,53 +11,84 @@ use serde_json::{Value, json};
use tokio::sync::mpsc;
use tracing::{debug, info, trace, warn};
use crate::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, Role, SseDecoder, StreamDelta, ToolCall, error_response_body, headers_to_json, redact_key};
use super::{SseDecoder, error_response_body, headers_to_json, redact_key};
use crate::APP_NAME;
use crate::model::{
Model, ModelError, ModelRequest, ModelResponse, NamedModel, RawMeta, StreamDelta, ToolCall,
Usage,
};
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
const ANTHROPIC_VERSION: &str = "2023-06-01";
pub struct AnthropicClient {
base_url: String,
api_key: String,
pub struct AnthropicModel {
base_url: String,
api_key: String,
default_model: String,
/// Extra top-level request-body keys merged into every request (e.g. the
/// `thinking` config for extended reasoning). See `apply_extra`.
extra_body: Option<Value>,
http: reqwest::Client,
/// `thinking` config for extended reasoning).
extra_body: Option<Value>,
app_name: String,
http: reqwest::Client,
}
impl AnthropicClient {
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(DEFAULT_BASE_URL, api_key)
impl AnthropicModel {
pub fn new(api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
Self::with_extra_body(api_key, default_model, None)
}
pub fn with_base_url(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
pub fn with_base_url(
base_url: impl Into<String>,
api_key: impl Into<String>,
default_model: impl Into<String>,
) -> Self {
Self {
base_url: base_url.into(),
api_key: api_key.into(),
base_url: base_url.into(),
api_key: api_key.into(),
default_model: default_model.into(),
extra_body: None,
http: reqwest::Client::new(),
app_name: APP_NAME.to_string(),
http: reqwest::Client::new(),
}
}
/// Like `new` but with extra request-body keys (e.g. `{"thinking": {...}}`).
pub fn with_extra_body(api_key: impl Into<String>, extra_body: Option<Value>) -> Self {
pub fn with_extra_body(
api_key: impl Into<String>,
default_model: impl Into<String>,
extra_body: Option<Value>,
) -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_string(),
api_key: api_key.into(),
base_url: DEFAULT_BASE_URL.to_string(),
api_key: api_key.into(),
default_model: default_model.into(),
extra_body,
http: reqwest::Client::new(),
app_name: APP_NAME.to_string(),
http: reqwest::Client::new(),
}
}
/// Merges `extra_body` into `body` and enforces Anthropic's extended-thinking
/// constraints: when `thinking` is enabled, `temperature` is not allowed and
/// `max_tokens` must be strictly greater than `budget_tokens`.
fn apply_extra(&self, body: &mut Value) {
let Some(extra) = self.extra_body.as_ref().and_then(|v| v.as_object()) else { return };
let Some(obj) = body.as_object_mut() else { return };
for (k, v) in extra {
obj.insert(k.clone(), v.clone());
pub fn with_app_name(mut self, app_name: impl Into<String>) -> Self {
self.app_name = app_name.into();
self
}
/// Merges `extra_body` (then the request's own `extras`) into `body` and
/// enforces Anthropic's extended-thinking constraints: when `thinking` is
/// enabled, `temperature` is not allowed and `max_tokens` must be strictly
/// greater than `budget_tokens`.
fn apply_extra(&self, body: &mut Value, req_extras: &Value) {
for extra in [self.extra_body.as_ref(), Some(req_extras).filter(|v| v.is_object())]
.into_iter()
.flatten()
{
let Some(extra) = extra.as_object() else { continue };
let Some(obj) = body.as_object_mut() else { return };
for (k, v) in extra {
obj.insert(k.clone(), v.clone());
}
}
let Some(obj) = body.as_object_mut() else { return };
if obj.get("thinking").map(|t| t["type"] == json!("enabled")).unwrap_or(false) {
obj.remove("temperature");
let budget = obj["thinking"]["budget_tokens"].as_i64().unwrap_or(0);
@@ -67,11 +103,10 @@ impl AnthropicClient {
/// OpenAI: { "type": "function", "function": { "name", "description", "parameters" } }
/// Anthropic: { "name", "description", "input_schema" }
///
/// DTL (tool search): a top-level `defer_loading: true` on the OpenAI tool
/// object is carried through to Anthropic's native `defer_loading` field. When
/// any tool is deferred, the cache breakpoint is placed on the last
/// **non-deferred** tool — a deferred tool cannot also carry `cache_control`
/// (the API 400s), and at least one tool must stay non-deferred anyway.
/// DTL (`DeferredToolReference`): a top-level `defer_loading: true` on the
/// OpenAI tool object is carried through. When any tool is deferred, the
/// cache breakpoint is placed on the last **non-deferred** tool — a
/// deferred tool cannot carry `cache_control` (the API 400s).
fn convert_tools(tools: &[Value]) -> Vec<Value> {
let has_deferred = tools.iter().any(|t| t["defer_loading"].as_bool() == Some(true));
let mut out: Vec<Value> = tools
@@ -90,20 +125,17 @@ impl AnthropicClient {
Some(tool)
})
.collect();
if has_deferred {
if let Some(t) = out.iter_mut().rev().find(|t| t["defer_loading"].as_bool() != Some(true)) {
t["cache_control"] = json!({ "type": "ephemeral" });
}
if has_deferred
&& let Some(t) = out.iter_mut().rev().find(|t| t["defer_loading"].as_bool() != Some(true))
{
t["cache_control"] = json!({ "type": "ephemeral" });
}
out
}
/// Converts OpenAI-format message array to Anthropic format.
///
/// Key differences:
/// - System messages are skipped (extracted separately).
/// - Assistant messages with `tool_calls` become content arrays with `tool_use` blocks.
/// - `tool` role messages are grouped into `user` messages with `tool_result` blocks.
/// Converts OpenAI-format messages to Anthropic format: system extracted
/// separately; assistant tool_calls → tool_use blocks; consecutive `tool`
/// messages grouped into one user message of tool_result blocks.
fn convert_messages(messages: &[Value]) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
let mut i = 0;
@@ -158,16 +190,14 @@ impl AnthropicClient {
}
"tool" => {
// Group all consecutive tool-result messages into a single user message.
// Group consecutive tool results into a single user message.
let mut results: Vec<Value> = Vec::new();
while i < messages.len() && messages[i]["role"].as_str() == Some("tool") {
let tm = &messages[i];
// DTL (custom tool search): a tool result carrying
// `_tool_references` (set by the message builder on an
// `activate_tools` result in AnthropicToolReference mode) becomes a
// `content` array of `tool_reference` blocks, which the API expands
// into the deferred tools' full definitions. Empty/absent → the
// normal text result.
// DTL (`DeferredToolReference`): a tool result carrying
// `_tool_references` becomes a content array of
// `tool_reference` blocks, which the API expands into
// the deferred tools' full definitions.
let content: Value = match tm["_tool_references"].as_array() {
Some(refs) if !refs.is_empty() => Value::Array(
refs.iter()
@@ -194,31 +224,25 @@ impl AnthropicClient {
out
}
/// Assembles the `/v1/messages` request body shared by the buffered and the
/// streaming path (the caller adds `stream` on top).
fn tools_body(&self, system: Option<Value>, messages: Vec<Value>, tools: Vec<Value>, options: &ChatOptions) -> Value {
let max_tokens = options.max_tokens.unwrap_or(4096);
/// Shared `/v1/messages` body (the caller adds `stream` on top).
fn tools_body(&self, system: Option<Value>, messages: Vec<Value>, tools: Vec<Value>, req: &ModelRequest) -> Value {
let max_tokens = req.max_tokens.unwrap_or(4096);
let mut body = json!({
"model": options.model,
"model": req.model,
"max_tokens": max_tokens,
"messages": messages,
"tools": tools,
});
if let Some(sys) = system { body["system"] = sys; }
if let Some(t) = options.temperature { body["temperature"] = t.into(); }
self.apply_extra(&mut body);
if let Some(sys) = system { body["system"] = sys; }
if let Some(t) = req.temperature { body["temperature"] = t.into(); }
self.apply_extra(&mut body, &req.extras);
body
}
/// Collects ALL system-role messages (main prompt, mid-conversation summary,
/// tail_reminder) into the single `system` parameter the Anthropic API accepts.
///
/// Returns a plain string in the common case. When any system message carries
/// **structured** content (a text-block array, e.g. the static prompt tagged
/// with `cache_control` when prompt caching is on), it returns the array form
/// instead so the cache breakpoint survives into `system`. String-content
/// messages become plain text blocks (no cache_control).
/// Collects ALL system-role messages into the single `system` parameter.
/// Structured content (a text-block array with `cache_control`) is kept
/// in array form so the cache breakpoint survives.
fn merged_system(messages: &[Value]) -> Option<Value> {
let sys: Vec<&Value> = messages
.iter()
@@ -260,22 +284,21 @@ impl AnthropicClient {
})
}
/// Sends the request and returns the raw response **without** `error_for_status`,
/// so the tool-calling paths can read the error body and attach the request
/// payload to the `LlmError` (a `reqwest` status error discards the body). The
/// plain `chat` path keeps its own `error_for_status`.
async fn send_request(&self, body: &Value) -> reqwest::Result<reqwest::Response> {
/// Sends the request WITHOUT `error_for_status`, so the caller can read
/// the error body and attach the payload to the `ModelError`.
async fn send_request(&self, body: &Value) -> Result<reqwest::Response, ModelError> {
self.http
.post(self.url())
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("X-Title", core_api::APP_NAME)
.header("X-Title", &self.app_name)
.json(body)
.send()
.await
.map_err(ModelError::from_reqwest)
}
/// Joined `thinking` blocks of a content array, if any (extended thinking).
/// Joined `thinking` blocks of a content array (extended thinking).
fn reasoning_of(content_blocks: &[Value]) -> Option<String> {
let parts: Vec<&str> = content_blocks
.iter()
@@ -285,27 +308,121 @@ impl AnthropicClient {
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
/// SSE streaming path behind `chat_with_tools_raw_streaming`. Anthropic
/// streams typed events (`message_start` / `content_block_*` /
/// `message_delta` / `message_stop`); text and thinking deltas are
/// forwarded to `delta_tx` best-effort while the blocks are accumulated
/// into the same `LlmTurn` the buffered path returns.
/// The buffered path.
async fn buffered(&self, req: &ModelRequest) -> Result<ModelResponse, ModelError> {
let system = Self::merged_system(&req.messages);
let anthropic_messages = Self::convert_messages(&req.messages);
let anthropic_tools = Self::convert_tools(&req.tools);
let body = self.tools_body(system, anthropic_messages, anthropic_tools, req);
debug!(model = %req.model, tools = req.tools.len(), "anthropic: sending request");
trace!(body = %body, "anthropic: request body");
let request_body = body.clone();
let request_headers = self.logged_headers();
let http_resp = self.send_request(&body).await?;
let response_headers = headers_to_json(http_resp.headers());
let status = http_resp.status();
let resp_text = http_resp.text().await.map_err(ModelError::from_reqwest)?;
if !status.is_success() {
return Err(ModelError {
status: Some(status.as_u16()),
message: format!("anthropic: HTTP {status} from {url}\nbody: {resp_text}", url = self.url()),
raw: Some(RawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(error_response_body(resp_text)),
}),
});
}
let resp: Value = serde_json::from_str(&resp_text).map_err(|e| {
ModelError::new(None, format!("anthropic: failed to parse response JSON: {e}\nbody: {resp_text}"))
})?;
let raw = RawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(resp.clone()),
};
let stop_reason = resp["stop_reason"].as_str().unwrap_or("");
let mut usage = Usage {
input_tokens: resp["usage"]["input_tokens"].as_u64().map(|n| n as u32),
output_tokens: resp["usage"]["output_tokens"].as_u64().map(|n| n as u32),
cache_read: resp["usage"]["cache_read_input_tokens"].as_u64().map(|n| n as u32),
cache_write: resp["usage"]["cache_creation_input_tokens"].as_u64().map(|n| n as u32),
cost_usd: None,
truncated: stop_reason == "max_tokens",
};
let content_blocks = resp["content"].as_array().cloned().unwrap_or_default();
info!(model = %req.model, ?usage.input_tokens, ?usage.output_tokens, stop_reason, "anthropic: response received");
if usage.truncated {
warn!(model = %req.model, ?usage.output_tokens, "anthropic: response truncated (max_tokens reached)");
}
let has_tool_use = content_blocks.iter().any(|b| b["type"].as_str() == Some("tool_use"));
let reasoning = Self::reasoning_of(&content_blocks);
// Anthropic sometimes returns stop_reason "end_turn" even when
// tool_use blocks are present — check the blocks directly.
let mut resp_out = if stop_reason == "tool_use" || has_tool_use {
let text: String = content_blocks
.iter()
.filter(|b| b["type"].as_str() == Some("text"))
.filter_map(|b| b["text"].as_str())
.collect::<Vec<_>>()
.join("\n");
usage.truncated = false;
let calls: Vec<ToolCall> = content_blocks
.iter()
.filter(|b| b["type"].as_str() == Some("tool_use"))
.map(|b| ToolCall {
id: b["id"].as_str().unwrap_or("").to_string(),
name: b["name"].as_str().unwrap_or("").to_string(),
arguments: b["input"].clone(),
})
.collect();
ModelResponse::ToolCalls { content: text, calls, reasoning, usage, raw: None }
} else {
let content = content_blocks
.iter()
.find(|b| b["type"].as_str() == Some("text"))
.and_then(|b| b["text"].as_str())
.unwrap_or("")
.to_string();
ModelResponse::Message { content, reasoning, usage, raw: None }
};
match &mut resp_out {
ModelResponse::Message { raw: r, .. } | ModelResponse::ToolCalls { raw: r, .. } => {
*r = Some(raw)
}
}
Ok(resp_out)
}
/// SSE streaming path: Anthropic streams typed events (`message_start` /
/// `content_block_*` / `message_delta`); text and thinking deltas are
/// forwarded best-effort while blocks accumulate into the same
/// `ModelResponse` the buffered path returns.
#[allow(clippy::result_large_err)]
async fn stream_chat(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
req: &ModelRequest,
delta_tx: &mpsc::Sender<StreamDelta>,
emitted: &mut bool,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let system = Self::merged_system(messages);
let anthropic_messages = Self::convert_messages(messages);
let anthropic_tools = Self::convert_tools(tools);
let mut body = self.tools_body(system, anthropic_messages, anthropic_tools, options);
) -> Result<ModelResponse, ModelError> {
let system = Self::merged_system(&req.messages);
let anthropic_messages = Self::convert_messages(&req.messages);
let anthropic_tools = Self::convert_tools(&req.tools);
let mut body = self.tools_body(system, anthropic_messages, anthropic_tools, req);
body["stream"] = json!(true);
debug!(model = %options.model, tools = tools.len(), "anthropic: sending streaming chat_with_tools request");
trace!(body = %body, "anthropic: streaming chat_with_tools request body");
debug!(model = %req.model, tools = req.tools.len(), "anthropic: sending streaming request");
trace!(body = %body, "anthropic: streaming request body");
let request_body = body.clone();
let request_headers = self.logged_headers();
@@ -314,20 +431,17 @@ impl AnthropicClient {
let response_headers = headers_to_json(http_resp.headers());
let status = http_resp.status();
if !status.is_success() {
let resp_text = http_resp.text().await?;
return Err(crate::LlmError {
let resp_text = http_resp.text().await.map_err(ModelError::from_reqwest)?;
return Err(ModelError {
status: Some(status.as_u16()),
message: format!(
"anthropic: HTTP {status} from {url}\nbody: {resp_text}",
url = self.url(),
),
raw_meta: Some(LlmRawMeta {
message: format!("anthropic: HTTP {status} from {url}\nbody: {resp_text}", url = self.url()),
raw: Some(RawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(error_response_body(resp_text)),
}),
}.into());
});
}
/// One content block being accumulated by index.
@@ -345,7 +459,7 @@ impl AnthropicClient {
let mut sse = SseDecoder::new();
let mut byte_stream = http_resp.bytes_stream();
let mut handle_payload = |payload: &str, emitted: &mut bool| -> anyhow::Result<()> {
let mut handle_payload = |payload: &str, emitted: &mut bool| -> Result<(), ModelError> {
let Ok(v) = serde_json::from_str::<Value>(payload) else { return Ok(()) };
match v["type"].as_str().unwrap_or("") {
"message_start" => {
@@ -384,7 +498,6 @@ impl AnthropicClient {
blocks.entry(idx).or_default().buf.push_str(j);
}
}
// signature_delta and unknown deltas carry no displayable text.
_ => {}
}
}
@@ -397,16 +510,15 @@ impl AnthropicClient {
}
}
"error" => {
return Err(anyhow::anyhow!("anthropic: stream error event: {payload}"));
return Err(ModelError::new(None, format!("anthropic: stream error event: {payload}")));
}
// content_block_stop / message_stop / ping: nothing to accumulate.
_ => {}
}
Ok(())
};
while let Some(chunk) = byte_stream.next().await {
let chunk = chunk?;
let chunk = chunk.map_err(ModelError::from_reqwest)?;
for payload in sse.feed(&chunk) {
handle_payload(&payload, emitted)?;
}
@@ -415,14 +527,18 @@ impl AnthropicClient {
handle_payload(&payload, emitted)?;
}
let stop = stop_reason.as_deref().unwrap_or("");
let input_tokens = usage["input_tokens"].as_u64().map(|n| n as u32);
let output_tokens = usage["output_tokens"].as_u64().map(|n| n as u32);
let cache_read_tokens = usage["cache_read_input_tokens"].as_u64().map(|n| n as u32);
let cache_creation_tokens = usage["cache_creation_input_tokens"].as_u64().map(|n| n as u32);
info!(model = %options.model, ?input_tokens, ?output_tokens, stop_reason = stop, "anthropic: streaming response completed");
if stop == "max_tokens" {
warn!(model = %options.model, ?output_tokens, "anthropic: response truncated (max_tokens reached)");
let stop = stop_reason.as_deref().unwrap_or("");
let usage_struct = Usage {
input_tokens: usage["input_tokens"].as_u64().map(|n| n as u32),
output_tokens: usage["output_tokens"].as_u64().map(|n| n as u32),
cache_read: usage["cache_read_input_tokens"].as_u64().map(|n| n as u32),
cache_write: usage["cache_creation_input_tokens"].as_u64().map(|n| n as u32),
cost_usd: None,
truncated: stop == "max_tokens",
};
info!(model = %req.model, ?usage_struct.input_tokens, ?usage_struct.output_tokens, stop_reason = stop, "anthropic: streaming response completed");
if usage_struct.truncated {
warn!(model = %req.model, "anthropic: response truncated (max_tokens reached)");
}
let text_of = |kind: &str| -> String {
@@ -432,35 +548,17 @@ impl AnthropicClient {
.collect::<Vec<_>>()
.join("\n")
};
let reasoning = text_of("thinking");
let reasoning_content = if reasoning.is_empty() { None } else { Some(reasoning) };
let reasoning_text = text_of("thinking");
let reasoning = if reasoning_text.is_empty() { None } else { Some(reasoning_text) };
let tool_blocks: Vec<&Block> = blocks.values().filter(|b| b.kind == "tool_use").collect();
let turn = if !tool_blocks.is_empty() {
let calls = tool_blocks
.iter()
.map(|b| ToolCall {
id: b.id.clone(),
name: b.name.clone(),
arguments: serde_json::from_str(&b.buf).unwrap_or(Value::Object(Default::default())),
})
.collect();
LlmTurn::ToolCalls { content: text_of("text"), calls, input_tokens, output_tokens, reasoning_content, cache_read_tokens, cache_creation_tokens, cost: None }
} else {
let truncated = stop == "max_tokens";
LlmTurn::Message(ChatResponse {
content: text_of("text"), input_tokens, output_tokens, truncated,
reasoning_content, cache_read_tokens, cache_creation_tokens, cost: None,
})
};
// Buffered-shaped response body for the payload log.
let content_log: Vec<Value> = blocks.values().map(|b| match b.kind.as_str() {
"tool_use" => json!({"type": "tool_use", "id": b.id, "name": b.name, "input": serde_json::from_str::<Value>(&b.buf).unwrap_or(json!({}))}),
"thinking" => json!({"type": "thinking", "thinking": b.buf}),
_ => json!({"type": "text", "text": b.buf}),
}).collect();
let raw_meta = LlmRawMeta {
let raw = RawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
@@ -472,16 +570,62 @@ impl AnthropicClient {
})),
};
Ok((turn, Some(raw_meta)))
let mut resp_out = if !tool_blocks.is_empty() {
let calls = tool_blocks
.iter()
.map(|b| ToolCall {
id: b.id.clone(),
name: b.name.clone(),
arguments: serde_json::from_str(&b.buf).unwrap_or(Value::Object(Default::default())),
})
.collect();
ModelResponse::ToolCalls { content: text_of("text"), calls, reasoning, usage: usage_struct, raw: None }
} else {
ModelResponse::Message { content: text_of("text"), reasoning, usage: usage_struct, raw: None }
};
match &mut resp_out {
ModelResponse::Message { raw: r, .. } | ModelResponse::ToolCalls { raw: r, .. } => {
*r = Some(raw)
}
}
Ok(resp_out)
}
}
impl NamedModel for AnthropicModel {
fn default_model(&self) -> &str { &self.default_model }
}
#[async_trait]
impl Model for AnthropicModel {
async fn complete(
&self,
req: &ModelRequest,
deltas: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
match deltas {
None => self.buffered(req).await,
Some(delta_tx) => {
let mut emitted = false;
match self.stream_chat(req, &delta_tx, &mut emitted).await {
Ok(ok) => Ok(ok),
// Pre-stream failure (nothing shown yet): retry buffered.
// A mid-stream failure propagates to the fallback logic.
Err(e) if !emitted => {
debug!(model = %req.model, error = %e, "anthropic: streaming failed before any delta; retrying buffered");
self.buffered(req).await
}
Err(e) => Err(e),
}
}
}
}
}
/// User content arrives either as a plain string or as an OpenAI-style parts
/// array (text + `image_url` data URLs, produced when the resolved model has
/// the `vision` capability). Strings pass through; parts become Anthropic
/// blocks. Video and unknown parts are dropped with a warning — providers
/// gate capabilities upstream, so this should only indicate a misconfigured
/// model row.
/// array (text + `image_url` data URLs + `file` PDF parts). Strings pass
/// through; parts become Anthropic blocks. Unknown parts are dropped with a
/// warning.
fn convert_user_content(content: &Value) -> Value {
let Some(parts) = content.as_array() else {
return Value::String(content.as_str().unwrap_or("").to_string());
@@ -509,8 +653,7 @@ fn convert_user_content(content: &Value) -> Value {
Value::Array(blocks)
}
/// `{"url": "data:<mime>;base64,<data>"}` (or the bare-string shorthand) → an
/// Anthropic base64 image block. Only data URLs are supported.
/// `{"url": "data:<mime>;base64,<data>"}` → an Anthropic base64 image block.
fn parse_data_image(image_url: &Value) -> Option<Value> {
let url = image_url["url"].as_str().or_else(|| image_url.as_str())?;
let (mime, data) = url.strip_prefix("data:")?.split_once(";base64,")?;
@@ -520,9 +663,8 @@ fn parse_data_image(image_url: &Value) -> Option<Value> {
}))
}
/// `{"file_data": "data:application/pdf;base64,<data>"}` → an Anthropic base64
/// `document` block (the native PDF input). Only base64 data URLs are supported;
/// the OpenAI `file` part is what the media pipeline emits for a PDF.
/// `{"file_data": "data:application/pdf;base64,<data>"}` → an Anthropic
/// base64 `document` block (the native PDF input).
fn parse_data_document(file: &Value) -> Option<Value> {
let url = file["file_data"].as_str()?;
let (mime, data) = url.strip_prefix("data:")?.split_once(";base64,")?;
@@ -532,213 +674,6 @@ fn parse_data_document(file: &Value) -> Option<Value> {
}))
}
#[async_trait]
impl ChatbotClient for AnthropicClient {
async fn chat(
&self,
messages: &[Message],
options: &ChatOptions,
) -> anyhow::Result<ChatResponse> {
// Merge all system-role messages into a single `system:` parameter.
let system: Option<String> = {
let parts: Vec<&str> = messages
.iter()
.filter(|m| m.role == Role::System)
.map(|m| m.content.as_str())
.collect();
if parts.is_empty() { None } else { Some(parts.join("\n\n---\n\n")) }
};
let msgs: Vec<Value> = messages
.iter()
.filter(|m| m.role != Role::System)
.map(|m| {
let role = match m.role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => unreachable!(),
};
json!({ "role": role, "content": m.content })
})
.collect();
let max_tokens = options.max_tokens.unwrap_or(4096);
let mut body = json!({
"model": options.model,
"max_tokens": max_tokens,
"messages": msgs,
});
if let Some(sys) = system { body["system"] = sys.into(); }
if let Some(t) = options.temperature { body["temperature"] = t.into(); }
self.apply_extra(&mut body);
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
debug!(model = %options.model, "anthropic: sending chat request");
trace!(body = %body, "anthropic: chat request body");
let resp: Value = self
.http
.post(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
let content = resp["content"]
.as_array()
.and_then(|arr| arr.iter().find(|b| b["type"].as_str() == Some("text")))
.and_then(|block| block["text"].as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content in Anthropic response"))?
.to_string();
let input_tokens = resp["usage"]["input_tokens"].as_u64().map(|n| n as u32);
let output_tokens = resp["usage"]["output_tokens"].as_u64().map(|n| n as u32);
let cache_read_tokens = resp["usage"]["cache_read_input_tokens"].as_u64().map(|n| n as u32);
let cache_creation_tokens = resp["usage"]["cache_creation_input_tokens"].as_u64().map(|n| n as u32);
info!(model = %options.model, ?input_tokens, ?output_tokens, "anthropic: chat response received");
let cost = self.extract_cost(&resp);
Ok(ChatResponse { content, input_tokens, output_tokens, truncated: false, reasoning_content: None, cache_read_tokens, cache_creation_tokens, cost })
}
async fn chat_with_tools(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<LlmTurn> {
self.chat_with_tools_raw(messages, tools, options).await.map(|(t, _)| t)
}
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
// Mid-conversation system messages (compaction summaries, tail
// reminders) are merged into the single `system:` parameter — they
// must not be silently dropped.
let system = Self::merged_system(messages);
let anthropic_messages = Self::convert_messages(messages);
let anthropic_tools = Self::convert_tools(tools);
let body = self.tools_body(system, anthropic_messages, anthropic_tools, options);
debug!(model = %options.model, tools = tools.len(), "anthropic: sending chat_with_tools request");
trace!(body = %body, "anthropic: chat_with_tools request body");
// Capture request metadata for logging.
let request_body = body.clone();
let request_headers = self.logged_headers();
let http_resp = self.send_request(&body).await?;
let response_headers = headers_to_json(http_resp.headers());
let status = http_resp.status();
let resp_text = http_resp.text().await?;
if !status.is_success() {
return Err(crate::LlmError {
status: Some(status.as_u16()),
message: format!(
"anthropic: HTTP {status} from {url}\nbody: {resp_text}",
url = self.url(),
),
raw_meta: Some(LlmRawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(error_response_body(resp_text)),
}),
}.into());
}
let resp: Value = serde_json::from_str(&resp_text)
.map_err(|e| anyhow::anyhow!("anthropic: failed to parse response JSON: {e}\nbody: {resp_text}"))?;
let response_body: Value = serde_json::from_str(&resp_text).unwrap_or(Value::Null);
let raw_meta = LlmRawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(response_body),
};
let stop_reason = resp["stop_reason"].as_str().unwrap_or("");
let input_tokens = resp["usage"]["input_tokens"].as_u64().map(|n| n as u32);
let output_tokens = resp["usage"]["output_tokens"].as_u64().map(|n| n as u32);
let cache_read_tokens = resp["usage"]["cache_read_input_tokens"].as_u64().map(|n| n as u32);
let cache_creation_tokens = resp["usage"]["cache_creation_input_tokens"].as_u64().map(|n| n as u32);
let content_blocks = resp["content"].as_array().cloned().unwrap_or_default();
let cost = self.extract_cost(&resp);
info!(model = %options.model, ?input_tokens, ?output_tokens, stop_reason, "anthropic: chat_with_tools response received");
if stop_reason == "max_tokens" {
warn!(model = %options.model, ?output_tokens, "anthropic: response truncated (max_tokens reached)");
}
let has_tool_use = content_blocks.iter().any(|b| b["type"].as_str() == Some("tool_use"));
let reasoning_content = Self::reasoning_of(&content_blocks);
// Check content blocks directly: Anthropic sometimes returns stop_reason "end_turn"
// even when tool_use blocks are present, so stop_reason alone is not reliable.
let turn = if stop_reason == "tool_use" || has_tool_use {
let text: String = content_blocks
.iter()
.filter(|b| b["type"].as_str() == Some("text"))
.filter_map(|b| b["text"].as_str())
.collect::<Vec<_>>()
.join("\n");
let calls: Vec<ToolCall> = content_blocks
.iter()
.filter(|b| b["type"].as_str() == Some("tool_use"))
.map(|b| ToolCall {
id: b["id"].as_str().unwrap_or("").to_string(),
name: b["name"].as_str().unwrap_or("").to_string(),
arguments: b["input"].clone(),
})
.collect();
LlmTurn::ToolCalls { content: text, calls, input_tokens, output_tokens, reasoning_content, cache_read_tokens, cache_creation_tokens, cost }
} else {
let content = content_blocks
.iter()
.find(|b| b["type"].as_str() == Some("text"))
.and_then(|b| b["text"].as_str())
.unwrap_or("")
.to_string();
let truncated = stop_reason == "max_tokens";
LlmTurn::Message(ChatResponse { content, input_tokens, output_tokens, truncated, reasoning_content, cache_read_tokens, cache_creation_tokens, cost })
};
Ok((turn, Some(raw_meta)))
}
async fn chat_with_tools_raw_streaming(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
delta_tx: mpsc::Sender<StreamDelta>,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let mut emitted = false;
match self.stream_chat(messages, tools, options, &delta_tx, &mut emitted).await {
Ok(ok) => Ok(ok),
// Pre-stream failure (nothing shown yet): retry buffered. A
// mid-stream failure propagates to the model-fallback logic.
Err(e) if !emitted => {
debug!(model = %options.model, error = %e, "anthropic: streaming failed before any delta; retrying buffered");
self.chat_with_tools_raw(messages, tools, options).await
}
Err(e) => Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -751,16 +686,48 @@ mod tests {
json!({"type": "thinking", "thinking": "second"}),
];
assert_eq!(
AnthropicClient::reasoning_of(&blocks),
AnthropicModel::reasoning_of(&blocks),
Some("first\nsecond".to_string())
);
assert_eq!(AnthropicClient::reasoning_of(&[]), None);
assert_eq!(AnthropicModel::reasoning_of(&[]), None);
assert_eq!(
AnthropicClient::reasoning_of(&[json!({"type": "text", "text": "a"})]),
AnthropicModel::reasoning_of(&[json!({"type": "text", "text": "a"})]),
None
);
}
#[test]
fn convert_tools_carries_defer_loading_and_moves_cache_control() {
let tools = vec![
json!({"type":"function","function":{"name":"a","description":"","parameters":{}}}),
json!({"type":"function","function":{"name":"b","description":"","parameters":{}},"defer_loading":true}),
json!({"type":"function","function":{"name":"c","description":"","parameters":{}},"defer_loading":true}),
];
let out = AnthropicModel::convert_tools(&tools);
assert_eq!(out[0]["cache_control"], json!({"type": "ephemeral"}));
assert!(out[0].get("defer_loading").is_none());
assert_eq!(out[1]["defer_loading"], json!(true));
assert!(out[1].get("cache_control").is_none());
assert_eq!(out[2]["defer_loading"], json!(true));
}
#[test]
fn convert_messages_tool_references_become_blocks() {
let messages = vec![
json!({"role":"assistant","content":"","tool_calls":[
{"id":"t1","type":"function","function":{"name":"activate_tools","arguments":"{\"groups\":[\"gmail\"]}"}}
]}),
json!({"role":"tool","tool_call_id":"t1","content":"ok","_tool_references":["mcp__gmail__send"]}),
];
let out = AnthropicModel::convert_messages(&messages);
assert_eq!(out.len(), 2);
let results = out[1]["content"].as_array().unwrap();
assert_eq!(
results[0]["content"],
json!([{ "type": "tool_reference", "tool_name": "mcp__gmail__send" }])
);
}
#[test]
fn user_content_string_passthrough() {
let v = convert_user_content(&json!("hello"));
@@ -791,8 +758,6 @@ mod tests {
#[test]
fn user_content_file_part_becomes_document_block() {
// The OpenAI `file` part (emitted by the media pipeline for a PDF) becomes
// an Anthropic native `document` block.
let v = convert_user_content(&json!([
{ "type": "text", "text": "read this" },
{ "type": "file", "file": { "filename": "a.pdf", "file_data": "data:application/pdf;base64,QUJD" } },
@@ -802,7 +767,6 @@ mod tests {
{ "type": "document", "source": { "type": "base64", "media_type": "application/pdf", "data": "QUJD" } },
]));
// A non-data file_data (or missing) is dropped, not forwarded.
let v = convert_user_content(&json!([
{ "type": "file", "file": { "filename": "a.pdf", "file_data": "https://example.com/a.pdf" } },
]));
+43
View File
@@ -0,0 +1,43 @@
//! LM Studio client — a thin wrapper over [`OpenAiModel`] defaulting to
//! `http://localhost:1234/v1` with no API key. (LM Studio can also be served
//! by a YAML-declared provider; this client is kept for explicit use.)
use async_trait::async_trait;
use tokio::sync::mpsc;
use super::openai::OpenAiModel;
use crate::model::{Model, ModelError, ModelRequest, ModelResponse, NamedModel, StreamDelta};
pub struct LmStudioModel {
inner: OpenAiModel,
}
impl LmStudioModel {
/// `base_url` defaults to `http://localhost:1234/v1` if `None`.
pub fn new(base_url: Option<impl Into<String>>, default_model: impl Into<String>) -> Self {
let url = base_url
.map(|u| u.into())
.unwrap_or_else(|| "http://localhost:1234/v1".to_string());
Self { inner: OpenAiModel::new(url, "", default_model) }
}
}
impl NamedModel for LmStudioModel {
fn default_model(&self) -> &str { self.inner.default_model() }
}
#[async_trait]
impl Model for LmStudioModel {
/// LM Studio is OpenAI-compatible: everything forwards to the inner
/// client (its pre-delta buffered retry covers local builds rejecting
/// `stream_options`).
async fn complete(
&self,
req: &ModelRequest,
deltas: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
self.inner.complete(req, deltas).await
}
fn is_retriable(&self, err: &ModelError) -> bool { self.inner.is_retriable(err) }
}
+42
View File
@@ -0,0 +1,42 @@
//! Shipped `Model` clients (blueprint D13): OpenAI-compatible, Anthropic,
//! Ollama, LM Studio — plus the shared SSE decoder and HTTP helpers.
//!
//! All clients are stateless (connection config only) and share the same
//! failure policy: if a stream dies BEFORE any delta, the client retries
//! buffered on the same model (providers rejecting `stream` keep working); a
//! mid-stream failure propagates to the caller's fallback logic.
pub mod anthropic;
pub mod lm_studio;
pub mod ollama;
pub mod openai;
mod sse;
pub use anthropic::AnthropicModel;
pub use lm_studio::LmStudioModel;
pub use ollama::OllamaModel;
pub use openai::OpenAiModel;
pub(crate) use sse::SseDecoder;
use serde_json::Value;
/// Converts a reqwest `HeaderMap` into a JSON object (for payload logging).
pub(crate) fn headers_to_json(headers: &reqwest::header::HeaderMap) -> Value {
let map: serde_json::Map<String, Value> = headers
.iter()
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("<binary>").into()))
.collect();
Value::Object(map)
}
/// Raw error body → JSON for the payload log: parsed JSON when the provider
/// returned JSON, else the raw text wrapped as a JSON string so a non-JSON
/// body (HTML gateway page) is still preserved verbatim.
pub(crate) fn error_response_body(text: String) -> Value {
serde_json::from_str::<Value>(&text).unwrap_or(Value::String(text))
}
/// Redacted preview of an API key: first 7 chars + "***".
pub(crate) fn redact_key(key: &str) -> String {
if key.len() > 7 { format!("{}***", &key[..7]) } else { "***".to_string() }
}
+102
View File
@@ -0,0 +1,102 @@
//! Ollama client (native `/api/chat` endpoint). Ported from
//! `llm-client/src/ollama.rs`. No streaming, no tool support — tool-call
//! messages are flattened to text, mirroring the previous default behavior.
use async_trait::async_trait;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use crate::model::{Model, ModelError, ModelRequest, ModelResponse, NamedModel, StreamDelta, Usage};
/// Ollama client. Defaults to `http://localhost:11434`. No API key required.
pub struct OllamaModel {
base_url: String,
default_model: String,
http: reqwest::Client,
}
impl OllamaModel {
/// `base_url` defaults to `http://localhost:11434` if `None`.
pub fn new(base_url: Option<impl Into<String>>, default_model: impl Into<String>) -> Self {
let url = base_url
.map(|u| u.into())
.unwrap_or_else(|| "http://localhost:11434".to_string());
Self { base_url: url, default_model: default_model.into(), http: reqwest::Client::new() }
}
}
impl NamedModel for OllamaModel {
fn default_model(&self) -> &str { &self.default_model }
}
#[async_trait]
impl Model for OllamaModel {
async fn complete(
&self,
req: &ModelRequest,
_deltas: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
// Flatten to plain text messages: tool results and assistant
// tool_calls are dropped (no native tool support on this path).
let msgs: Vec<Value> = req
.messages
.iter()
.filter_map(|m| {
let role = m["role"].as_str()?;
if !matches!(role, "system" | "user" | "assistant") {
return None;
}
let content = m["content"].as_str().unwrap_or("").to_string();
Some(json!({ "role": role, "content": content }))
})
.collect();
let mut options_obj = json!({});
if let Some(t) = req.temperature { options_obj["temperature"] = t.into(); }
if let Some(n) = req.max_tokens { options_obj["num_predict"] = n.into(); }
let body = json!({
"model": req.model,
"messages": msgs,
"stream": false,
"options": options_obj,
});
let url = format!("{}/api/chat", self.base_url.trim_end_matches('/'));
let http_resp = self
.http
.post(&url)
.json(&body)
.send()
.await
.map_err(ModelError::from_reqwest)?;
let status = http_resp.status();
if !status.is_success() {
let resp_text = http_resp.text().await.map_err(ModelError::from_reqwest)?;
return Err(ModelError::new(
Some(status.as_u16()),
format!("ollama: HTTP {status} from {url}\nbody: {resp_text}"),
));
}
let resp: Value = http_resp.json().await.map_err(ModelError::from_reqwest)?;
let content = resp["message"]["content"]
.as_str()
.ok_or_else(|| ModelError::new(None, "ollama: missing content in response"))?
.to_string();
Ok(ModelResponse::Message {
content,
reasoning: None,
usage: Usage {
input_tokens: resp["prompt_eval_count"].as_u64().map(|n| n as u32),
output_tokens: resp["eval_count"].as_u64().map(|n| n as u32),
..Usage::default()
},
raw: None,
})
}
}
+459
View File
@@ -0,0 +1,459 @@
//! OpenAI-compatible client (OpenAI, OpenRouter, Moonshot/Kimi, and every
//! provider declared via YAML). Ported from `llm-client/src/openai.rs` onto
//! the `Model` trait.
//!
//! Kimi's `SystemToolBlock` DTL needs NO client code: messages are passed
//! through verbatim and the endpoint speaks the `{role:"system", tools:[…]}`
//! convention natively.
use std::collections::BTreeMap;
use async_trait::async_trait;
use futures_util::StreamExt;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use tracing::{debug, info, trace, warn};
use super::{SseDecoder, error_response_body, headers_to_json, redact_key};
use crate::APP_NAME;
use crate::model::{
Model, ModelError, ModelRequest, ModelResponse, NamedModel, RawMeta, StreamDelta, ToolCall,
Usage,
};
/// OpenAI ChatGPT client (also compatible with any OpenAI-spec endpoint).
pub struct OpenAiModel {
base_url: String,
api_key: String,
default_model: String,
extra_params: Option<Value>,
/// When true, Anthropic-compatible prompt-caching hints are injected
/// (OpenRouter routing to Anthropic models).
enable_prompt_cache: bool,
app_name: String,
http: reqwest::Client,
}
impl OpenAiModel {
/// Minimal constructor: base URL + key + default model name (used as the
/// selector id by `SingleModel`).
pub fn new(
base_url: impl Into<String>,
api_key: impl Into<String>,
default_model: impl Into<String>,
) -> Self {
Self::with_options(base_url, api_key, default_model, None, false)
}
pub fn with_options(
base_url: impl Into<String>,
api_key: impl Into<String>,
default_model: impl Into<String>,
extra_params: Option<Value>,
enable_prompt_cache: bool,
) -> Self {
Self {
base_url: base_url.into(),
api_key: api_key.into(),
default_model: default_model.into(),
extra_params,
enable_prompt_cache,
app_name: APP_NAME.to_string(),
http: reqwest::Client::new(),
}
}
/// Override the `X-Title` header (OpenRouter rankings).
pub fn with_app_name(mut self, app_name: impl Into<String>) -> Self {
self.app_name = app_name.into();
self
}
/// Merges extra top-level object keys into `body` (later maps win).
fn merge_extra(body: &mut Value, extra: Option<&Value>) {
if let Some(Value::Object(extra)) = extra
&& let Some(b) = body.as_object_mut()
{
for (k, v) in extra {
b.insert(k.clone(), v.clone());
}
}
}
fn url(&self) -> String {
format!("{}/chat/completions", self.base_url.trim_end_matches('/'))
}
/// Shared request body for the buffered and the streaming path.
fn base_body(&self, model: &str, messages: &[Value], tools: &[Value]) -> Value {
let mut body = json!({
"model": model,
"messages": messages,
});
if !tools.is_empty() {
// When prompt caching is enabled, tag the last tool with cache_control
// so the entire tools array is included in the KV cache prefix.
let tools_value: Value = if self.enable_prompt_cache {
let mut tagged = tools.to_vec();
if let Some(last) = tagged.last_mut() {
last["cache_control"] = json!({"type": "ephemeral"});
}
tagged.into()
} else {
tools.into()
};
body["tools"] = tools_value;
body["tool_choice"] = "auto".into();
}
body
}
fn finalize_body(&self, mut body: Value, req: &ModelRequest) -> Value {
if let Some(t) = req.max_tokens { body["max_tokens"] = t.into(); }
if let Some(t) = req.temperature { body["temperature"] = t.into(); }
Self::merge_extra(&mut body, self.extra_params.as_ref());
Self::merge_extra(&mut body, Some(&req.extras));
body
}
/// Request metadata for logging (shared by buffered and streaming paths).
fn logged_headers(&self) -> Value {
let mut logged_headers = json!({
"authorization": format!("Bearer {}", redact_key(&self.api_key)),
"content-type": "application/json",
});
if self.enable_prompt_cache {
logged_headers["anthropic-beta"] = "prompt-caching-2024-07-31".into();
}
logged_headers
}
async fn send_request(&self, body: &Value) -> Result<reqwest::Response, ModelError> {
let mut req = self
.http
.post(self.url())
.bearer_auth(&self.api_key)
.header("X-Title", &self.app_name);
if self.enable_prompt_cache {
req = req.header("anthropic-beta", "prompt-caching-2024-07-31");
}
req.json(body).send().await.map_err(ModelError::from_reqwest)
}
/// The buffered path.
async fn buffered(&self, req: &ModelRequest) -> Result<ModelResponse, ModelError> {
let body = self.finalize_body(self.base_body(&req.model, &req.messages, &req.tools), req);
debug!(model = %req.model, tools = req.tools.len(), prompt_cache = self.enable_prompt_cache, "openai: sending request");
trace!(body = %body, "openai: request body");
let request_body = body.clone();
let request_headers = self.logged_headers();
let http_resp = self.send_request(&body).await?;
let response_headers = headers_to_json(http_resp.headers());
let status = http_resp.status();
let resp_text = http_resp.text().await.map_err(ModelError::from_reqwest)?;
if !status.is_success() {
return Err(ModelError {
status: Some(status.as_u16()),
message: format!("openai: HTTP {status} from {url}\nbody: {resp_text}", url = self.url()),
raw: Some(RawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(error_response_body(resp_text)),
}),
});
}
let resp: Value = serde_json::from_str(&resp_text).map_err(|e| {
ModelError::new(None, format!("openai: failed to parse response JSON: {e}\nbody: {resp_text}"))
})?;
let response_body: Value = serde_json::from_str(&resp_text).unwrap_or(Value::Null);
let raw = RawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(response_body),
};
Ok(parse_turn(&resp, &req.model).with_raw(raw))
}
/// SSE streaming path. Accumulates fragments into the same `ModelResponse`
/// the buffered path returns, forwarding deltas best-effort. `emitted`
/// tracks whether any delta was pushed, distinguishing a pre-stream
/// failure (safe to retry buffered) from a mid-stream one.
async fn stream_chat(
&self,
req: &ModelRequest,
delta_tx: &mpsc::Sender<StreamDelta>,
emitted: &mut bool,
) -> Result<ModelResponse, ModelError> {
let mut body = self.base_body(&req.model, &req.messages, &req.tools);
body["stream"] = json!(true);
body["stream_options"] = json!({ "include_usage": true });
let body = self.finalize_body(body, req);
debug!(model = %req.model, tools = req.tools.len(), prompt_cache = self.enable_prompt_cache, "openai: sending streaming request");
trace!(body = %body, "openai: streaming request body");
let request_body = body.clone();
let request_headers = self.logged_headers();
let http_resp = self.send_request(&body).await?;
let response_headers = headers_to_json(http_resp.headers());
let status = http_resp.status();
if !status.is_success() {
let resp_text = http_resp.text().await.map_err(ModelError::from_reqwest)?;
return Err(ModelError {
status: Some(status.as_u16()),
message: format!("openai: HTTP {status} from {url}\nbody: {resp_text}", url = self.url()),
raw: Some(RawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(error_response_body(resp_text)),
}),
});
}
let mut content = String::new();
let mut reasoning = String::new();
// index → (id, name, arguments fragment buffer)
let mut tool_calls: BTreeMap<u64, (String, String, String)> = BTreeMap::new();
let mut finish_reason: Option<String> = None;
let mut usage: Option<Value> = None;
let mut sse = SseDecoder::new();
let mut byte_stream = http_resp.bytes_stream();
let mut handle_payload = |payload: &str, emitted: &mut bool| {
if payload == "[DONE]" {
return;
}
let Ok(v) = serde_json::from_str::<Value>(payload) else { return };
if let Some(u) = v.get("usage").filter(|u| !u.is_null()) {
usage = Some(u.clone());
}
let Some(choice) = v["choices"].as_array().and_then(|a| a.first()) else { return };
if let Some(fr) = choice["finish_reason"].as_str() {
finish_reason = Some(fr.to_string());
}
let delta = &choice["delta"];
if let Some(t) = delta["content"].as_str().filter(|t| !t.is_empty()) {
content.push_str(t);
*emitted = true;
let _ = delta_tx.try_send(StreamDelta::Text(t.to_string()));
}
// DeepSeek uses `reasoning_content`, MiniMax M3 and others `reasoning`.
if let Some(t) = delta["reasoning_content"].as_str()
.or_else(|| delta["reasoning"].as_str())
.filter(|t| !t.is_empty())
{
reasoning.push_str(t);
*emitted = true;
let _ = delta_tx.try_send(StreamDelta::Reasoning(t.to_string()));
}
if let Some(tc_arr) = delta["tool_calls"].as_array() {
for tc in tc_arr {
let idx = tc["index"].as_u64().unwrap_or(0);
let entry = tool_calls.entry(idx).or_default();
if let Some(id) = tc["id"].as_str() { entry.0 = id.to_string(); }
if let Some(n) = tc["function"]["name"].as_str() { entry.1 = n.to_string(); }
if let Some(a) = tc["function"]["arguments"].as_str() { entry.2.push_str(a); }
}
}
};
while let Some(chunk) = byte_stream.next().await {
let chunk = chunk.map_err(ModelError::from_reqwest)?;
for payload in sse.feed(&chunk) {
handle_payload(&payload, emitted);
}
}
for payload in sse.finish() {
handle_payload(&payload, emitted);
}
let finish = finish_reason.as_deref().unwrap_or("stop");
let input_tokens = usage.as_ref().and_then(|u| u["prompt_tokens"].as_u64()).map(|n| n as u32);
let output_tokens = usage.as_ref().and_then(|u| u["completion_tokens"].as_u64()).map(|n| n as u32);
let cache_read = usage.as_ref()
.and_then(|u| u["prompt_tokens_details"]["cached_tokens"].as_u64())
.map(|n| n as u32);
let cost_usd = usage.as_ref().and_then(|u| u["cost"].as_f64());
let reasoning_content = if reasoning.is_empty() { None } else { Some(reasoning) };
info!(model = %req.model, ?input_tokens, ?output_tokens, finish_reason = finish, "openai: streaming response completed");
if finish == "length" {
warn!(model = %req.model, ?output_tokens, "openai: response truncated (max_tokens reached)");
}
let usage_struct = Usage {
input_tokens,
output_tokens,
cache_read,
cache_write: None,
cost_usd,
truncated: finish == "length",
};
// Reassemble the streamed message for the payload log (buffered shape).
let logged_tool_calls: Vec<Value> = tool_calls.iter()
.map(|(_idx, (id, name, args))| json!({
"id": id,
"type": "function",
"function": { "name": name, "arguments": args },
}))
.collect();
let mut logged_message = json!({ "role": "assistant", "content": content.clone() });
if let Some(rc) = &reasoning_content {
logged_message["reasoning_content"] = rc.clone().into();
}
if !logged_tool_calls.is_empty() {
logged_message["tool_calls"] = Value::Array(logged_tool_calls);
}
let raw = RawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(json!({
"streamed": true,
"choices": [{ "finish_reason": finish, "message": logged_message }],
"usage": usage,
})),
};
let mut resp = if !tool_calls.is_empty() {
let calls = tool_calls
.into_values()
.map(|(id, name, args)| ToolCall {
id,
name,
arguments: serde_json::from_str(&args).unwrap_or(Value::Object(Default::default())),
})
.collect();
ModelResponse::ToolCalls { content, calls, reasoning: reasoning_content, usage: usage_struct, raw: None }
} else {
ModelResponse::Message { content, reasoning: reasoning_content, usage: usage_struct, raw: None }
};
set_raw(&mut resp, raw);
Ok(resp)
}
}
impl NamedModel for OpenAiModel {
fn default_model(&self) -> &str { &self.default_model }
}
#[async_trait]
impl Model for OpenAiModel {
async fn complete(
&self,
req: &ModelRequest,
deltas: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
match deltas {
None => self.buffered(req).await,
Some(delta_tx) => {
let mut emitted = false;
match self.stream_chat(req, &delta_tx, &mut emitted).await {
Ok(ok) => Ok(ok),
// Nothing was ever streamed: some OpenAI-compatible
// providers reject `stream`/`stream_options` outright —
// retry buffered so they keep working. A mid-stream
// failure instead propagates to the fallback logic.
Err(e) if !emitted => {
debug!(model = %req.model, error = %e, "openai: streaming failed before any delta; retrying buffered");
self.buffered(req).await
}
Err(e) => Err(e),
}
}
}
}
}
// ── response parsing (shared by buffered and tests) ──
trait WithRaw {
fn with_raw(self, raw: RawMeta) -> ModelResponse;
}
impl WithRaw for ModelResponse {
fn with_raw(mut self, raw: RawMeta) -> ModelResponse {
set_raw(&mut self, raw);
self
}
}
fn set_raw(resp: &mut ModelResponse, raw: RawMeta) {
match resp {
ModelResponse::Message { raw: r, .. } | ModelResponse::ToolCalls { raw: r, .. } => {
*r = Some(raw)
}
}
}
/// Parse a buffered OpenAI response body into a `ModelResponse`.
fn parse_turn(resp: &Value, model: &str) -> ModelResponse {
let usage = Usage {
input_tokens: resp["usage"]["prompt_tokens"].as_u64().map(|n| n as u32),
output_tokens: resp["usage"]["completion_tokens"].as_u64().map(|n| n as u32),
cache_read: resp["usage"]["prompt_tokens_details"]["cached_tokens"].as_u64().map(|n| n as u32),
cache_write: None,
cost_usd: resp["usage"]["cost"].as_f64(),
truncated: false,
};
let choice = &resp["choices"][0];
let message = &choice["message"];
let finish = choice["finish_reason"].as_str().unwrap_or("stop");
if finish == "length" {
warn!(model = %model, "openai: response truncated (max_tokens reached)");
}
let reasoning_content = message["reasoning_content"].as_str()
.or_else(|| message["reasoning"].as_str())
.map(str::to_string);
let tool_calls_array = message["tool_calls"].as_array().filter(|a| !a.is_empty());
// Some models (e.g. Qwen via OpenRouter) return finish_reason "stop" even
// when tool_calls are present, so check the array directly.
if finish == "tool_calls" || tool_calls_array.is_some() {
let content = message["content"].as_str().unwrap_or("").to_string();
let calls = tool_calls_array
.map(|arr| {
arr.iter()
.map(|tc| ToolCall {
id: tc["id"].as_str().unwrap_or("").to_string(),
name: tc["function"]["name"].as_str().unwrap_or("").to_string(),
arguments: tc["function"]["arguments"]
.as_str()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Object(Default::default())),
})
.collect()
})
.unwrap_or_default();
ModelResponse::ToolCalls { content, calls, reasoning: reasoning_content, usage, raw: None }
} else {
// content can be null for thinking models or finish_reason="length".
let content = match message["content"].as_str() {
Some(s) => s.to_string(),
None => {
warn!(finish_reason = finish, raw_message = %message, "openai: response has null content");
String::new()
}
};
let mut usage = usage;
usage.truncated = finish == "length";
ModelResponse::Message { content, reasoning: reasoning_content, usage, raw: None }
}
}
+80
View File
@@ -0,0 +1,80 @@
//! Incremental SSE decoder: feed raw response bytes, get back the payload of
//! every complete `data:` line seen (`[DONE]` included — callers decide).
//! Buffers partial lines across chunks; `event:` lines and comments are
//! skipped (both OpenAI and Anthropic put the event type inside the JSON).
//!
//! Ported verbatim from `llm-client`.
#[derive(Default)]
pub(crate) struct SseDecoder {
buf: Vec<u8>,
}
impl SseDecoder {
pub(crate) fn new() -> Self { Self::default() }
pub(crate) fn feed(&mut self, bytes: &[u8]) -> Vec<String> {
self.buf.extend_from_slice(bytes);
let mut out = Vec::new();
while let Some(pos) = self.buf.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = self.buf.drain(..=pos).collect();
if let Some(payload) = parse_sse_line(&line) {
out.push(payload);
}
}
out
}
/// Flush a trailing line not terminated by `\n` at end-of-stream.
pub(crate) fn finish(&mut self) -> Vec<String> {
let rest = std::mem::take(&mut self.buf);
parse_sse_line(&rest).into_iter().collect()
}
}
/// A complete SSE line is valid UTF-8 (a multibyte sequence never contains a
/// `\n` byte), but decode lossily anyway — a corrupt line is skipped, not fatal.
fn parse_sse_line(line: &[u8]) -> Option<String> {
let line = String::from_utf8_lossy(line);
let line = line.trim_end_matches('\r').trim();
let data = line.strip_prefix("data:")?.trim_start();
if data.is_empty() { None } else { Some(data.to_string()) }
}
#[cfg(test)]
mod tests {
use super::SseDecoder;
#[test]
fn sse_decoder_buffers_partial_lines_across_chunks() {
let mut dec = SseDecoder::new();
assert!(dec.feed(br#"data: {"a": 1"#).is_empty());
assert_eq!(dec.feed(b"}\r\n").len(), 1);
}
#[test]
fn sse_decoder_skips_events_comments_and_keeps_done() {
let mut dec = SseDecoder::new();
let out = dec.feed(b"event: message_start\n: ping\n\ndata: {\"type\":\"ping\"}\ndata: [DONE]\n");
assert_eq!(out, vec!["{\"type\":\"ping\"}".to_string(), "[DONE]".to_string()]);
assert!(dec.finish().is_empty());
}
#[test]
fn sse_decoder_finish_flushes_unterminated_tail() {
let mut dec = SseDecoder::new();
assert!(dec.feed(b"data: tail-without-newline").is_empty());
assert_eq!(dec.finish(), vec!["tail-without-newline".to_string()]);
}
#[test]
fn sse_decoder_handles_multibyte_split() {
// "€" is 3 bytes in UTF-8; split across the chunk boundary.
let payload = "data: {\"t\":\"\"}\n".as_bytes();
let (a, b) = payload.split_at(12);
let mut dec = SseDecoder::new();
let (first, second) = (dec.feed(a), dec.feed(b));
assert!(first.is_empty());
assert_eq!(second.len(), 1);
}
}
+416
View File
@@ -0,0 +1,416 @@
//! The wire half of multimodal media: which files a model can take, in which
//! content-part shape, within which budgets.
//!
//! The host supplies **blobs** it has already authorized (containment, upload
//! rules, ownership — its policy); this module decides whether a blob reaches
//! the model and in what shape. The split is deliberate: the part shapes and
//! the byte ceilings are protocol (`MAX_DOCUMENT_BYTES` is literally
//! Anthropic's per-request document ceiling), the authorization is not.
//!
//! Promotion is strict: a blob is inlined only when the model declares the
//! modality's capability, the **sniffed magic bytes** match an allowed MIME (a
//! host-claimed MIME is never trusted — there is no seam to pass one), and the
//! per-file / per-turn budgets hold. Anything failing a check is reported back
//! as skipped so the host can keep it on its textual path.
use std::sync::Arc;
use async_trait::async_trait;
use base64::Engine as _;
use serde_json::{Value, json};
use tracing::debug;
/// Max media parts inlined per turn.
pub const MAX_MEDIA_PER_TURN: usize = 4;
/// Max bytes for one inlined image.
pub const MAX_IMAGE_BYTES: u64 = 10 * 1024 * 1024;
/// Max bytes for one inlined video.
pub const MAX_VIDEO_BYTES: u64 = 32 * 1024 * 1024;
/// Max bytes for one inlined document (Anthropic's per-request ceiling).
pub const MAX_DOCUMENT_BYTES: u64 = 32 * 1024 * 1024;
/// Max combined media bytes inlined per turn.
pub const MAX_TOTAL_MEDIA_BYTES: u64 = 48 * 1024 * 1024;
// ── MediaKind ────────────────────────────────────────────────────────────────
/// A model-input modality: the capability that unlocks it and the content-part
/// shape it maps to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaKind {
Image,
Video,
/// PDFs, as the OpenAI file-input part (`{"type":"file","file":{…}}`) —
/// forwarded verbatim by OpenAI-compatible clients and translated to a
/// native `document` block by the Anthropic client.
Document,
}
impl MediaKind {
/// The `ModelInfo::capabilities` entry that unlocks this modality.
pub fn capability(self) -> &'static str {
match self {
Self::Image => "vision",
Self::Video => "video",
Self::Document => "document",
}
}
/// The OpenAI content-part type.
pub fn part_type(self) -> &'static str {
match self {
Self::Image => "image_url",
Self::Video => "video_url",
Self::Document => "file",
}
}
/// Human-readable format list (hosts use it in tool descriptions).
pub fn formats(self) -> &'static str {
match self {
Self::Image => "images (PNG, JPEG, GIF, WebP)",
Self::Video => "video (MP4, WebM, MOV, …)",
Self::Document => "PDF documents",
}
}
/// The modality a sniffed MIME belongs to.
pub fn for_mime(mime: &str) -> Option<Self> {
match mime {
"image/png" | "image/jpeg" | "image/gif" | "image/webp" => Some(Self::Image),
"video/mp4" | "video/mpeg" | "video/quicktime" | "video/webm" | "video/x-msvideo"
| "video/x-flv" | "video/3gpp" => Some(Self::Video),
"application/pdf" => Some(Self::Document),
_ => None,
}
}
/// The modalities a model with these capabilities can take, in a stable order.
pub fn enabled(capabilities: &[String]) -> Vec<Self> {
[Self::Image, Self::Video, Self::Document]
.into_iter()
.filter(|k| capabilities.iter().any(|c| c == k.capability()))
.collect()
}
}
// ── MediaBudget ──────────────────────────────────────────────────────────────
/// Per-file and per-turn ceilings.
#[derive(Debug, Clone, Copy)]
pub struct MediaBudget {
pub max_per_turn: usize,
pub max_image_bytes: u64,
pub max_video_bytes: u64,
pub max_document_bytes: u64,
pub max_total_bytes: u64,
}
impl Default for MediaBudget {
fn default() -> Self {
Self {
max_per_turn: MAX_MEDIA_PER_TURN,
max_image_bytes: MAX_IMAGE_BYTES,
max_video_bytes: MAX_VIDEO_BYTES,
max_document_bytes: MAX_DOCUMENT_BYTES,
max_total_bytes: MAX_TOTAL_MEDIA_BYTES,
}
}
}
impl MediaBudget {
pub fn max_bytes(&self, kind: MediaKind) -> u64 {
match kind {
MediaKind::Image => self.max_image_bytes,
MediaKind::Video => self.max_video_bytes,
MediaKind::Document => self.max_document_bytes,
}
}
}
// ── MediaBlob ────────────────────────────────────────────────────────────────
/// A candidate medium the host has already authorized. Reads are lazy so a
/// blob rejected on capability or size is never fully loaded.
#[async_trait]
pub trait MediaBlob: Send + Sync {
/// Display name (the `filename` of a `file` part).
fn name(&self) -> &str;
/// Byte length; `None` (unknown) means "do not inline".
async fn size(&self) -> Option<u64>;
/// The first bytes, for magic-byte sniffing (16 are enough).
async fn head(&self) -> Option<Vec<u8>>;
/// The whole content.
async fn read_all(&self) -> Option<Vec<u8>>;
}
// ── projection ───────────────────────────────────────────────────────────────
/// The OpenAI-wire content part for one inlined medium.
pub fn media_part(kind: MediaKind, mime: &str, bytes: &[u8], filename: &str) -> Value {
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let url = format!("data:{mime};base64,{b64}");
match kind {
MediaKind::Document => {
json!({ "type": "file", "file": { "filename": filename, "file_data": url } })
}
k => {
let t = k.part_type();
json!({ "type": t, t: { "url": url } })
}
}
}
/// Splits blobs into inline content parts and the indices left out.
///
/// Skipped blobs are the host's business: it typically renders them as a
/// textual path list so the agent can still read them with a tool.
pub async fn partition(
blobs: &[Arc<dyn MediaBlob>],
capabilities: &[String],
budget: &MediaBudget,
) -> (Vec<Value>, Vec<usize>) {
if blobs.is_empty() {
return (Vec::new(), Vec::new());
}
if MediaKind::enabled(capabilities).is_empty() {
return (Vec::new(), (0..blobs.len()).collect());
}
let mut parts: Vec<Value> = Vec::new();
let mut skipped: Vec<usize> = Vec::new();
let mut total: u64 = 0;
for (idx, blob) in blobs.iter().enumerate() {
if parts.len() >= budget.max_per_turn {
debug!(name = blob.name(), "media not inlined: per-turn count budget exhausted");
skipped.push(idx);
continue;
}
match promote(blob.as_ref(), capabilities, budget, total).await {
Some((part, bytes)) => {
total += bytes;
parts.push(part);
}
None => skipped.push(idx),
}
}
(parts, skipped)
}
/// Sniff + capability + budget + build, for one blob. `None` (logged at debug)
/// when it is not a recognized medium, the model lacks the modality, or a byte
/// budget is exhausted. The per-turn **count** budget is the caller's.
async fn promote(
blob: &dyn MediaBlob,
capabilities: &[String],
budget: &MediaBudget,
used_total: u64,
) -> Option<(Value, u64)> {
let head = blob.head().await?;
let mime = sniff_mime(&head)?;
let kind = MediaKind::for_mime(mime)?;
if !capabilities.iter().any(|c| c == kind.capability()) {
debug!(name = blob.name(), mime, "media not inlined: model lacks the capability");
return None;
}
let size = blob.size().await?;
if size > budget.max_bytes(kind) {
debug!(name = blob.name(), size, "media not inlined: file too large");
return None;
}
if used_total + size > budget.max_total_bytes {
debug!(name = blob.name(), "media not inlined: per-turn byte budget exhausted");
return None;
}
let bytes = blob.read_all().await?;
Some((media_part(kind, mime, &bytes, blob.name()), size))
}
/// Sniffs the magic bytes of a medium we know how to inline, returning its
/// canonical MIME type. `None` = not a recognized medium (not an error —
/// ordinary files simply are not model input).
pub fn sniff_mime(head: &[u8]) -> Option<&'static str> {
if head.starts_with(b"\x89PNG\r\n\x1a\n") {
return Some("image/png");
}
if head.starts_with(b"\xff\xd8\xff") {
return Some("image/jpeg");
}
if head.starts_with(b"GIF87a") || head.starts_with(b"GIF89a") {
return Some("image/gif");
}
if head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"WEBP" {
return Some("image/webp");
}
if head.len() >= 12 && &head[4..8] == b"ftyp" {
let brand = &head[8..12];
if brand.starts_with(b"3gp") || brand.starts_with(b"3g2") {
return Some("video/3gpp");
}
if brand == b"qt " {
return Some("video/quicktime");
}
// isom / mp41 / mp42 / avc1 / M4V …
return Some("video/mp4");
}
// EBML header — WebM (and Matroska, close enough for the video models).
if head.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) {
return Some("video/webm");
}
if head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"AVI " {
return Some("video/x-msvideo");
}
if head.starts_with(b"FLV\x01") {
return Some("video/x-flv");
}
if head.starts_with(&[0x00, 0x00, 0x01, 0xBA]) || head.starts_with(&[0x00, 0x00, 0x01, 0xB3]) {
return Some("video/mpeg");
}
if head.starts_with(b"%PDF-") {
return Some("application/pdf");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// An in-memory blob.
struct Blob {
name: String,
bytes: Vec<u8>,
}
/// A blob as the trait object the engine takes.
fn blob(name: &str, bytes: Vec<u8>) -> Arc<dyn MediaBlob> {
Arc::new(Blob { name: name.to_string(), bytes })
}
#[async_trait]
impl MediaBlob for Blob {
fn name(&self) -> &str { &self.name }
async fn size(&self) -> Option<u64> { Some(self.bytes.len() as u64) }
async fn head(&self) -> Option<Vec<u8>> {
Some(self.bytes.iter().copied().take(16).collect())
}
async fn read_all(&self) -> Option<Vec<u8>> { Some(self.bytes.clone()) }
}
fn png() -> Vec<u8> {
let mut v = b"\x89PNG\r\n\x1a\n".to_vec();
v.extend_from_slice(&[0xAA; 64]);
v
}
fn pdf() -> Vec<u8> {
let mut v = b"%PDF-1.7\n".to_vec();
v.extend_from_slice(&[0x00; 64]);
v
}
fn caps(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn sniff_known_signatures() {
assert_eq!(sniff_mime(b"\x89PNG\r\n\x1a\n...."), Some("image/png"));
assert_eq!(sniff_mime(b"\xff\xd8\xff\xe0...."), Some("image/jpeg"));
assert_eq!(sniff_mime(b"GIF89a...."), Some("image/gif"));
assert_eq!(sniff_mime(b"RIFF\x00\x00\x00\x00WEBP"), Some("image/webp"));
assert_eq!(sniff_mime(b"\x00\x00\x00\x18ftypisom"), Some("video/mp4"));
assert_eq!(sniff_mime(b"\x00\x00\x00\x18ftypqt "), Some("video/quicktime"));
assert_eq!(sniff_mime(b"\x00\x00\x00\x18ftyp3gp4"), Some("video/3gpp"));
assert_eq!(sniff_mime(&[0x1A, 0x45, 0xDF, 0xA3, 0, 0]), Some("video/webm"));
assert_eq!(sniff_mime(b"RIFF\x00\x00\x00\x00AVI "), Some("video/x-msvideo"));
assert_eq!(sniff_mime(b"FLV\x01\x05"), Some("video/x-flv"));
assert_eq!(sniff_mime(&[0x00, 0x00, 0x01, 0xBA]), Some("video/mpeg"));
assert_eq!(sniff_mime(b"%PDF-1.7"), Some("application/pdf"));
assert_eq!(sniff_mime(b""), None);
}
#[tokio::test]
async fn inlines_png_for_a_vision_model() {
let (parts, skipped) =
partition(&[blob("a.png", png())], &caps(&["vision"]), &MediaBudget::default()).await;
assert!(skipped.is_empty());
assert_eq!(parts.len(), 1);
assert_eq!(parts[0]["type"], "image_url");
assert!(
parts[0]["image_url"]["url"].as_str().unwrap().starts_with("data:image/png;base64,")
);
}
#[tokio::test]
async fn inlines_pdf_as_a_file_part_for_a_document_model() {
let (parts, skipped) =
partition(&[blob("a.pdf", pdf())], &caps(&["document"]), &MediaBudget::default()).await;
assert!(skipped.is_empty());
assert_eq!(parts[0]["type"], "file");
assert_eq!(parts[0]["file"]["filename"], "a.pdf");
assert!(
parts[0]["file"]["file_data"].as_str().unwrap().starts_with("data:application/pdf;base64,")
);
}
#[tokio::test]
async fn gates_on_capability_per_modality() {
let b = |bytes: Vec<u8>| vec![blob("x", bytes)];
let budget = MediaBudget::default();
// No capability at all.
let (parts, skipped) = partition(&b(png()), &caps(&[]), &budget).await;
assert!(parts.is_empty() && skipped == vec![0]);
// vision does not unlock PDFs, document does not unlock images.
let (parts, skipped) = partition(&b(pdf()), &caps(&["vision"]), &budget).await;
assert!(parts.is_empty() && skipped == vec![0]);
let (parts, skipped) = partition(&b(png()), &caps(&["document"]), &budget).await;
assert!(parts.is_empty() && skipped == vec![0]);
// An unrecognized medium is never inlined.
let (parts, skipped) = partition(&b(b"plain text".to_vec()), &caps(&["vision"]), &budget).await;
assert!(parts.is_empty() && skipped == vec![0]);
}
#[tokio::test]
async fn enforces_count_per_file_and_total_budgets() {
let budget = MediaBudget::default();
let blobs: Vec<Arc<dyn MediaBlob>> = (0..budget.max_per_turn + 2)
.map(|i| blob(&format!("{i}.png"), png()))
.collect();
let (parts, skipped) = partition(&blobs, &caps(&["vision"]), &budget).await;
assert_eq!(parts.len(), budget.max_per_turn);
assert_eq!(skipped.len(), 2);
// Per-file ceiling.
let tight = MediaBudget { max_image_bytes: 8, ..MediaBudget::default() };
let (parts, skipped) = partition(&[blob("a.png", png())], &caps(&["vision"]), &tight).await;
assert!(parts.is_empty() && skipped == vec![0]);
// Per-turn total: the first fits, the second does not.
let total = MediaBudget { max_total_bytes: 100, ..MediaBudget::default() };
let (parts, skipped) = partition(
&[blob("a.png", png()), blob("b.png", png())],
&caps(&["vision"]),
&total,
)
.await;
assert_eq!(parts.len(), 1);
assert_eq!(skipped, vec![1]);
}
#[test]
fn enabled_modalities_are_capability_driven() {
assert!(MediaKind::enabled(&caps(&[])).is_empty());
assert_eq!(MediaKind::enabled(&caps(&["vision"])), vec![MediaKind::Image]);
assert_eq!(
MediaKind::enabled(&caps(&["document", "vision"])),
vec![MediaKind::Image, MediaKind::Document],
"the order is the enum's, not the capability list's"
);
}
}
+609
View File
@@ -0,0 +1,609 @@
//! The projection: stored history → wire messages. **This is where provider
//! divergence lives**, so it belongs to the crate rather than to any host.
//!
//! What the crate owns here: the shape of every message (string content vs
//! content-part array, `cache_control` placement, `tool_calls`/`tool` shapes,
//! media parts), the well-formedness rules (a result for every tool call, no
//! orphans, role alternation, boundary-safe windowing), the dynamic-tool-loading
//! injections, and the byte fidelity of what goes back on the wire.
//!
//! What the host owns: the **content** — the system prompt layers
//! ([`crate::context::SystemContextSource`]), which media a message may inline
//! ([`MediaSource`]) and how an over-long tool result is condensed
//! ([`ToolResultDigest`]). Everything is optional: with no hooks at all the
//! projection is a complete, correct OpenAI-shaped conversation.
//!
//! **Well-formedness contract** (the reason a resumed turn can just re-run):
//!
//! 1. Order: static system → extra static → summary → history after
//! `covered_up_to` → dynamic tail → tail reminder.
//! 2. Every assistant `tool_call` has a tool result: `Done` → the result,
//! `Failed` → an error, `Cancelled`/`Rejected` → a note, and a `Running` /
//! `AwaitingHuman` call that survived a crash → a synthetic "interrupted"
//! result. A model must never see a call it gets no answer for.
//! 3. No `failed` messages (orphans of cancelled turns) — the store filters them.
//! 4. DTL injections are **append-only**: the cacheable prefix stays
//! byte-identical, so activating a tool never invalidates the prompt cache.
pub mod media;
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::activation::{Activation, ActivationSource, ToolRendering};
use crate::context::AssembleInput;
use crate::ids::MessageId;
use crate::store::{CallState, HistoryStore, Role, StoredCall, StoredMessage};
pub use media::{MediaBlob, MediaBudget, MediaKind};
// ── Configuration ────────────────────────────────────────────────────────────
/// How a stored `reasoning_content` is echoed back.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReasoningEcho {
/// `reasoning_content` only (DeepSeek).
#[default]
ContentOnly,
/// Both `reasoning_content` and `reasoning` — some OpenAI-compatible
/// endpoints read one, some the other, and neither rejects the extra key.
Both,
}
/// When and how far tool results are shrunk.
#[derive(Debug, Clone, Copy)]
pub struct ResultLimit {
/// Gate: results longer than this (in bytes — cheap and stable) are shrunk.
/// The fallback truncation cuts on a **char** boundary, never mid-codepoint.
pub max_chars: usize,
/// Shrink only results of turns before the current one, so the in-flight
/// turn always sees its own tool output in full.
pub previous_turns_only: bool,
}
/// The protocol-shaped knobs of the projection. [`Default`] is a correct
/// OpenAI-shaped conversation; a host overrides only what its models need.
#[derive(Debug, Clone)]
pub struct Projection {
/// Header of the compaction summary block.
pub summary_prefix: String,
/// Optional trailer, to mark where the summary ends and full history resumes.
pub summary_suffix: Option<String>,
/// Keep at most this many history messages (cut boundary-safely).
pub max_messages: Option<usize>,
pub max_tool_result: Option<ResultLimit>,
/// Result text for a call that was still `Running`/`AwaitingHuman` when the
/// process died.
pub interrupted_text: String,
/// Result text for a `Rejected` call that recorded none.
pub rejected_default: String,
/// Result text for a `Cancelled` call that recorded none.
pub cancelled_default: String,
/// Some models (DeepSeek thinking mode) reject a replayed tool-calling turn
/// whose `reasoning_content` is empty: this stands in when none was stored.
pub reasoning_placeholder: Option<String>,
pub reasoning_echo: ReasoningEcho,
/// Joins the dynamic-tail layers into the single trailing system message.
pub tail_separator: String,
pub media: MediaBudget,
/// In `DeferredToolReference` mode, the tool whose result carries the
/// `_tool_references` marker (the activation tool's name). `None` = the
/// first result of the anchored message.
pub activation_anchor_tool: Option<String>,
}
/// The default summary header — enough for a model to know what it is reading.
pub const SUMMARY_PREFIX: &str =
"[CONTEXT SUMMARY — earlier messages were compacted into this summary]";
impl Default for Projection {
fn default() -> Self {
Self {
summary_prefix: SUMMARY_PREFIX.to_string(),
summary_suffix: None,
max_messages: None,
max_tool_result: None,
interrupted_text: "[interrupted: this tool call did not complete — the session \
restarted before a result was recorded]"
.to_string(),
rejected_default: String::new(),
cancelled_default: String::new(),
reasoning_placeholder: None,
reasoning_echo: ReasoningEcho::default(),
tail_separator: "\n\n---\n".to_string(),
media: MediaBudget::default(),
activation_anchor_tool: None,
}
}
}
// ── Host hooks ───────────────────────────────────────────────────────────────
/// Which media a message may inline. The host authorizes (containment,
/// ownership, upload rules); the crate decides shape and budget.
#[async_trait]
pub trait MediaSource: Send + Sync {
/// Media attached to a user/agent message.
async fn message_media(&self, _msg: &StoredMessage) -> Vec<Arc<dyn MediaBlob>> {
Vec::new()
}
/// Media produced by an assistant turn's tool calls.
async fn call_media(&self, _calls: &[StoredCall]) -> Vec<Arc<dyn MediaBlob>> {
Vec::new()
}
/// Text appended to the message for the media that did NOT make it (a path
/// list, so the agent can still reach them with a tool).
///
/// `skipped` are **positions in the vector `message_media` just returned**
/// for this message, so the host can map them back to whatever it built
/// them from.
fn skipped_text(&self, _msg: &StoredMessage, _skipped: &[usize]) -> Option<String> {
None
}
}
/// How an over-long tool result is condensed. The crate decides *when*
/// (the [`ResultLimit`] gate); the host decides *what to say*, because a good
/// summary knows what the tool does.
#[async_trait]
pub trait ToolResultDigest: Send + Sync {
/// `None` → the crate applies its generic char-boundary truncation.
async fn condense(&self, name: &str, args: &Value, result: &str) -> Option<String>;
}
/// The host hooks, all optional.
#[derive(Default, Clone)]
pub struct ProjectionHooks {
pub activation: Option<Arc<dyn ActivationSource>>,
pub media: Option<Arc<dyn MediaSource>>,
pub digest: Option<Arc<dyn ToolResultDigest>>,
}
// ── The engine ───────────────────────────────────────────────────────────────
/// Project a frame's stored history into wire messages.
pub async fn project(
store: &Arc<dyn HistoryStore>,
input: &AssembleInput,
cfg: &Projection,
hooks: &ProjectionHooks,
) -> crate::Result<Vec<Value>> {
let mut out: Vec<Value> = Vec::new();
// 1. Static system message — the cacheable prefix. With prompt caching the
// content becomes a one-part array carrying the cache breakpoint.
if !input.system.base.is_empty() {
out.push(if input.model.prompt_cache {
json!({
"role": "system",
"content": [{
"type": "text",
"text": input.system.base,
"cache_control": { "type": "ephemeral" },
}],
})
} else {
json!({ "role": "system", "content": input.system.base })
});
}
// 2. Extra static layers (per-interface rules, session-scoped blocks).
for s in &input.system.extra_static {
out.push(json!({ "role": "system", "content": s }));
}
// 3. Compaction summary, then the history it did not cover.
let summary = store.latest_summary(input.frame).await?;
if let Some(s) = &summary {
let mut content = format!("{}\n\n{}", cfg.summary_prefix, s.text);
if let Some(suffix) = &cfg.summary_suffix {
content.push_str("\n\n");
content.push_str(suffix);
}
out.push(json!({ "role": "system", "content": content }));
}
let mut history = match &summary {
Some(s) => store.load_since(input.frame, s.covered_up_to).await?,
None => store.load(input.frame).await?,
};
if let Some(max) = cfg.max_messages {
window(&mut history, max);
}
// 4. The conversation.
let ctx = HistoryCtx::new(&history, cfg, hooks, input).await?;
for (idx, entry) in history.iter().enumerate() {
ctx.project_message(&mut out, idx, entry).await;
}
// 5. Dynamic tail — the fresh layers, as ONE trailing system message so a
// model reads them as a single "current state" block.
if !input.system.dynamic_tail.is_empty() {
let tail = input.system.dynamic_tail.join(&cfg.tail_separator);
if !tail.is_empty() {
out.push(json!({ "role": "system", "content": tail }));
}
}
// 6. Tail reminder.
if let Some(r) = &input.system.tail_reminder {
out.push(json!({ "role": "system", "content": r }));
}
Ok(out)
}
/// Cut the history to at most `max` messages. A leading assistant message is
/// dropped as well: a window must not open on half an exchange.
fn window(history: &mut Vec<StoredMessage>, max: usize) {
if history.len() <= max {
return;
}
history.drain(..history.len() - max);
if matches!(history.first().map(|m| m.role), Some(Role::Assistant)) {
history.drain(..1);
}
}
/// Per-build state shared by every message projection.
struct HistoryCtx<'a> {
cfg: &'a Projection,
hooks: &'a ProjectionHooks,
model: &'a crate::model::ModelInfo,
/// Activated tool defs by anchor message (empty in `Inline` mode).
activations: HashMap<MessageId, Vec<Value>>,
/// Index of the last `User`/`Agent` message: everything before it belongs
/// to a previous turn.
boundary: Option<usize>,
/// First index of the current turn's group — media is inlined only from
/// here on, so images are not re-sent (and re-billed) every round.
media_turn_start: usize,
}
impl<'a> HistoryCtx<'a> {
async fn new(
history: &[StoredMessage],
cfg: &'a Projection,
hooks: &'a ProjectionHooks,
input: &'a AssembleInput,
) -> crate::Result<Self> {
let activations = match (&hooks.activation, input.model.tool_rendering) {
// Inline mode renders activated tools in the `tools` array itself:
// nothing to inject, so the source is not even consulted.
(_, ToolRendering::Inline) | (None, _) => HashMap::new(),
(Some(src), _) => src
.activations(input.frame)
.await
.unwrap_or_default()
.into_iter()
.fold(HashMap::<MessageId, Vec<Value>>::new(), |mut acc, a: Activation| {
acc.entry(a.anchor).or_default().extend(a.defs);
acc
}),
};
let boundary = history
.iter()
.rposition(|e| matches!(e.role, Role::User | Role::Agent));
// Trailing assistant rows are the in-flight turn's own rounds; the
// current turn's user messages sit just before them.
let mut media_turn_start = history.len();
while media_turn_start > 0
&& matches!(history[media_turn_start - 1].role, Role::Assistant)
{
media_turn_start -= 1;
}
while media_turn_start > 0
&& matches!(history[media_turn_start - 1].role, Role::User | Role::Agent)
{
media_turn_start -= 1;
}
Ok(Self {
cfg,
hooks,
model: &input.model,
activations,
boundary,
media_turn_start,
})
}
async fn project_message(&self, out: &mut Vec<Value>, idx: usize, entry: &StoredMessage) {
match entry.role {
// System messages are BUILT (layers 1-2), never replayed from the
// store; a host that stores them gets them back verbatim.
Role::System => out.push(json!({ "role": "system", "content": entry.content })),
Role::User | Role::Agent => self.push_user(out, idx, entry).await,
Role::Assistant => self.push_assistant(out, idx, entry).await,
}
}
/// A user/agent message: text plus, for the current turn, inlined media.
async fn push_user(&self, out: &mut Vec<Value>, idx: usize, entry: &StoredMessage) {
let mut text = entry.content.clone();
let mut parts: Vec<Value> = Vec::new();
if let Some(src) = &self.hooks.media {
let blobs = src.message_media(entry).await;
if !blobs.is_empty() {
// Older turns keep the textual path: everything is "skipped".
let (inlined, skipped) = if idx >= self.media_turn_start {
media::partition(&blobs, &self.model.capabilities, &self.cfg.media).await
} else {
(Vec::new(), (0..blobs.len()).collect())
};
if let Some(extra) = src.skipped_text(entry, &skipped) {
text.push_str(&extra);
}
parts = inlined;
}
}
push_user_chunk(out, text, parts);
}
/// An assistant message: the turn itself, then a result for every call, then
/// the append-only DTL injections.
async fn push_assistant(&self, out: &mut Vec<Value>, idx: usize, entry: &StoredMessage) {
let stored_reasoning = entry.reasoning.as_deref().filter(|s| !s.is_empty());
if entry.calls.is_empty() {
let mut msg = json!({ "role": "assistant", "content": entry.content });
if let Some(r) = stored_reasoning {
self.set_reasoning(&mut msg, r);
}
out.push(msg);
return;
}
let calls: Vec<Value> = entry
.calls
.iter()
.map(|c| {
json!({
"id": c.provider_id,
"type": "function",
"function": { "name": c.name, "arguments": wire_arguments(c) },
})
})
.collect();
let mut msg = json!({
"role": "assistant",
"content": entry.content,
"tool_calls": calls,
});
// A tool-calling turn may need a non-empty reasoning on replay even when
// none was recorded.
if let Some(r) = stored_reasoning.or(self.cfg.reasoning_placeholder.as_deref()) {
self.set_reasoning(&mut msg, r);
}
out.push(msg);
// One result per call, in call order — the model matches them by id.
let is_previous_turn = self.boundary.is_some_and(|b| idx < b);
let anchored = self.activations.get(&entry.id);
let mut marked = false;
for call in &entry.calls {
let mut tool_msg = json!({
"role": "tool",
"tool_call_id": call.provider_id,
"content": self.result_content(call, is_previous_turn).await,
});
// Anthropic DTL: the activation's result carries the marker its
// client turns into `tool_reference` blocks.
if self.model.tool_rendering == ToolRendering::DeferredToolReference
&& !marked
&& let Some(defs) = anchored
&& self.is_anchor(call)
{
let names: Vec<Value> = defs
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.map(|n| json!(n))
.collect();
if !names.is_empty() {
tool_msg["_tool_references"] = Value::Array(names);
marked = true;
}
}
out.push(tool_msg);
}
// Media a tool produced, as a synthetic user message right after the
// result group (the current turn only).
if idx >= self.media_turn_start
&& let Some(src) = &self.hooks.media
{
let blobs = src.call_media(&entry.calls).await;
if !blobs.is_empty() {
let (parts, _) =
media::partition(&blobs, &self.model.capabilities, &self.cfg.media).await;
if !parts.is_empty() {
out.push(json!({ "role": "user", "content": parts }));
}
}
}
// Kimi-style DTL: the activated defs as a `system` message carrying a
// `tools` field, appended after the group — the prefix stays identical.
if self.model.tool_rendering == ToolRendering::SystemToolBlock
&& let Some(defs) = anchored
&& !defs.is_empty()
{
out.push(json!({ "role": "system", "tools": defs }));
}
}
fn set_reasoning(&self, msg: &mut Value, reasoning: &str) {
msg["reasoning_content"] = json!(reasoning);
if self.cfg.reasoning_echo == ReasoningEcho::Both {
msg["reasoning"] = json!(reasoning);
}
}
/// Whether this call is the DTL anchor within its message.
fn is_anchor(&self, call: &StoredCall) -> bool {
match &self.cfg.activation_anchor_tool {
Some(name) => &call.name == name,
None => true, // the first result of the message
}
}
/// The tool result text: the well-formedness rule of contract point 2, then
/// the size gate.
async fn result_content(&self, call: &StoredCall, is_previous_turn: bool) -> String {
let content = match call.state {
CallState::Done => call.result.clone().unwrap_or_default(),
CallState::Failed => {
format!("Error: {}", call.result.as_deref().unwrap_or("unknown error"))
}
// A recorded reason wins; an absent or empty one falls back to the
// configured note — a model must never read an empty tool result
// and have to guess what happened.
CallState::Rejected => non_empty(&call.result)
.unwrap_or_else(|| self.cfg.rejected_default.clone()),
CallState::Cancelled => non_empty(&call.result)
.unwrap_or_else(|| self.cfg.cancelled_default.clone()),
// Running / AwaitingHuman reaching the projection means the process
// died mid-flight: the call really was interrupted.
CallState::Running | CallState::AwaitingHuman => self.cfg.interrupted_text.clone(),
};
let Some(limit) = self.cfg.max_tool_result else {
return content;
};
if limit.previous_turns_only && !is_previous_turn {
return content;
}
if content.len() <= limit.max_chars {
return content;
}
if let Some(d) = &self.hooks.digest
&& let Some(short) = d.condense(&call.name, &call.arguments, &content).await
{
return short;
}
format!(
"{}… [truncated]",
content.chars().take(limit.max_chars).collect::<String>()
)
}
}
fn non_empty(s: &Option<String>) -> Option<String> {
s.clone().filter(|s| !s.is_empty())
}
/// The arguments string sent back on the wire. The **raw recorded string** wins:
/// re-serializing a parsed `Value` reorders object keys (serde_json's map is
/// ordered), which would change the bytes the model produced and break the
/// prompt-cache prefix.
fn wire_arguments(call: &StoredCall) -> String {
match &call.arguments_raw {
Some(raw) => raw.clone(),
None => serde_json::to_string(&call.arguments).unwrap_or_else(|_| "{}".into()),
}
}
/// Append one user/agent chunk, coalescing with a preceding `user` message —
/// consecutive user rows are one wire message, so strict-alternation APIs stay
/// happy. Media parts keep their position relative to the text.
pub fn push_user_chunk(out: &mut Vec<Value>, text: String, media: Vec<Value>) {
fn text_part(t: &str) -> Value {
json!({ "type": "text", "text": t })
}
if let Some(last) = out.last_mut()
&& last["role"] == "user"
{
if !last["content"].is_array() && media.is_empty() {
let prev = last["content"].as_str().unwrap_or("").to_string();
last["content"] = Value::String(format!("{prev}\n\n{text}"));
return;
}
let mut parts = match last["content"].take() {
Value::Array(a) => a,
Value::String(s) => vec![text_part(&s)],
_ => Vec::new(),
};
if let Some(tp) = parts.iter_mut().rev().find(|p| p["type"] == "text") {
let prev = tp["text"].as_str().unwrap_or("").to_string();
tp["text"] = Value::String(format!("{prev}\n\n{text}"));
} else {
parts.insert(0, text_part(&text));
}
parts.extend(media);
last["content"] = Value::Array(parts);
return;
}
if media.is_empty() {
out.push(json!({ "role": "user", "content": text }));
} else {
let mut parts = vec![text_part(&text)];
parts.extend(media);
out.push(json!({ "role": "user", "content": parts }));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coalesces_consecutive_user_messages() {
let mut out = vec![];
push_user_chunk(&mut out, "one".into(), vec![]);
push_user_chunk(&mut out, "two".into(), vec![]);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["content"], "one\n\ntwo");
}
#[test]
fn media_promotes_the_chunk_to_a_parts_array() {
let mut out = vec![];
let part = json!({ "type": "image_url", "image_url": { "url": "data:x" } });
push_user_chunk(&mut out, "look".into(), vec![part.clone()]);
assert_eq!(out[0]["content"][0]["type"], "text");
assert_eq!(out[0]["content"][1], part);
// A following text chunk folds into the LAST text part, keeping the
// media after it.
push_user_chunk(&mut out, "more".into(), vec![]);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["content"][0]["text"], "look\n\nmore");
assert_eq!(out[0]["content"][1], part);
}
#[test]
fn a_non_user_tail_starts_a_new_chunk() {
let mut out = vec![json!({ "role": "assistant", "content": "hi" })];
push_user_chunk(&mut out, "next".into(), vec![]);
assert_eq!(out.len(), 2);
assert_eq!(out[1]["role"], "user");
}
#[test]
fn raw_arguments_win_over_the_parsed_value() {
let mut call = StoredCall {
id: crate::ids::ToolCallId(1),
message_id: MessageId(1),
provider_id: "c1".into(),
name: "write_file".into(),
arguments: json!({ "a": 1, "z": 2 }),
arguments_raw: Some(r#"{"z":2,"a":1}"#.to_string()),
state: CallState::Done,
result: None,
result_kind: "text".into(),
extras: Value::Null,
};
assert_eq!(wire_arguments(&call), r#"{"z":2,"a":1}"#);
call.arguments_raw = None;
assert_eq!(wire_arguments(&call), r#"{"a":1,"z":2}"#);
}
}
+727
View File
@@ -0,0 +1,727 @@
//! Restart recovery (blueprint §8) — turning a half-written conversation back
//! into a well-formed one, then running a **normal loop** on it.
//!
//! There is no "recovery mode" in the kernel. Every state transition is written
//! the instant it happens (see [`crate::store`]), so a crash loses RAM — the
//! approval oneshot, the cancellation token — never the truth. What it leaves
//! behind is a store that a model would choke on: calls with no result, a child
//! frame whose answer nobody propagated, a half-run parallel batch. This module
//! repairs exactly those, then hands the frame to the same `LlmLoop` a live turn
//! uses.
//!
//! The order matters and mirrors `resume.rs`, the path this replaces:
//!
//! 1. **Reap** an interrupted parallel batch (≥2 active frames at one depth).
//! 2. **Resolve** the deepest active frame's non-terminal calls, by policy and
//! by each tool's [`RestartHint`].
//! 3. **Un-wedge**: a child that finished but never told its parent.
//! 4. **Cascade**: run the frame, resolve its parent's call with the result,
//! close it, walk up — every frame with **its own** agent's config (B3), read
//! from the catalog, never the root's.
use std::collections::HashMap;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use crate::delegate::{AgentCatalog, FilteredToolSet};
use crate::events::{EventSink, LoopEvent, PendingToolCall};
use crate::ids::{ConversationId, FrameId};
use crate::kernel::{PreExecution, TurnOutcome};
use crate::manager::{LoopManager, LoopParams, TurnMeta, TurnParams};
use crate::store::{CallOutcome, CallState, FrameRecord, Role, StoredCall};
use crate::tool::{ExecutionOutcome, RestartHint, ToolCtx, ToolSet, drive_execution};
// ── Policy ───────────────────────────────────────────────────────────────────
/// What to do with a call that was `Running` when the process died.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RunningPolicy {
/// Re-gate and re-execute, unless the tool's own [`RestartHint`] says
/// otherwise (which always wins: only the tool knows if it is idempotent).
#[default]
ReExecute,
/// Never re-run: resolve every interrupted call as failed.
MarkInterrupted,
}
/// What to do with a call that was waiting on a human.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PendingPolicy {
/// Ask again — the approval card reappears (today's behavior).
#[default]
ReAsk,
/// Leave it pending for an out-of-band decision
/// ([`LoopManager::resolve_pending`]), and stop: the frame cannot run with
/// an unanswered call in it.
LeavePending,
}
#[derive(Debug, Clone)]
pub struct RecoveryPolicy {
pub on_running: RunningPolicy,
pub on_awaiting_human: PendingPolicy,
/// Recorded on a call that is not re-run.
pub interrupted_text: String,
/// Recorded on the delegating call of a reaped parallel batch.
pub batch_reaped_text: String,
}
impl Default for RecoveryPolicy {
fn default() -> Self {
Self {
on_running: RunningPolicy::default(),
on_awaiting_human: PendingPolicy::default(),
interrupted_text: "Tool call interrupted by a restart.".to_string(),
batch_reaped_text: "Sub-agent interrupted by restart (parallel batch).".to_string(),
}
}
}
/// What a recovery pass did — logged by hosts, asserted by tests.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RecoveryReport {
pub frames_resumed: usize,
pub calls_reexecuted: usize,
pub calls_failed: usize,
pub batches_reaped: usize,
/// A call was left `AwaitingHuman`: the conversation waits for a decision.
pub left_pending: bool,
}
// ── Recovery ─────────────────────────────────────────────────────────────────
pub struct Recovery {
manager: Arc<LoopManager>,
catalog: Arc<dyn AgentCatalog>,
policy: RecoveryPolicy,
}
impl Recovery {
pub fn new(
manager: Arc<LoopManager>,
catalog: Arc<dyn AgentCatalog>,
policy: RecoveryPolicy,
) -> Self {
Self { manager, catalog, policy }
}
/// Recover one conversation. `root` is what the **root** frame runs with —
/// the host's own turn parameters, since no catalog describes the entry
/// agent; `root.frame` must be that root frame, and `root.live_input` is
/// ignored (a recovery is not a live turn).
///
/// Refuses while a loop is already live on the conversation: that loop is
/// already the thing driving it.
pub async fn run(
&self,
conv: &ConversationId,
root: &TurnParams,
) -> crate::Result<RecoveryReport> {
let Some(claim) = self.manager.claim(conv, root.frame, &root.agent) else {
info!(%conv, "recovery: a loop is already running — nothing to do");
return Ok(RecoveryReport::default());
};
let token = claim.token();
let events = self.manager.sink_for(conv.clone());
let store = self.manager.store();
let mut report = RecoveryReport::default();
// ── 1. reap an interrupted parallel batch ──
self.reap_batches(conv, &mut report).await?;
// ── 2. the deepest active frame is where the conversation stopped ──
let Some(mut frame) = store.deepest_active(conv).await? else {
info!(%conv, "recovery: no active frame — nothing to resume");
return Ok(report);
};
let mut params = self.params_for(&frame, root, conv).await?;
let pending = self
.resolve_frame_calls(conv, &frame, &params, &token, &events, &mut report)
.await?;
if report.left_pending {
return Ok(report);
}
// ── 3. un-wedge: a finished child whose result never reached its parent ──
let mut outcome = match self.completed_without_propagating(&frame, pending).await? {
Some(o) => o,
None => {
report.frames_resumed += 1;
self.run_frame(&params, &token, conv, frame.id, frame.parent).await?
}
};
// ── 4. cascade to the root ──
while let Some(parent_call) = frame.spec.parent_call {
let result = child_result(&outcome, &frame.spec.agent);
match &result {
Ok(text) => store.resolve_call(parent_call, &CallOutcome::Completed(
crate::tool::ToolOutput::Text(text.clone()),
)).await?,
Err(text) => store.resolve_call(parent_call, &CallOutcome::Failed(text.clone())).await?,
}
let (text, failed) = match result {
Ok(t) => (t, false),
Err(t) => (t, true),
};
self.catalog.on_child_closed(frame.id).await;
store.close_frame(frame.id).await?;
let parent = match store.frame_of_call(parent_call).await? {
Some(p) => p,
None => {
warn!(%conv, call = %parent_call, "recovery: the call's frame is gone");
break;
}
};
events.emit(frame.id, Some(parent.id), LoopEvent::AgentFinished {
frame: frame.id,
agent: frame.spec.agent.clone(),
result_preview: crate::delegate::preview_truncate(&text, 500),
parent_agent: parent.spec.agent.clone(),
});
events.emit(parent.id, parent.parent, LoopEvent::ToolCallFinished {
id: parent_call,
outcome: if failed {
CallOutcome::Failed(text)
} else {
CallOutcome::Completed(crate::tool::ToolOutput::Text(text))
},
});
frame = parent;
params = self.params_for(&frame, root, conv).await?;
self.resolve_frame_calls(conv, &frame, &params, &token, &events, &mut report)
.await?;
if report.left_pending {
return Ok(report);
}
report.frames_resumed += 1;
outcome = self.run_frame(&params, &token, conv, frame.id, frame.parent).await?;
}
drop(claim);
Ok(report)
}
/// Make the store well-formed **without continuing the conversation**: reap
/// an interrupted batch, resolve the deepest frame's dangling calls.
///
/// This is what a host runs before starting a *new* turn on a session that
/// died mid-tool: the user has something else to say, so nothing should
/// re-drive the old turn, but the model must not be shown a call with no
/// result. Unlike [`Self::run`] it does not claim the conversation — the
/// caller is already inside its own turn.
pub async fn repair(
&self,
conv: &ConversationId,
root: &TurnParams,
) -> crate::Result<RecoveryReport> {
let mut report = RecoveryReport::default();
self.reap_batches(conv, &mut report).await?;
if let Some(frame) = self.manager.store().deepest_active(conv).await? {
let params = self.params_for(&frame, root, conv).await?;
let token = CancellationToken::new();
let events = self.manager.sink_for(conv.clone());
self.resolve_frame_calls(conv, &frame, &params, &token, &events, &mut report)
.await?;
}
Ok(report)
}
/// Two or more active frames at one depth can only be a concurrent batch
/// caught mid-flight (a linear stack has at most one per depth). Recovering
/// it properly would mean re-driving several siblings; instead the batch is
/// pruned — deliberately lossy — and the parent continues with the failures
/// in view.
async fn reap_batches(
&self,
conv: &ConversationId,
report: &mut RecoveryReport,
) -> crate::Result<()> {
let store = self.manager.store();
let active = store.active_frames(conv).await?;
let Some(d_min) = shallowest_parallel_depth(&active) else {
return Ok(());
};
warn!(%conv, depth = d_min, "recovery: reaping an interrupted parallel batch");
for frame in active.iter().filter(|f| f.spec.depth >= d_min) {
if let Some(parent_call) = frame.spec.parent_call {
let _ = store
.resolve_call(
parent_call,
&CallOutcome::Failed(self.policy.batch_reaped_text.clone()),
)
.await;
}
let _ = store.close_frame(frame.id).await;
}
report.batches_reaped += 1;
Ok(())
}
/// Runs one frame's loop to completion, through the manager (so the turn is
/// an ordinary loop — same kernel, same events, same rules).
async fn run_frame(
&self,
params: &LoopParams,
token: &CancellationToken,
conv: &ConversationId,
frame: FrameId,
parent: Option<FrameId>,
) -> crate::Result<TurnOutcome> {
let handle = self
.manager
.start_loop(clone_params(params, conv, frame, parent, Some(token.clone())))
.await
.map_err(|e| anyhow::anyhow!("recovery: {e}"))?;
handle.join().await
}
/// Every non-terminal call of a frame, resolved per policy. Returns whether
/// anything at all was pending (the un-wedge check needs to know).
async fn resolve_frame_calls(
&self,
conv: &ConversationId,
frame: &FrameRecord,
params: &LoopParams,
token: &CancellationToken,
events: &EventSink,
report: &mut RecoveryReport,
) -> crate::Result<bool> {
let store = self.manager.store();
let calls = store
.calls_in_state(frame.id, &[CallState::Running, CallState::AwaitingHuman])
.await?;
if calls.is_empty() {
return Ok(false);
}
// A call that spawned a frame is the cascade's business: its result is
// the child's answer, not a re-execution. Structural, not by name — a
// host may register the delegate under any number of aliases.
let children = store.active_frames(conv).await?;
let spawned = |call: &StoredCall| {
children.iter().any(|f| f.spec.parent_call == Some(call.id))
};
for call in &calls {
if spawned(call) {
info!(call = %call.id, "recovery: sub-agent call left to the cascade");
continue;
}
let hint = params
.tools
.find(&call.name)
.map(|t| t.restart_hint())
.unwrap_or_default();
let re_execute = match call.state {
CallState::AwaitingHuman => match self.policy.on_awaiting_human {
PendingPolicy::ReAsk => true,
PendingPolicy::LeavePending => {
info!(call = %call.id, "recovery: leaving the call pending for a decision");
report.left_pending = true;
return Ok(true);
}
},
// The tool's own hint wins: only it knows whether re-running is
// safe (a shell command may already have had its effect).
_ => {
self.policy.on_running == RunningPolicy::ReExecute
&& hint == RestartHint::ReExecute
}
};
if !re_execute {
store
.resolve_call(
call.id,
&CallOutcome::Failed(self.policy.interrupted_text.clone()),
)
.await?;
events.emit(frame.id, frame.parent, LoopEvent::ToolCallFinished {
id: call.id,
outcome: CallOutcome::Failed(self.policy.interrupted_text.clone()),
});
report.calls_failed += 1;
continue;
}
if self.re_execute(call, params, token, events, frame).await? {
report.calls_reexecuted += 1;
} else {
// Suspended again (the human is still not there, or the channel
// closed): the call stays AwaitingHuman for the next attempt.
report.left_pending = true;
return Ok(true);
}
}
Ok(true)
}
/// Re-runs one call through the **normal** path — gate, hooks, tool — so a
/// rule change since the crash applies and the approval card reappears.
/// `Ok(false)` = it suspended again and must be left pending.
async fn re_execute(
&self,
call: &StoredCall,
params: &LoopParams,
token: &CancellationToken,
events: &EventSink,
frame: &FrameRecord,
) -> crate::Result<bool> {
let ptc = PendingToolCall {
id: call.id,
message_id: call.message_id,
provider_id: Some(call.provider_id.clone()).filter(|s| !s.is_empty()),
name: call.name.clone(),
arguments: call.arguments.clone(),
};
events.emit(frame.id, frame.parent, LoopEvent::ToolCallStarted {
id: ptc.id,
message_id: ptc.message_id,
name: ptc.name.clone(),
args: ptc.arguments.clone(),
});
let deps = self.manager.deps();
match crate::kernel::pre_execution(deps, params, events, token, &ptc).await? {
PreExecution::Run(tool) => {
let ctx = ToolCtx {
conversation: params.conversation.clone(),
frame: params.frame,
agent: params.agent.clone(),
call_id: ptc.id,
cancel: token.clone(),
extensions: crate::kernel::tool_extensions(params, events),
};
let exec = tool.start(ptc.arguments.clone(), &ctx);
match drive_execution(&*exec, token).await {
ExecutionOutcome::Suspended => Ok(false),
outcome => {
crate::kernel::record_outcome(
deps,
params,
events,
&self.manager.store(),
&ptc,
outcome.into_call_outcome(),
)
.await?;
Ok(true)
}
}
}
PreExecution::Resolved(outcome) => {
crate::kernel::record_outcome(
deps, params, events, &self.manager.store(), &ptc, outcome,
)
.await?;
Ok(true)
}
PreExecution::Suspended => Ok(false),
PreExecution::TurnCancelled => Ok(false),
}
}
/// The wedge case: nothing was pending and the frame's last message is a
/// plain assistant reply — its turn finished, and the process died before
/// the result reached the parent. Re-running the model would ask it to
/// answer a question it already answered, so the stored answer is used as
/// the outcome and only the propagation is redone.
///
/// On the ROOT frame the same shape means the turn is simply complete.
async fn completed_without_propagating(
&self,
frame: &FrameRecord,
had_pending: bool,
) -> crate::Result<Option<TurnOutcome>> {
if had_pending {
return Ok(None);
}
let Some(last) = self.manager.store().last(frame.id).await? else {
return Ok(None);
};
if last.role != Role::Assistant || !last.calls.is_empty() {
return Ok(None);
}
Ok(Some(TurnOutcome::Final {
content: last.content,
message_id: last.id,
usage: last.usage,
reasoning: last.reasoning,
}))
}
/// The parameters one frame runs with: the host's for the root, the
/// catalog's for every other (B3 — a resumed sub-agent is ITS agent, with
/// its prompt, its tools and its model).
async fn params_for(
&self,
frame: &FrameRecord,
root: &TurnParams,
conv: &ConversationId,
) -> crate::Result<LoopParams> {
let mut params = clone_params_from_turn(root, conv, frame.id, frame.parent);
if frame.spec.parent_call.is_none() {
return Ok(params);
}
let ctx = ToolCtx {
conversation: conv.clone(),
frame: frame.id,
agent: frame.spec.agent.clone(),
// The call that spawned this frame — the same handle the live
// dispatch had.
call_id: frame.spec.parent_call.unwrap(),
cancel: CancellationToken::new(),
extensions: root.extensions.clone(),
};
let profile = self.catalog.get(&frame.spec.agent, frame.id, &ctx).await?;
params.agent = frame.spec.agent.clone();
params.system = profile.context;
params.tools = match profile.toolset {
Some(ts) => ts,
None => Arc::new(FilteredToolSet::derive(root.tools.clone(), &profile.tools))
as Arc<dyn ToolSet>,
};
params.model_hint = profile.model.unwrap_or_default();
params.selector = profile.selector;
params.assembler = profile.assembler;
params.meta = TurnMeta { user_message: frame.spec.prompt.clone(), ..root.meta.clone() };
Ok(params)
}
}
// ── resolve_pending (blueprint §8.5) ─────────────────────────────────────────
/// A human's answer to a call that was waiting for one.
#[derive(Debug, Clone)]
pub enum HumanDecision {
Approved,
Rejected { reason: String },
}
/// Apply a human decision to a call nothing is driving anymore — the approval
/// card answered after a restart, or from the Inbox.
///
/// Approval **skips the gate**: the human is the gate, and re-running the rules
/// would ask them again. The call is executed through the normal tool path
/// (with the frame's own context, so a write lands in the caller's workspace,
/// never the server's cwd), then the conversation is recovered so the model
/// reads the result.
pub(crate) async fn resolve_pending(
manager: &Arc<LoopManager>,
call_id: crate::ids::ToolCallId,
decision: HumanDecision,
catalog: Arc<dyn AgentCatalog>,
root: &TurnParams,
) -> crate::Result<RecoveryReport> {
let store = manager.store();
let call = store
.get_call(call_id)
.await?
.ok_or_else(|| anyhow::anyhow!("resolve_pending: call {call_id} not found"))?;
if call.state.is_terminal() {
info!(call = %call_id, state = ?call.state, "resolve_pending: already resolved");
return Ok(RecoveryReport::default());
}
let frame = store
.frame_of_call(call_id)
.await?
.ok_or_else(|| anyhow::anyhow!("resolve_pending: no frame for call {call_id}"))?;
let conv = frame.conversation.clone();
match decision {
HumanDecision::Rejected { reason } => {
store.resolve_call(call_id, &CallOutcome::Rejected { reason: reason.clone() }).await?;
manager.sink_for(conv.clone()).emit(frame.id, frame.parent, LoopEvent::ToolCallFinished {
id: call_id,
outcome: CallOutcome::Rejected { reason },
});
}
HumanDecision::Approved => {
// Claimed for the execution only: the recovery below takes its own.
let outcome = {
let Some(claim) = manager.claim(&conv, frame.id, &frame.spec.agent) else {
anyhow::bail!("resolve_pending: a loop is already running on {conv}");
};
let token = claim.token();
let events = manager.sink_for(conv.clone());
let params = clone_params_from_turn(root, &conv, frame.id, frame.parent);
let ext = crate::kernel::tool_extensions(&params, &events);
match params.tools.find(&call.name) {
Some(tool) => {
let ctx = ToolCtx {
conversation: conv.clone(),
frame: frame.id,
agent: frame.spec.agent.clone(),
call_id,
cancel: token.clone(),
extensions: ext,
};
let exec = tool.start(call.arguments.clone(), &ctx);
match drive_execution(&*exec, &token).await {
// Suspending again would need another human: leave
// it pending rather than resolving it as cancelled.
ExecutionOutcome::Suspended => None,
outcome => Some(outcome.into_call_outcome()),
}
}
None => Some(CallOutcome::Failed(format!(
"unknown tool '{}' (not in this turn's tool set)",
call.name
))),
}
};
let Some(outcome) = outcome else {
return Ok(RecoveryReport { left_pending: true, ..RecoveryReport::default() });
};
store.resolve_call(call_id, &outcome).await?;
manager.sink_for(conv.clone()).emit(frame.id, frame.parent, LoopEvent::ToolCallFinished {
id: call_id,
outcome,
});
}
}
// The history is well-formed again: a normal recovery continues the turn.
Recovery::new(manager.clone(), catalog, RecoveryPolicy::default())
.run(&conv, root)
.await
}
// ── helpers ──────────────────────────────────────────────────────────────────
/// The text a finished child propagates to its parent's call — `Err` when the
/// child did not produce an answer.
fn child_result(outcome: &TurnOutcome, agent: &str) -> Result<String, String> {
match outcome {
TurnOutcome::Final { content, .. } => Ok(content.clone()),
TurnOutcome::Cancelled => Err(format!("Sub-agent `{agent}` was cancelled.")),
TurnOutcome::Exhausted => Err(format!("Sub-agent `{agent}` exhausted tool-call rounds.")),
}
}
fn clone_params_from_turn(
root: &TurnParams,
conv: &ConversationId,
frame: FrameId,
parent: Option<FrameId>,
) -> LoopParams {
LoopParams {
conversation: conv.clone(),
frame,
parent_frame: parent,
agent: root.agent.clone(),
system: root.system.clone(),
tools: root.tools.clone(),
model_hint: root.model_hint.clone(),
selector: root.selector.clone(),
token: None,
// A recovery is not a live turn: no live input, and no tail reminder
// semantics — the host decides that when it builds `root`.
live_input: None,
extensions: root.extensions.clone(),
meta: root.meta.clone(),
assembler: root.assembler.clone(),
}
}
fn clone_params(
p: &LoopParams,
conv: &ConversationId,
frame: FrameId,
parent: Option<FrameId>,
token: Option<CancellationToken>,
) -> LoopParams {
LoopParams {
conversation: conv.clone(),
frame,
parent_frame: parent,
agent: p.agent.clone(),
system: p.system.clone(),
tools: p.tools.clone(),
model_hint: p.model_hint.clone(),
selector: p.selector.clone(),
token,
live_input: None,
extensions: p.extensions.clone(),
meta: p.meta.clone(),
assembler: p.assembler.clone(),
}
}
/// Shallowest depth holding more than one active frame — the top of an
/// interrupted parallel batch. `None` for a linear stack, where every depth has
/// at most one active frame. Pure (see tests).
pub fn shallowest_parallel_depth(active: &[FrameRecord]) -> Option<u32> {
let mut by_depth: HashMap<u32, usize> = HashMap::new();
for f in active {
*by_depth.entry(f.spec.depth).or_default() += 1;
}
by_depth
.iter()
.filter_map(|(depth, count)| (*count > 1).then_some(*depth))
.min()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::ToolCallId;
use crate::store::FrameSpec;
fn frame(id: i64, depth: u32, parent_call: Option<i64>) -> FrameRecord {
FrameRecord {
id: FrameId(id),
conversation: ConversationId::new("c"),
parent: None,
spec: FrameSpec {
agent: "agent".into(),
prompt: None,
depth,
parent_call: parent_call.map(ToolCallId),
meta: serde_json::Value::Null,
},
active: true,
}
}
#[test]
fn linear_stack_is_not_a_batch() {
let frames = vec![frame(1, 0, None), frame(2, 1, Some(10)), frame(3, 2, Some(20))];
assert_eq!(shallowest_parallel_depth(&frames), None);
assert_eq!(shallowest_parallel_depth(&[]), None);
}
#[test]
fn detects_shallowest_multi_frame_depth() {
// Two siblings at depth 1 (parallel batch) plus a grandchild at depth 2.
let frames = vec![
frame(1, 0, None),
frame(2, 1, Some(10)),
frame(3, 1, Some(11)),
frame(4, 2, Some(30)),
];
assert_eq!(shallowest_parallel_depth(&frames), Some(1));
}
#[test]
fn detects_deeper_batch_when_upper_levels_linear() {
let frames = vec![
frame(1, 0, None),
frame(2, 1, Some(10)),
frame(3, 2, Some(20)),
frame(4, 2, Some(21)),
];
assert_eq!(shallowest_parallel_depth(&frames), Some(2));
}
}
+300
View File
@@ -0,0 +1,300 @@
//! `HistoryStore` — the durability heart of the loop.
//!
//! Contract (enforced by doc, relied upon by recovery):
//!
//! 1. **Every state transition is an immediate write** — the kernel never
//! accumulates state in RAM. A crash loses only RAM, never truth.
//! 2. `MessageId`/`ToolCallId` are **monotonically increasing per frame**.
//! 3. `resolve_call` is the ONLY path to terminal states; `set_call_state`
//! is only for `Running → AwaitingHuman`.
//! 4. `load` returns calls nested inside their messages — the input of the
//! assembler's well-formed projection.
use async_trait::async_trait;
use serde_json::Value;
use crate::ids::{ConversationId, FrameId, MessageId, SummaryId, ToolCallId};
use crate::model::Usage;
// ── Role ─────────────────────────────────────────────────────────────────────
/// Who produced a message. `Agent` is an injected agent-to-agent message
/// (sub-agent prompt, async result delivery); it projects to `user` on the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
System,
User,
Assistant,
Agent,
}
// ── CallState ────────────────────────────────────────────────────────────────
/// Lifecycle of a tool call — semantics identical to Skald's
/// `chat_llm_tools.status`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallState {
/// Was executing at crash time → interrupted (NOT terminal).
Running,
/// 'pending': approval or clarification in flight (NOT terminal).
AwaitingHuman,
/// Terminal.
Done,
/// Terminal.
Failed,
/// Deliberate /stop — NEVER re-execute.
Cancelled,
/// Policy/human denial — NEVER re-execute.
Rejected,
}
impl CallState {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Done | Self::Failed | Self::Cancelled | Self::Rejected)
}
}
// ── CallOutcome ──────────────────────────────────────────────────────────────
/// The result of an execution, before recording.
#[derive(Debug, Clone)]
pub enum CallOutcome {
Completed(crate::tool::ToolOutput),
Failed(String),
Cancelled,
Rejected { reason: String },
}
impl CallOutcome {
pub fn state(&self) -> CallState {
match self {
Self::Completed(_) => CallState::Done,
Self::Failed(_) => CallState::Failed,
Self::Cancelled => CallState::Cancelled,
Self::Rejected { .. } => CallState::Rejected,
}
}
/// Text persisted as the call's result. Kept RAW (the assembler formats
/// for the model: `Failed` results get their "Error:" prefix at
/// projection time, not here) so hosts with an existing schema (Skald's
/// `chat_llm_tools.result`) round-trip byte-identically.
pub fn result_text(&self) -> String {
match self {
Self::Completed(out) => out.to_wire(),
Self::Failed(e) => e.clone(),
Self::Cancelled => "Cancelled by user.".to_string(),
Self::Rejected { reason } => reason.clone(),
}
}
pub fn result_kind(&self) -> &'static str {
match self {
Self::Completed(out) => out.kind(),
Self::Failed(_) => "error",
Self::Cancelled => "cancelled",
Self::Rejected { .. } => "rejected",
}
}
}
// ── Frames ───────────────────────────────────────────────────────────────────
/// What a frame is opened with (a sub-agent dispatch; the root carries the
/// conversation's entry agent).
#[derive(Debug, Clone)]
pub struct FrameSpec {
/// Agent id in the HOST's catalog (opaque to the crate).
pub agent: String,
/// The sub-agent's prompt (root: None).
pub prompt: Option<String>,
pub depth: u32,
/// The parent frame's tool call that spawned this frame.
pub parent_call: Option<ToolCallId>,
/// Host free-form (run_context_json, …).
pub meta: Value,
}
impl FrameSpec {
pub fn root(agent: impl Into<String>) -> Self {
Self {
agent: agent.into(),
prompt: None,
depth: 0,
parent_call: None,
meta: Value::Null,
}
}
}
/// A stored frame.
#[derive(Debug, Clone)]
pub struct FrameRecord {
pub id: FrameId,
pub conversation: ConversationId,
pub parent: Option<FrameId>,
pub spec: FrameSpec,
pub active: bool,
}
// ── Messages ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct NewMessage {
pub role: Role,
pub content: String,
/// TIC/notify/injection: not echoed to the UI as a user message.
pub synthetic: bool,
pub reasoning: Option<String>,
/// Attachments, command display, … (host free-form).
pub metadata: Option<Value>,
}
impl NewMessage {
pub fn user(content: impl Into<String>) -> Self {
Self { role: Role::User, content: content.into(), synthetic: false, reasoning: None, metadata: None }
}
pub fn assistant(content: impl Into<String>, reasoning: Option<String>) -> Self {
Self { role: Role::Assistant, content: content.into(), synthetic: false, reasoning, metadata: None }
}
pub fn agent(content: impl Into<String>) -> Self {
Self { role: Role::Agent, content: content.into(), synthetic: false, reasoning: None, metadata: None }
}
pub fn synthetic(mut self, synthetic: bool) -> Self {
self.synthetic = synthetic;
self
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
}
/// A stored message with its tool calls nested.
#[derive(Debug, Clone)]
pub struct StoredMessage {
pub id: MessageId,
pub role: Role,
pub content: String,
pub reasoning: Option<String>,
pub synthetic: bool,
/// Orphan of a cancelled turn — excluded from `load`.
pub failed: bool,
pub metadata: Option<Value>,
pub usage: Usage,
pub calls: Vec<StoredCall>,
}
// ── Tool calls ───────────────────────────────────────────────────────────────
/// What a call is recorded with, BEFORE execution (phase 1 of the fan-out).
#[derive(Debug, Clone)]
pub struct NewCall {
/// The model's wire call id ("call_abc", "toolu_…"), needed to rebuild
/// `tool_calls`/`tool` wire messages. Synthesized by the store when absent.
pub provider_id: Option<String>,
pub name: String,
pub arguments: Value,
}
impl NewCall {
pub fn new(name: impl Into<String>, arguments: Value) -> Self {
Self { provider_id: None, name: name.into(), arguments }
}
pub fn with_provider_id(mut self, id: impl Into<String>) -> Self {
self.provider_id = Some(id.into());
self
}
}
/// A stored tool call.
#[derive(Debug, Clone)]
pub struct StoredCall {
pub id: ToolCallId,
pub message_id: MessageId,
/// The model's wire call id (see [`NewCall::provider_id`]).
pub provider_id: String,
pub name: String,
pub arguments: Value,
/// The arguments **exactly as the model emitted them**, when the store kept
/// the string. The projection replays this verbatim: re-serializing
/// [`Self::arguments`] reorders object keys, which changes the bytes the
/// model produced and breaks the prompt-cache prefix.
pub arguments_raw: Option<String>,
pub state: CallState,
pub result: Option<String>,
pub result_kind: String,
/// Host free-form (Skald: preview_old/new, media refs).
pub extras: Value,
}
// ── Summaries ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct NewSummary {
pub text: String,
/// Last message covered by the summary — the projection resumes after it.
pub covered_up_to: MessageId,
}
#[derive(Debug, Clone)]
pub struct StoredSummary {
pub id: SummaryId,
pub text: String,
pub covered_up_to: MessageId,
}
// ── HistoryStore ─────────────────────────────────────────────────────────────
#[async_trait]
pub trait HistoryStore: Send + Sync {
// ── frames ──
async fn open_frame(
&self,
conv: &ConversationId,
parent: Option<FrameId>,
spec: FrameSpec,
) -> crate::Result<FrameId>;
async fn close_frame(&self, frame: FrameId) -> crate::Result<()>;
/// One frame by id (DelegateTool depth checks, recovery).
async fn get_frame(&self, frame: FrameId) -> crate::Result<Option<FrameRecord>>;
/// All active frames of a conversation (recovery: batch detection, cascade).
async fn active_frames(&self, conv: &ConversationId) -> crate::Result<Vec<FrameRecord>>;
async fn deepest_active(&self, conv: &ConversationId) -> crate::Result<Option<FrameRecord>>;
// ── messages ──
async fn append(&self, frame: FrameId, msg: NewMessage) -> crate::Result<MessageId>;
async fn set_usage(&self, msg: MessageId, usage: &Usage) -> crate::Result<()>;
/// Frame history with calls nested per message. EXCLUDES failed messages
/// (orphans of cancelled turns).
async fn load(&self, frame: FrameId) -> crate::Result<Vec<StoredMessage>>;
async fn load_since(&self, frame: FrameId, after: MessageId) -> crate::Result<Vec<StoredMessage>>;
async fn last(&self, frame: FrameId) -> crate::Result<Option<StoredMessage>>;
async fn mark_failed(&self, msg: MessageId) -> crate::Result<()>;
// ── tool calls ──
async fn append_call(&self, msg: MessageId, call: NewCall) -> crate::Result<ToolCallId>;
/// The ONLY path to terminal states.
async fn resolve_call(&self, id: ToolCallId, outcome: &CallOutcome) -> crate::Result<()>;
/// Only `Running → AwaitingHuman`.
async fn set_call_state(&self, id: ToolCallId, state: CallState) -> crate::Result<()>;
/// One call by id (translators enriching finish events, recovery).
async fn get_call(&self, id: ToolCallId) -> crate::Result<Option<StoredCall>>;
/// The frame a call belongs to. Recovery walks the cascade with it, and an
/// out-of-band resolution (an approval answered from a REST endpoint) has
/// nothing but a call id to start from.
async fn frame_of_call(&self, id: ToolCallId) -> crate::Result<Option<FrameRecord>>;
/// Merge host free-form extras into a call (Skald: diff preview, media).
/// Keys not understood by the store are ignored.
async fn set_call_extras(&self, id: ToolCallId, extras: Value) -> crate::Result<()>;
async fn calls_in_state(&self, frame: FrameId, states: &[CallState]) -> crate::Result<Vec<StoredCall>>;
// ── summaries ──
async fn save_summary(&self, frame: FrameId, s: NewSummary) -> crate::Result<SummaryId>;
async fn latest_summary(&self, frame: FrameId) -> crate::Result<Option<StoredSummary>>;
}
+301
View File
@@ -0,0 +1,301 @@
//! `InMemoryStore` — the shipped non-persistent store (chat not persisted;
//! testing; simple hosts). Monotonic ids per the store contract.
use std::collections::HashMap;
use std::sync::Mutex;
use async_trait::async_trait;
use crate::ids::{ConversationId, FrameId, MessageId, SummaryId, ToolCallId};
use crate::model::Usage;
use crate::store::{
CallOutcome, CallState, FrameRecord, FrameSpec, HistoryStore, NewCall, NewMessage, NewSummary,
StoredCall, StoredMessage, StoredSummary,
};
#[derive(Default)]
struct Inner {
frames: HashMap<FrameId, FrameRecord>,
messages: HashMap<FrameId, Vec<StoredMessage>>,
calls: HashMap<MessageId, Vec<StoredCall>>,
summaries: HashMap<FrameId, Vec<StoredSummary>>,
next_frame: i64,
next_msg: i64,
next_call: i64,
next_summary: i64,
}
/// Non-persistent store. A "crash" loses everything — which is exactly why
/// it's also the natural target for recovery scenario tests (build the
/// post-crash state by hand).
pub struct InMemoryStore {
inner: Mutex<Inner>,
}
impl InMemoryStore {
pub fn new() -> Self { Self { inner: Mutex::new(Inner::default()) } }
}
impl Default for InMemoryStore {
fn default() -> Self { Self::new() }
}
#[async_trait]
impl HistoryStore for InMemoryStore {
async fn open_frame(
&self,
conv: &ConversationId,
parent: Option<FrameId>,
spec: FrameSpec,
) -> crate::Result<FrameId> {
let mut i = self.inner.lock().unwrap();
i.next_frame += 1;
let id = FrameId(i.next_frame);
i.frames.insert(id, FrameRecord {
id,
conversation: conv.clone(),
parent,
spec,
active: true,
});
Ok(id)
}
async fn close_frame(&self, frame: FrameId) -> crate::Result<()> {
let mut i = self.inner.lock().unwrap();
if let Some(f) = i.frames.get_mut(&frame) {
f.active = false;
}
Ok(())
}
async fn get_frame(&self, frame: FrameId) -> crate::Result<Option<FrameRecord>> {
let i = self.inner.lock().unwrap();
Ok(i.frames.get(&frame).cloned())
}
async fn active_frames(&self, conv: &ConversationId) -> crate::Result<Vec<FrameRecord>> {
let i = self.inner.lock().unwrap();
Ok(i.frames.values().filter(|f| f.active && &f.conversation == conv).cloned().collect())
}
async fn deepest_active(&self, conv: &ConversationId) -> crate::Result<Option<FrameRecord>> {
let i = self.inner.lock().unwrap();
Ok(i.frames
.values()
.filter(|f| f.active && &f.conversation == conv)
.max_by_key(|f| f.spec.depth)
.cloned())
}
async fn append(&self, frame: FrameId, msg: NewMessage) -> crate::Result<MessageId> {
let mut i = self.inner.lock().unwrap();
i.next_msg += 1;
let id = MessageId(i.next_msg);
i.messages.entry(frame).or_default().push(StoredMessage {
id,
role: msg.role,
content: msg.content,
reasoning: msg.reasoning,
synthetic: msg.synthetic,
failed: false,
metadata: msg.metadata,
usage: Usage::default(),
calls: Vec::new(),
});
Ok(id)
}
async fn set_usage(&self, msg: MessageId, usage: &Usage) -> crate::Result<()> {
let mut i = self.inner.lock().unwrap();
for msgs in i.messages.values_mut() {
if let Some(m) = msgs.iter_mut().find(|m| m.id == msg) {
m.usage = usage.clone();
return Ok(());
}
}
Ok(())
}
async fn load(&self, frame: FrameId) -> crate::Result<Vec<StoredMessage>> {
let i = self.inner.lock().unwrap();
Ok(load_frame(&i, frame, None))
}
async fn load_since(&self, frame: FrameId, after: MessageId) -> crate::Result<Vec<StoredMessage>> {
let i = self.inner.lock().unwrap();
Ok(load_frame(&i, frame, Some(after)))
}
async fn last(&self, frame: FrameId) -> crate::Result<Option<StoredMessage>> {
let i = self.inner.lock().unwrap();
Ok(load_frame(&i, frame, None).into_iter().last())
}
async fn mark_failed(&self, msg: MessageId) -> crate::Result<()> {
let mut i = self.inner.lock().unwrap();
for msgs in i.messages.values_mut() {
if let Some(m) = msgs.iter_mut().find(|m| m.id == msg) {
m.failed = true;
return Ok(());
}
}
Ok(())
}
async fn append_call(&self, msg: MessageId, call: NewCall) -> crate::Result<ToolCallId> {
let mut i = self.inner.lock().unwrap();
i.next_call += 1;
let id = ToolCallId(i.next_call);
let provider_id = call.provider_id.unwrap_or_else(|| format!("call_{}", id.get()));
let stored = StoredCall {
id,
message_id: msg,
provider_id,
name: call.name,
arguments: call.arguments,
// Nothing to replay verbatim: this store never saw a wire string.
arguments_raw: None,
state: CallState::Running,
result: None,
result_kind: String::new(),
extras: serde_json::Value::Null,
};
i.calls.entry(msg).or_default().push(stored.clone());
// Keep the nested copy inside the message in sync.
for msgs in i.messages.values_mut() {
if let Some(m) = msgs.iter_mut().find(|m| m.id == msg) {
m.calls.push(stored);
break;
}
}
Ok(id)
}
async fn resolve_call(&self, id: ToolCallId, outcome: &CallOutcome) -> crate::Result<()> {
let mut i = self.inner.lock().unwrap();
update_call(&mut i, id, |c| {
c.state = outcome.state();
c.result = Some(outcome.result_text());
c.result_kind = outcome.result_kind().to_string();
});
Ok(())
}
async fn set_call_state(&self, id: ToolCallId, state: CallState) -> crate::Result<()> {
anyhow::ensure!(
!state.is_terminal(),
"set_call_state is only for Running → AwaitingHuman, not terminal {state:?}"
);
let mut i = self.inner.lock().unwrap();
update_call(&mut i, id, |c| c.state = state);
Ok(())
}
async fn get_call(&self, id: ToolCallId) -> crate::Result<Option<StoredCall>> {
let i = self.inner.lock().unwrap();
Ok(i.calls.values().flatten().find(|c| c.id == id).cloned())
}
async fn frame_of_call(&self, id: ToolCallId) -> crate::Result<Option<FrameRecord>> {
let i = self.inner.lock().unwrap();
let Some(msg_id) = i
.calls
.values()
.flatten()
.find(|c| c.id == id)
.map(|c| c.message_id)
else {
return Ok(None);
};
let frame = i
.messages
.iter()
.find(|(_, msgs)| msgs.iter().any(|m| m.id == msg_id))
.map(|(frame, _)| *frame);
Ok(frame.and_then(|f| i.frames.get(&f).cloned()))
}
async fn set_call_extras(&self, id: ToolCallId, extras: serde_json::Value) -> crate::Result<()> {
let mut i = self.inner.lock().unwrap();
update_call(&mut i, id, |c| {
if let (Some(dst), Some(src)) = (c.extras.as_object_mut(), extras.as_object()) {
for (k, v) in src {
dst.insert(k.clone(), v.clone());
}
} else {
c.extras = extras.clone();
}
});
Ok(())
}
async fn calls_in_state(&self, frame: FrameId, states: &[CallState]) -> crate::Result<Vec<StoredCall>> {
let i = self.inner.lock().unwrap();
Ok(i.messages
.get(&frame)
.map(|msgs| {
msgs.iter()
.flat_map(|m| &m.calls)
.filter(|c| states.contains(&c.state))
.cloned()
.collect()
})
.unwrap_or_default())
}
async fn save_summary(&self, frame: FrameId, s: NewSummary) -> crate::Result<SummaryId> {
let mut i = self.inner.lock().unwrap();
i.next_summary += 1;
let id = SummaryId(i.next_summary);
i.summaries.entry(frame).or_default().push(StoredSummary {
id,
text: s.text,
covered_up_to: s.covered_up_to,
});
Ok(id)
}
async fn latest_summary(&self, frame: FrameId) -> crate::Result<Option<StoredSummary>> {
let i = self.inner.lock().unwrap();
Ok(i.summaries.get(&frame).and_then(|v| v.last()).cloned())
}
}
/// Load a frame's history with calls nested, excluding failed messages,
/// optionally only messages after `after`.
fn load_frame(i: &Inner, frame: FrameId, after: Option<MessageId>) -> Vec<StoredMessage> {
i.messages
.get(&frame)
.map(|msgs| {
msgs.iter()
.filter(|m| !m.failed)
.filter(|m| after.is_none_or(|a| m.id > a))
.cloned()
.collect()
})
.unwrap_or_default()
}
/// Apply a mutation to a call both in the by-message index and in the nested
/// copy inside its message.
fn update_call(i: &mut Inner, id: ToolCallId, f: impl Fn(&mut StoredCall)) {
let mut msg_id = None;
for calls in i.calls.values_mut() {
if let Some(c) = calls.iter_mut().find(|c| c.id == id) {
f(c);
msg_id = Some(c.message_id);
break;
}
}
if let Some(msg_id) = msg_id {
for msgs in i.messages.values_mut() {
if let Some(m) = msgs.iter_mut().find(|m| m.id == msg_id) {
if let Some(c) = m.calls.iter_mut().find(|c| c.id == id) {
f(c);
}
break;
}
}
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Test utilities: a scripted `FakeModel` + builders for kernel and recovery
//! scenarios. (Blueprint: will move behind a `test-util` feature if the crate
//! is ever published.)
use std::collections::VecDeque;
use std::sync::Mutex;
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::model::{
Model, ModelError, ModelRequest, ModelResponse, NamedModel, StreamDelta, ToolCall, Usage,
};
/// One scripted step: the response (or error) plus optional deltas to emit
/// before returning.
pub struct Step {
pub result: Result<ModelResponse, ModelError>,
pub deltas: Vec<StreamDelta>,
/// Never return (cancellation tests).
pub pending: bool,
}
impl Step {
pub fn message(content: impl Into<String>) -> Self {
Self { result: Ok(ModelResponse::message(content)), deltas: Vec::new(), pending: false }
}
pub fn message_with_usage(content: impl Into<String>, input: u32, output: u32) -> Self {
let mut resp = ModelResponse::message(content);
*resp.usage_mut() = Usage {
input_tokens: Some(input),
output_tokens: Some(output),
..Usage::default()
};
Self { result: Ok(resp), deltas: Vec::new(), pending: false }
}
pub fn tool_calls(content: impl Into<String>, calls: Vec<ToolCall>) -> Self {
Self { result: Ok(ModelResponse::tool_calls(content, calls)), deltas: Vec::new(), pending: false }
}
pub fn error(status: Option<u16>, message: impl Into<String>) -> Self {
Self { result: Err(ModelError::new(status, message)), deltas: Vec::new(), pending: false }
}
/// Never completes — the only way out is cancelling the turn.
pub fn pending() -> Self {
Self { result: Ok(ModelResponse::message("")), deltas: Vec::new(), pending: true }
}
/// Stream these deltas (in order) before returning the response.
pub fn with_deltas(mut self, deltas: Vec<StreamDelta>) -> Self {
self.deltas = deltas;
self
}
}
/// A scripted model: pops one [`Step`] per `complete` call, records every
/// request for assertions. Clone the `Arc` around it to inspect afterwards.
pub struct FakeModel {
script: Mutex<VecDeque<Step>>,
requests: Mutex<Vec<ModelRequest>>,
default_model: String,
}
impl FakeModel {
pub fn new(default_model: impl Into<String>, script: Vec<Step>) -> Self {
Self {
script: Mutex::new(script.into()),
requests: Mutex::new(Vec::new()),
default_model: default_model.into(),
}
}
/// All requests seen so far (one per attempt, fallback included).
pub fn requests(&self) -> Vec<ModelRequest> {
self.requests.lock().unwrap().clone()
}
/// Steps not yet consumed (assert a script was fully driven).
pub fn remaining(&self) -> usize {
self.script.lock().unwrap().len()
}
}
impl NamedModel for FakeModel {
fn default_model(&self) -> &str { &self.default_model }
}
#[async_trait]
impl Model for FakeModel {
async fn complete(
&self,
req: &ModelRequest,
deltas: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
self.requests.lock().unwrap().push(req.clone());
let step = self
.script
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| panic!("FakeModel: script exhausted (request for model {})", req.model));
if let Some(tx) = deltas {
for d in step.deltas {
let _ = tx.try_send(d);
}
}
if step.pending {
std::future::pending::<()>().await;
}
step.result
}
}
/// A `ModelHandle` over a shared `FakeModel` (tests keep the Arc to inspect
/// `requests()` afterwards).
pub fn handle(fake: &std::sync::Arc<FakeModel>, id: &str) -> crate::model::ModelHandle {
crate::model::ModelHandle {
id: id.to_string(),
model: fake.clone(),
info: crate::model::ModelInfo::default(),
}
}
/// Build a wire `ToolCall` compactly in tests.
pub fn call(id: &str, name: &str, args: serde_json::Value) -> ToolCall {
ToolCall { id: id.to_string(), name: name.to_string(), arguments: args }
}
+371
View File
@@ -0,0 +1,371 @@
//! The `Tool` trait, the type-erased [`ToolCtx`] (blueprint D3 — a type-map,
//! axum/tower style, not generics), and the cancellable execution machinery
//! (ported verbatim from Skald's core-api: it was already pure).
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::ids::{ConversationId, FrameId, ToolCallId};
// ── Extensions ───────────────────────────────────────────────────────────────
/// A type-map of host values threaded into every tool call (axum/tower
/// style). Hosts insert in ONE place (turn construction) and read with typed
/// helpers — never scattered string keys.
#[derive(Clone, Default)]
pub struct Extensions {
map: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
}
impl Extensions {
pub fn new() -> Self { Self::default() }
pub fn insert<T: Send + Sync + 'static>(&mut self, value: Arc<T>) -> &mut Self {
self.map.insert(TypeId::of::<T>(), value);
self
}
pub fn get<T: Send + Sync + 'static>(&self) -> Option<Arc<T>> {
self.map.get(&TypeId::of::<T>())?.clone().downcast::<T>().ok()
}
pub fn contains<T: Send + Sync + 'static>(&self) -> bool {
self.map.contains_key(&TypeId::of::<T>())
}
}
impl std::fmt::Debug for Extensions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Extensions({} entries)", self.map.len())
}
}
// ── ToolCtx ──────────────────────────────────────────────────────────────────
/// Per-invocation execution context threaded into a tool call.
#[derive(Clone)]
pub struct ToolCtx {
pub conversation: ConversationId,
pub frame: FrameId,
/// Agent of the current frame (self-call check for delegation).
pub agent: String,
/// The call being executed (parent_call of any child frame).
pub call_id: ToolCallId,
pub cancel: CancellationToken,
pub extensions: Extensions,
}
// ── ToolOutput / ToolFailure ─────────────────────────────────────────────────
/// A reference to one media file a tool produced. The assembler decides
/// whether to inline it — the kernel only transports it.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MediaRef {
/// Absolute host path, already containment-checked by the producing tool.
pub host_path: String,
/// Sniffed MIME (informational — pipelines re-sniff from bytes).
pub mime: String,
}
/// The successful output of a tool.
#[derive(Debug, Clone)]
pub enum ToolOutput {
Text(String),
Json(Value),
/// A text note plus media refs; the wire message carries only `text`.
Media { text: String, refs: Vec<MediaRef> },
}
impl ToolOutput {
/// Canonical string form persisted as the call result and replayed to the
/// model (both OpenAI and Anthropic encode tool results as text/JSON).
pub fn to_wire(&self) -> String {
match self {
Self::Text(s) => s.clone(),
Self::Json(v) => serde_json::to_string(v).unwrap_or_else(|_| "null".into()),
Self::Media { text, .. } => text.clone(),
}
}
pub fn kind(&self) -> &'static str {
match self {
Self::Text(_) | Self::Media { .. } => "string",
Self::Json(_) => "json",
}
}
pub fn media(&self) -> &[MediaRef] {
match self {
Self::Media { refs, .. } => refs,
_ => &[],
}
}
}
impl From<String> for ToolOutput {
fn from(s: String) -> Self { Self::Text(s) }
}
impl From<&str> for ToolOutput {
fn from(s: &str) -> Self { Self::Text(s.to_string()) }
}
/// How a tool call can fail.
#[derive(Debug, Clone)]
pub enum ToolFailure {
Failed(String),
/// The tool suspended waiting for a human and the channel closed: the turn
/// ends, the call STAYS `AwaitingHuman` for the resume. (The tool marks
/// the call `AwaitingHuman` via the store BEFORE returning this.)
Suspend,
}
impl std::fmt::Display for ToolFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Failed(e) => write!(f, "{e}"),
Self::Suspend => write!(f, "tool suspended awaiting human input"),
}
}
}
impl std::error::Error for ToolFailure {}
// ── RestartHint / Visibility ─────────────────────────────────────────────────
/// What recovery does with a call that was `Running` at crash (blueprint D7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RestartHint {
/// Re-gate and re-execute (default — today's behavior; idempotent tools).
#[default]
ReExecute,
/// Resolve as Failed "interrupted" (tools with non-idempotent external
/// side effects, e.g. shell commands).
MarkInterrupted,
}
/// Declared visibility — the HOST filters at `ToolSet` construction, the
/// kernel never filters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Visibility {
#[default]
Always,
InteractiveOnly,
RootOnly,
SubAgentsOnly,
}
// ── Tool ─────────────────────────────────────────────────────────────────────
/// A single LLM-callable tool.
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
/// OpenAI-shaped tool definition (`{"type":"function","function":{…}}`).
fn definition(&self) -> Value;
/// The simple execution path. The kernel wraps it in a [`SimpleExecution`]
/// by default (drop of the future = stop) — override [`start`](Self::start)
/// for remote/child teardown instead.
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<crate::tool::ToolOutput, ToolFailure>;
/// May this call run in parallel with other concurrency-safe calls of the
/// same round? (Generalized sub-agent batch, blueprint §7.) Default false
/// → the sequential path.
fn concurrency_safe(&self, _args: &Value) -> bool { false }
/// Recovery behavior when the call was `Running` at crash (D7).
fn restart_hint(&self) -> RestartHint { RestartHint::ReExecute }
/// Declared visibility (host-side filtering only).
fn visibility(&self) -> Visibility { Visibility::Always }
/// Start one execution, returning a live handle. The default wraps
/// [`call`](Self::call) in a [`SimpleExecution`]. Tools needing
/// remote/child teardown (kill a process group, POST an /interrupt)
/// override this with a bespoke [`ToolExecution::stop`].
fn start<'a>(&'a self, args: Value, ctx: &'a ToolCtx) -> Box<dyn ToolExecution + 'a> {
Box::new(SimpleExecution::new(Box::pin(self.call(args, ctx))))
}
}
// ── ToolSet ──────────────────────────────────────────────────────────────────
/// The per-turn tool registry, ALREADY filtered by the host (visibility,
/// approval, interactive). `defs` is re-read at EVERY round and every
/// fallback attempt: grants activated at round N are visible at round N+1,
/// and a cross-mode DTL fallback re-shapes for free.
pub trait ToolSet: Send + Sync {
fn defs(&self, model: &crate::model::ModelInfo) -> Vec<Value>;
fn find(&self, name: &str) -> Option<Arc<dyn Tool>>;
}
/// Wrapper so `Arc<dyn ToolSet>` can ride in [`Extensions`] (type-map keys
/// must be `Sized`). The kernel inserts one into every `ToolCtx`; shipped
/// tools that spawn child loops (delegate) inherit from it.
#[derive(Clone)]
pub struct SharedToolSet(pub Arc<dyn ToolSet>);
/// A trivial `ToolSet` from a list of tools (testing, simple hosts).
pub struct ToolRegistry {
tools: Vec<Arc<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self { Self { tools: Vec::new() } }
pub fn with(mut self, tool: impl Tool + 'static) -> Self {
self.tools.push(Arc::new(tool));
self
}
pub fn with_arc(mut self, tool: Arc<dyn Tool>) -> Self {
self.tools.push(tool);
self
}
pub fn into_toolset(self) -> Arc<dyn ToolSet> { Arc::new(self) }
}
impl Default for ToolRegistry {
fn default() -> Self { Self::new() }
}
impl ToolSet for ToolRegistry {
fn defs(&self, _model: &crate::model::ModelInfo) -> Vec<Value> {
self.tools.iter().map(|t| t.definition()).collect()
}
fn find(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.iter().find(|t| t.name() == name).cloned()
}
}
// ── ToolExecution ────────────────────────────────────────────────────────────
/// Lifecycle state of a single tool execution (in-memory, richer than the
/// persisted `CallState`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolExecutionState {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
/// Terminal outcome of [`ToolExecution::wait`].
#[derive(Debug, Clone)]
pub enum ExecutionOutcome {
Completed(ToolOutput),
Failed(String),
Cancelled,
/// The tool suspended awaiting a human (`ToolFailure::Suspend`): the turn
/// ends and the call STAYS `AwaitingHuman` — never resolve it here.
Suspended,
}
impl ExecutionOutcome {
pub fn into_call_outcome(self) -> crate::store::CallOutcome {
match self {
Self::Completed(out) => crate::store::CallOutcome::Completed(out),
Self::Failed(e) => crate::store::CallOutcome::Failed(e),
Self::Cancelled => crate::store::CallOutcome::Cancelled,
// Handled by the kernel before this conversion is reached.
Self::Suspended => crate::store::CallOutcome::Cancelled,
}
}
}
/// A single live execution of a [`Tool`]. Pure: it never touches a store or a
/// transport — the kernel mirrors transitions to persistence and events.
pub trait ToolExecution: Send + Sync {
fn state(&self) -> ToolExecutionState;
/// Drive the work to its terminal outcome. Called exactly once.
fn wait<'a>(&'a self) -> Pin<Box<dyn Future<Output = ExecutionOutcome> + Send + 'a>>;
/// Tool-specific cancellation. The default relies on the driver dropping
/// the `wait` future.
fn stop<'a>(&'a self) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async {})
}
}
/// The boxed work unit inside a [`SimpleExecution`].
pub type ToolWork<'a> =
Pin<Box<dyn Future<Output = Result<ToolOutput, ToolFailure>> + Send + 'a>>;
/// Default [`ToolExecution`] for any tool that is a single async unit of work:
/// `wait` races the work against a stop-token, so `stop()` (or dropping
/// `wait`) aborts the in-flight I/O.
pub struct SimpleExecution<'a> {
state: Mutex<ToolExecutionState>,
stop: CancellationToken,
work: tokio::sync::Mutex<Option<ToolWork<'a>>>,
}
impl<'a> SimpleExecution<'a> {
pub fn new(work: ToolWork<'a>) -> Self {
Self {
state: Mutex::new(ToolExecutionState::Running),
stop: CancellationToken::new(),
work: tokio::sync::Mutex::new(Some(work)),
}
}
}
impl ToolExecution for SimpleExecution<'_> {
fn state(&self) -> ToolExecutionState { *self.state.lock().unwrap() }
fn wait<'b>(&'b self) -> Pin<Box<dyn Future<Output = ExecutionOutcome> + Send + 'b>> {
Box::pin(async move {
let work = self.work.lock().await.take();
let Some(work) = work else { return ExecutionOutcome::Cancelled };
let outcome = tokio::select! {
biased;
_ = self.stop.cancelled() => ExecutionOutcome::Cancelled,
r = work => match r {
Ok(out) => ExecutionOutcome::Completed(out),
Err(ToolFailure::Failed(e)) => ExecutionOutcome::Failed(e),
Err(ToolFailure::Suspend) => ExecutionOutcome::Suspended,
},
};
*self.state.lock().unwrap() = match outcome {
ExecutionOutcome::Completed(_) => ToolExecutionState::Completed,
ExecutionOutcome::Failed(_) => ToolExecutionState::Failed,
ExecutionOutcome::Cancelled | ExecutionOutcome::Suspended => ToolExecutionState::Cancelled,
};
outcome
})
}
fn stop<'b>(&'b self) -> Pin<Box<dyn Future<Output = ()> + Send + 'b>> {
Box::pin(async move { self.stop.cancel() })
}
}
/// Run a [`ToolExecution`] to completion honouring a cancellation token: on
/// cancel, `exec.stop()` is called once (tool-specific teardown), then `wait`
/// resolves.
pub async fn drive_execution(exec: &dyn ToolExecution, cancel: &CancellationToken) -> ExecutionOutcome {
let work = exec.wait();
tokio::pin!(work);
let mut stopped = false;
loop {
tokio::select! {
biased;
outcome = &mut work => return outcome,
_ = cancel.cancelled(), if !stopped => {
exec.stop().await;
stopped = true;
}
}
}
}
+196
View File
@@ -0,0 +1,196 @@
//! Assembler tests (blueprint §13): well-formed projection, DTL rendering
//! modes, summary, window, crash survivors.
use std::sync::Arc;
use agent_loop::activation::{Activation, ActivationSource, ToolRendering};
use agent_loop::context::{AssembleInput, ContextAssembler, LinearAssembler, SystemContext};
use agent_loop::ids::{ConversationId, FrameId};
use agent_loop::model::ModelInfo;
use agent_loop::prelude::async_trait;
use agent_loop::store::{
CallOutcome, FrameSpec, HistoryStore, NewCall, NewMessage,
};
use agent_loop::store_memory::InMemoryStore;
use agent_loop::tool::ToolOutput;
use serde_json::{Value, json};
fn tool_def(name: &str) -> Value {
json!({"type":"function","function":{"name":name,"parameters":{"type":"object"}}})
}
struct StubActivations {
acts: Vec<Activation>,
}
#[async_trait]
impl ActivationSource for StubActivations {
async fn activations(&self, _frame: FrameId) -> agent_loop::Result<Vec<Activation>> {
Ok(self.acts.clone())
}
}
fn model_info(mode: ToolRendering) -> ModelInfo {
ModelInfo { tool_rendering: mode, ..ModelInfo::default() }
}
async fn input(store: &Arc<InMemoryStore>, conv: &ConversationId, mode: ToolRendering) -> (FrameId, AssembleInput) {
let frame = store.open_frame(conv, None, FrameSpec::root("assistant")).await.unwrap();
let input = AssembleInput {
frame,
system: SystemContext::base("BASE"),
model: model_info(mode),
round: 0,
};
(frame, input)
}
/// History: user → assistant with an activate_tools call (resolved) → final.
/// Returns the anchor (the assistant message id).
async fn seed_activation_history(store: &Arc<InMemoryStore>, frame: FrameId) -> agent_loop::ids::MessageId {
store.append(frame, NewMessage::user("use gmail")).await.unwrap();
let anchor = store.append(frame, NewMessage::assistant("activating", None)).await.unwrap();
let call = store
.append_call(anchor, NewCall::new("activate_tools", json!({"groups":["gmail"]})).with_provider_id("c1"))
.await
.unwrap();
store
.resolve_call(call, &CallOutcome::Completed(ToolOutput::Text("gmail activated".into())))
.await
.unwrap();
anchor
}
#[tokio::test]
async fn inline_mode_injects_nothing() {
let store = Arc::new(InMemoryStore::new());
let conv = ConversationId::new("a1");
let (frame, input) = input(&store, &conv, ToolRendering::Inline).await;
let anchor = seed_activation_history(&store, frame).await;
let assembler = LinearAssembler::new().with_activation(Arc::new(StubActivations {
acts: vec![Activation { anchor, defs: vec![tool_def("mcp__gmail__send")] }],
}));
let store_dyn: Arc<dyn HistoryStore> = store;
let msgs = assembler.build(&store_dyn, &input).await.unwrap();
assert!(!msgs.iter().any(|m| m.get("tools").is_some()), "Inline must not inject system+tools");
assert!(!msgs.iter().any(|m| m.get("_tool_references").is_some()));
}
#[tokio::test]
async fn system_tool_block_appends_after_tool_results() {
let store = Arc::new(InMemoryStore::new());
let conv = ConversationId::new("a2");
let (frame, input) = input(&store, &conv, ToolRendering::SystemToolBlock).await;
let anchor = seed_activation_history(&store, frame).await;
let assembler = LinearAssembler::new().with_activation(Arc::new(StubActivations {
acts: vec![Activation { anchor, defs: vec![tool_def("mcp__gmail__send")] }],
}));
let store_dyn: Arc<dyn HistoryStore> = store;
let msgs = assembler.build(&store_dyn, &input).await.unwrap();
// [system BASE, user, assistant(tool_calls), tool(result), system+tools]
let block_idx = msgs
.iter()
.position(|m| m["role"].as_str() == Some("system") && m.get("tools").is_some())
.expect("no system+tools block injected");
assert_eq!(msgs[block_idx]["tools"][0]["function"]["name"], json!("mcp__gmail__send"));
assert!(msgs[block_idx].get("content").is_none(), "Kimi block has no content field");
// It comes right after the tool result of the anchor group.
assert_eq!(msgs[block_idx - 1]["role"], json!("tool"));
}
#[tokio::test]
async fn deferred_tool_reference_marks_first_tool_result() {
let store = Arc::new(InMemoryStore::new());
let conv = ConversationId::new("a3");
let (frame, input) = input(&store, &conv, ToolRendering::DeferredToolReference).await;
let anchor = seed_activation_history(&store, frame).await;
let assembler = LinearAssembler::new().with_activation(Arc::new(StubActivations {
acts: vec![Activation { anchor, defs: vec![tool_def("mcp__gmail__send")] }],
}));
let store_dyn: Arc<dyn HistoryStore> = store;
let msgs = assembler.build(&store_dyn, &input).await.unwrap();
let tool_msg = msgs
.iter()
.find(|m| m["role"].as_str() == Some("tool"))
.expect("no tool result projected");
assert_eq!(tool_msg["_tool_references"], json!(["mcp__gmail__send"]));
}
#[tokio::test]
async fn crash_survivors_get_synthetic_interrupted_results() {
let store = Arc::new(InMemoryStore::new());
let conv = ConversationId::new("a4");
let (frame, input) = input(&store, &conv, ToolRendering::Inline).await;
store.append(frame, NewMessage::user("do it")).await.unwrap();
let msg = store.append(frame, NewMessage::assistant("running", None)).await.unwrap();
// Never resolved: still Running, as after a crash.
store.append_call(msg, NewCall::new("execute_cmd", json!({})).with_provider_id("c1")).await.unwrap();
let assembler = LinearAssembler::new();
let store_dyn: Arc<dyn HistoryStore> = store;
let msgs = assembler.build(&store_dyn, &input).await.unwrap();
let tool_msg = msgs.iter().find(|m| m["role"].as_str() == Some("tool")).unwrap();
assert!(
tool_msg["content"].as_str().unwrap().contains("interrupted"),
"a Running survivor must project a synthetic interrupted result: {tool_msg}"
);
}
#[tokio::test]
async fn summary_replaces_covered_history() {
let store = Arc::new(InMemoryStore::new());
let conv = ConversationId::new("a5");
let (frame, input) = input(&store, &conv, ToolRendering::Inline).await;
let m1 = store.append(frame, NewMessage::user("old question")).await.unwrap();
store.append(frame, NewMessage::assistant("old answer", None)).await.unwrap();
let m3 = store.append(frame, NewMessage::user("new question")).await.unwrap();
store
.save_summary(frame, agent_loop::store::NewSummary {
text: "User asked about old stuff.".into(),
covered_up_to: m1,
})
.await
.unwrap();
let assembler = LinearAssembler::new();
let store_dyn: Arc<dyn HistoryStore> = store;
let msgs = assembler.build(&store_dyn, &input).await.unwrap();
let joined = msgs.iter().filter_map(|m| m["content"].as_str()).collect::<Vec<_>>().join("\n");
assert!(joined.contains("CONTEXT SUMMARY"), "summary block missing: {joined}");
assert!(joined.contains("old answer"), "post-summary messages must survive");
assert!(!joined.contains("old question"), "covered messages must be gone");
let _ = m3;
}
#[tokio::test]
async fn window_cuts_at_user_boundary_never_mid_tool_group() {
let store = Arc::new(InMemoryStore::new());
let conv = ConversationId::new("a6");
let (frame, input) = input(&store, &conv, ToolRendering::Inline).await;
store.append(frame, NewMessage::user("first")).await.unwrap();
let asst = store.append(frame, NewMessage::assistant("calling", None)).await.unwrap();
let call = store.append_call(asst, NewCall::new("t", json!({})).with_provider_id("c1")).await.unwrap();
store.resolve_call(call, &CallOutcome::Completed(ToolOutput::Text("r".into()))).await.unwrap();
store.append(frame, NewMessage::user("second")).await.unwrap();
// Window of 2 would cut right before the assistant+tool group; the
// boundary rule must move the cut to "second".
let assembler = LinearAssembler::new().with_max_messages(2);
let store_dyn: Arc<dyn HistoryStore> = store;
let msgs = assembler.build(&store_dyn, &input).await.unwrap();
let roles: Vec<&str> = msgs.iter().filter_map(|m| m["role"].as_str()).collect();
assert_eq!(roles, ["system", "user"], "cut must land on the user boundary: {roles:?}");
}
+823
View File
@@ -0,0 +1,823 @@
//! Kernel test suite (blueprint §13) — against `FakeModel` + `InMemoryStore`,
//! no DB, no Docker, no network.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_loop::gate::DenyList;
use agent_loop::ids::ConversationId;
use agent_loop::kernel::TurnOutcome;
use agent_loop::manager::{LoopManager, TurnMeta, TurnParams};
use agent_loop::model::{ModelHint, StaticModels, StreamDelta};
use agent_loop::prelude::async_trait;
use agent_loop::store::{CallState, FrameSpec, HistoryStore, NewMessage};
use agent_loop::store_memory::InMemoryStore;
use agent_loop::testing::{self, FakeModel, Step};
use agent_loop::tool::{Tool, ToolCtx, ToolFailure, ToolOutput, ToolRegistry};
use agent_loop::context::StaticSystemContext;
use agent_loop::delegate::{AgentCatalog, AgentKind, AgentProfile, DelegateTool, ToolSelection};
use agent_loop::events::LoopEvent;
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
// ── test tools ──
struct WeatherTool;
#[async_trait]
impl Tool for WeatherTool {
fn name(&self) -> &str { "get_weather" }
fn definition(&self) -> Value {
json!({"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}})
}
async fn call(&self, args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
Ok(ToolOutput::Text(format!("Sunny in {}", args["city"].as_str().unwrap_or("?"))))
}
}
struct SlowTool;
#[async_trait]
impl Tool for SlowTool {
fn name(&self) -> &str { "slow" }
fn definition(&self) -> Value {
json!({"type":"function","function":{"name":"slow","parameters":{"type":"object"}}})
}
async fn call(&self, _args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
tokio::time::sleep(Duration::from_secs(60)).await;
Ok(ToolOutput::Text("done".into()))
}
}
/// Concurrency-safe tool rendezvousing on a barrier: proves the fan-out runs
/// concurrently (a sequential path would deadlock → timeout).
struct BarrierTool {
name: &'static str,
barrier: Arc<tokio::sync::Barrier>,
log: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl Tool for BarrierTool {
fn name(&self) -> &str { self.name }
fn definition(&self) -> Value {
json!({"type":"function","function":{"name":self.name,"parameters":{"type":"object"}}})
}
fn concurrency_safe(&self, _args: &Value) -> bool { true }
async fn call(&self, _args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
self.log.lock().unwrap().push(format!("start:{}", self.name));
self.barrier.wait().await;
self.log.lock().unwrap().push(format!("end:{}", self.name));
Ok(ToolOutput::Text(format!("{} done", self.name)))
}
}
/// Records start/end order in a shared log (sequentiality proofs).
struct OrderedTool {
name: &'static str,
safe: bool,
log: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl Tool for OrderedTool {
fn name(&self) -> &str { self.name }
fn definition(&self) -> Value {
json!({"type":"function","function":{"name":self.name,"parameters":{"type":"object"}}})
}
fn concurrency_safe(&self, _args: &Value) -> bool { self.safe }
async fn call(&self, _args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
self.log.lock().unwrap().push(format!("start:{}", self.name));
tokio::task::yield_now().await;
self.log.lock().unwrap().push(format!("end:{}", self.name));
Ok(ToolOutput::Text("ok".into()))
}
}
/// Marks itself AwaitingHuman then suspends (ask_user semantics).
struct SuspendTool {
store: Arc<InMemoryStore>,
}
#[async_trait]
impl Tool for SuspendTool {
fn name(&self) -> &str { "suspend_me" }
fn definition(&self) -> Value {
json!({"type":"function","function":{"name":"suspend_me","parameters":{"type":"object"}}})
}
async fn call(&self, _args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
self.store
.set_call_state(ctx.call_id, CallState::AwaitingHuman)
.await
.map_err(|e| ToolFailure::Failed(e.to_string()))?;
Err(ToolFailure::Suspend)
}
}
// ── harness ──
struct Harness {
manager: LoopManager,
store: Arc<InMemoryStore>,
}
fn harness_with(model: testing::FakeModel) -> Harness {
let store = Arc::new(InMemoryStore::new());
let manager = LoopManager::builder()
.models(Arc::new(agent_loop::model::SingleModel::new(model)))
.store(store.clone())
.build()
.unwrap();
Harness { manager, store }
}
async fn params(
manager: &LoopManager,
conv: &ConversationId,
tools: Arc<dyn agent_loop::tool::ToolSet>,
) -> TurnParams {
let frame = manager.open_root(conv, FrameSpec::root("assistant")).await.unwrap();
TurnParams {
frame,
agent: "assistant".into(),
system: Arc::new(StaticSystemContext::new("You are a test agent.")),
tools,
model_hint: ModelHint::default(),
selector: None,
live_input: None,
extensions: Default::default(),
meta: TurnMeta::default(),
assembler: None,
}
}
// ── tests ──
#[tokio::test]
async fn multi_round_text_tool_text_final() {
let model = FakeModel::new("m", vec![
Step::tool_calls("let me check", vec![testing::call("c1", "get_weather", json!({"city":"Rome"}))]),
Step::message("It is sunny in Rome."),
]);
let h = harness_with(model);
let conv = ConversationId::new("t1");
let tools = ToolRegistry::new().with(WeatherTool).into_toolset();
let p = params(&h.manager, &conv, tools).await;
let handle = h.manager.start_turn(conv, NewMessage::user("weather?"), p).await.unwrap();
let outcome = handle.join().await.unwrap();
let TurnOutcome::Final { content, .. } = outcome else { panic!("expected Final, got {outcome:?}") };
assert_eq!(content, "It is sunny in Rome.");
// The store recorded everything: user, assistant+tool_call, tool result,
// final assistant.
let frame = h.manager.store().active_frames(&ConversationId::new("t1")).await.unwrap()[0].id;
let history = h.store.load(frame).await.unwrap();
assert_eq!(history.len(), 3);
assert_eq!(history[1].calls.len(), 1);
assert_eq!(history[1].calls[0].state, CallState::Done);
assert_eq!(history[1].calls[0].result.as_deref(), Some("Sunny in Rome"));
}
#[tokio::test]
async fn exhausted_after_max_rounds() {
let model = FakeModel::new("m", vec![
Step::tool_calls("", vec![testing::call("c1", "get_weather", json!({}))]),
Step::tool_calls("", vec![testing::call("c2", "get_weather", json!({}))]),
]);
let store = Arc::new(InMemoryStore::new());
let manager = LoopManager::builder()
.models(Arc::new(agent_loop::model::SingleModel::new(model)))
.store(store.clone())
.max_rounds(2)
.build()
.unwrap();
let conv = ConversationId::new("t2");
let p = params(&manager, &conv, ToolRegistry::new().with(WeatherTool).into_toolset()).await;
let handle = manager.start_turn(conv, NewMessage::user("loop forever"), p).await.unwrap();
let outcome = handle.join().await.unwrap();
assert!(matches!(outcome, TurnOutcome::Exhausted), "got {outcome:?}");
}
#[tokio::test]
async fn fallback_retriable_moves_to_second_model() {
let m1 = Arc::new(FakeModel::new("m1", vec![Step::error(Some(500), "boom")]));
let m2 = Arc::new(FakeModel::new("m2", vec![Step::message("recovered")]));
let store = Arc::new(InMemoryStore::new());
let mut rx;
let manager = LoopManager::builder()
.models(Arc::new(StaticModels::new(vec![
testing::handle(&m1, "m1"),
testing::handle(&m2, "m2"),
])))
.store(store.clone())
.build()
.unwrap();
rx = manager.events();
let conv = ConversationId::new("t3");
let p = params(&manager, &conv, ToolRegistry::new().into_toolset()).await;
let handle = manager.start_turn(conv, NewMessage::user("hi"), p).await.unwrap();
let outcome = handle.join().await.unwrap();
assert!(matches!(outcome, TurnOutcome::Final { .. }), "got {outcome:?}");
assert_eq!(m1.requests().len(), 1);
assert_eq!(m2.requests().len(), 1);
let mut saw_fallback = false;
while let Ok(ev) = rx.try_recv() {
if let LoopEvent::ModelFallback { from, to, .. } = ev.inner {
assert_eq!(from, "m1");
assert_eq!(to, "m2");
saw_fallback = true;
}
}
assert!(saw_fallback, "no ModelFallback event");
}
#[tokio::test]
async fn non_retriable_error_stops_without_fallback() {
let m1 = Arc::new(FakeModel::new("m1", vec![Step::error(Some(404), "no such model")]));
let m2 = Arc::new(FakeModel::new("m2", vec![Step::message("never reached")]));
let store = Arc::new(InMemoryStore::new());
let manager = LoopManager::builder()
.models(Arc::new(StaticModels::new(vec![
testing::handle(&m1, "m1"),
testing::handle(&m2, "m2"),
])))
.store(store.clone())
.build()
.unwrap();
let conv = ConversationId::new("t4");
let p = params(&manager, &conv, ToolRegistry::new().into_toolset()).await;
let handle = manager.start_turn(conv, NewMessage::user("hi"), p).await.unwrap();
assert!(handle.join().await.is_err(), "404 must fail the turn");
assert_eq!(m2.requests().len(), 0, "404 must not fall back");
}
#[tokio::test]
async fn cancel_during_llm_call() {
let model = FakeModel::new("m", vec![Step::pending()]);
let h = harness_with(model);
let conv = ConversationId::new("t5");
let p = params(&h.manager, &conv, ToolRegistry::new().into_toolset()).await;
let handle = h.manager.start_turn(conv.clone(), NewMessage::user("hi"), p).await.unwrap();
let cancel: CancellationToken = handle.cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
cancel.cancel();
});
let outcome = tokio::time::timeout(Duration::from_secs(5), handle.join())
.await
.expect("join hung")
.unwrap();
assert!(matches!(outcome, TurnOutcome::Cancelled), "got {outcome:?}");
assert!(!h.manager.is_running(&conv));
}
#[tokio::test]
async fn cancel_during_slow_tool_marks_call_cancelled() {
let model = FakeModel::new("m", vec![
Step::tool_calls("", vec![testing::call("c1", "slow", json!({}))]),
]);
let h = harness_with(model);
let conv = ConversationId::new("t6");
let p = params(&h.manager, &conv, ToolRegistry::new().with(SlowTool).into_toolset()).await;
let frame = p.frame;
let handle = h.manager.start_turn(conv, NewMessage::user("run slow"), p).await.unwrap();
let cancel = handle.cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(150)).await;
cancel.cancel();
});
let outcome = tokio::time::timeout(Duration::from_secs(5), handle.join())
.await
.expect("join hung")
.unwrap();
assert!(matches!(outcome, TurnOutcome::Cancelled), "got {outcome:?}");
let calls = h.store.calls_in_state(frame, &[CallState::Cancelled]).await.unwrap();
assert_eq!(calls.len(), 1, "the slow call must be recorded Cancelled, got {calls:?}");
}
#[tokio::test]
async fn fan_out_runs_concurrently_and_records_in_order() {
let barrier = Arc::new(tokio::sync::Barrier::new(3));
let log = Arc::new(Mutex::new(Vec::new()));
let model = FakeModel::new("m", vec![
Step::tool_calls("", vec![
testing::call("c1", "p1", json!({})),
testing::call("c2", "p2", json!({})),
testing::call("c3", "p3", json!({})),
]),
Step::message("all done"),
]);
let h = harness_with(model);
let conv = ConversationId::new("t7");
let p = params(&h.manager, &conv, ToolRegistry::new()
.with_arc(Arc::new(BarrierTool { name: "p1", barrier: barrier.clone(), log: log.clone() }))
.with_arc(Arc::new(BarrierTool { name: "p2", barrier: barrier.clone(), log: log.clone() }))
.with_arc(Arc::new(BarrierTool { name: "p3", barrier: barrier.clone(), log: log.clone() }))
.into_toolset()).await;
let frame = p.frame;
let handle = h.manager.start_turn(conv, NewMessage::user("go"), p).await.unwrap();
let outcome = tokio::time::timeout(Duration::from_secs(5), handle.join())
.await
.expect("fan-out deadlocked (ran sequentially?)")
.unwrap();
assert!(matches!(outcome, TurnOutcome::Final { .. }), "got {outcome:?}");
// All three started before any ended (true concurrency).
{
let log = log.lock().unwrap();
let first_end = log.iter().position(|e| e.starts_with("end:")).unwrap();
assert_eq!(log[..first_end].iter().filter(|e| e.starts_with("start:")).count(), 3,
"not all tools started before the first end: {log:?}");
}
// Ids are increasing in call order and all resolved Done.
let calls = h.store.calls_in_state(frame, &[CallState::Done]).await.unwrap();
assert_eq!(calls.len(), 3);
let mut ids: Vec<i64> = calls.iter().map(|c| c.id.get()).collect();
let sorted = ids.clone();
ids.sort_unstable();
// calls_in_state returns in message order; ids must already be ascending.
assert_eq!(ids, sorted);
}
#[tokio::test]
async fn mixed_batch_stays_sequential() {
let log = Arc::new(Mutex::new(Vec::new()));
let model = FakeModel::new("m", vec![
Step::tool_calls("", vec![
testing::call("c1", "safe", json!({})),
testing::call("c2", "unsafe", json!({})),
]),
Step::message("done"),
]);
let h = harness_with(model);
let conv = ConversationId::new("t8");
let p = params(&h.manager, &conv, ToolRegistry::new()
.with_arc(Arc::new(OrderedTool { name: "safe", safe: true, log: log.clone() }))
.with_arc(Arc::new(OrderedTool { name: "unsafe", safe: false, log: log.clone() }))
.into_toolset()).await;
let handle = h.manager.start_turn(conv, NewMessage::user("go"), p).await.unwrap();
let outcome = handle.join().await.unwrap();
assert!(matches!(outcome, TurnOutcome::Final { .. }), "got {outcome:?}");
assert_eq!(
*log.lock().unwrap(),
vec!["start:safe", "end:safe", "start:unsafe", "end:unsafe"],
"mixed batch must run sequentially in order"
);
}
#[tokio::test]
async fn suspend_leaves_call_awaiting_human_and_ends_turn() {
let store = Arc::new(InMemoryStore::new());
let model = FakeModel::new("m", vec![
Step::tool_calls("", vec![testing::call("c1", "suspend_me", json!({}))]),
]);
let manager = LoopManager::builder()
.models(Arc::new(agent_loop::model::SingleModel::new(model)))
.store(store.clone())
.build()
.unwrap();
let conv = ConversationId::new("t9");
let suspend = SuspendTool { store: store.clone() };
let p = params(&manager, &conv, ToolRegistry::new().with(suspend).into_toolset()).await;
let frame = p.frame;
let handle = manager.start_turn(conv, NewMessage::user("ask something"), p).await.unwrap();
let outcome = handle.join().await.unwrap();
assert!(matches!(outcome, TurnOutcome::Cancelled), "got {outcome:?}");
let pending = store.calls_in_state(frame, &[CallState::AwaitingHuman]).await.unwrap();
assert_eq!(pending.len(), 1, "the call must STAY AwaitingHuman");
assert!(pending[0].result.is_none(), "no result recorded for a suspended call");
}
#[tokio::test]
async fn gate_reject_marks_rejected_and_loop_continues() {
let model = FakeModel::new("m", vec![
Step::tool_calls("", vec![testing::call("c1", "blocked_tool", json!({}))]),
Step::message("after rejection"),
]);
let store = Arc::new(InMemoryStore::new());
let manager = LoopManager::builder()
.models(Arc::new(agent_loop::model::SingleModel::new(model)))
.store(store.clone())
.gate(DenyList::new(["blocked_*"]))
.build()
.unwrap();
let conv = ConversationId::new("t10");
let p = params(&manager, &conv, ToolRegistry::new().with(WeatherTool).into_toolset()).await;
let frame = p.frame;
let handle = manager.start_turn(conv, NewMessage::user("try it"), p).await.unwrap();
let outcome = handle.join().await.unwrap();
let TurnOutcome::Final { content, .. } = outcome else { panic!("expected Final, got {outcome:?}") };
assert_eq!(content, "after rejection");
let rejected = store.calls_in_state(frame, &[CallState::Rejected]).await.unwrap();
assert_eq!(rejected.len(), 1);
}
#[tokio::test]
async fn streaming_deltas_precede_outcome_events() {
let model = FakeModel::new("m", vec![
Step::message("hello").with_deltas(vec![
StreamDelta::Text("he".into()),
StreamDelta::Text("llo".into()),
]),
]);
let h = harness_with(model);
let mut rx = h.manager.events();
let conv = ConversationId::new("t11");
let p = params(&h.manager, &conv, ToolRegistry::new().into_toolset()).await;
let handle = h.manager.start_turn(conv, NewMessage::user("hi"), p).await.unwrap();
let _ = handle.join().await.unwrap();
let mut events = Vec::new();
while let Ok(ev) = rx.try_recv() {
events.push(ev.inner);
}
let done_idx = events.iter().position(|e| matches!(e, LoopEvent::Done { .. })).unwrap();
let delta_count = events[..done_idx]
.iter()
.filter(|e| matches!(e, LoopEvent::TokenDelta { .. }))
.count();
assert_eq!(delta_count, 2, "both deltas must precede Done: {events:?}");
}
#[tokio::test]
async fn orphan_user_message_marked_failed_on_new_turn() {
let model = FakeModel::new("m", vec![Step::message("reply")]);
let h = harness_with(model);
let conv = ConversationId::new("t12");
let p = params(&h.manager, &conv, ToolRegistry::new().into_toolset()).await;
let frame = p.frame;
// A previous user message with no assistant reply (crash mid-turn).
h.store.append(frame, NewMessage::user("orphan")).await.unwrap();
let handle = h.manager.start_turn(conv, NewMessage::user("fresh"), p).await.unwrap();
let _ = handle.join().await.unwrap();
let history = h.store.load(frame).await.unwrap();
assert!(
!history.iter().any(|m| m.content == "orphan"),
"the orphan must be excluded from the projection: {history:?}"
);
}
#[tokio::test]
async fn second_loop_on_same_conversation_rejected() {
let model = FakeModel::new("m", vec![Step::pending()]);
let h = harness_with(model);
let conv = ConversationId::new("t13");
let p1 = params(&h.manager, &conv, ToolRegistry::new().into_toolset()).await;
let handle = h.manager.start_turn(conv.clone(), NewMessage::user("first"), p1).await.unwrap();
let p2 = params(&h.manager, &conv, ToolRegistry::new().into_toolset()).await;
let second = h.manager.start_turn(conv.clone(), NewMessage::user("second"), p2).await;
assert!(
matches!(second, Err(agent_loop::manager::StartError::AlreadyRunning)),
"double-driving must be rejected"
);
handle.cancel.cancel();
let _ = handle.join().await;
}
// ── delegate (sub-agents as a tool) ──
struct TestCatalog {
context: Arc<StaticSystemContext>,
/// Pins the child to its own model, so a test can script parent and child
/// independently (a shared script would race on who pops which step).
model: Option<ModelHint>,
}
#[async_trait]
impl AgentCatalog for TestCatalog {
async fn get(
&self,
id: &str,
_child_frame: agent_loop::ids::FrameId,
_ctx: &agent_loop::tool::ToolCtx,
) -> agent_loop::Result<AgentProfile> {
Ok(AgentProfile {
id: id.into(),
kind: AgentKind::Task,
context: self.context.clone(),
tools: ToolSelection::inherit(),
toolset: None,
model: self.model.clone(),
selector: None,
assembler: None,
})
}
async fn list(&self, _kind: AgentKind) -> Vec<agent_loop::delegate::AgentSummary> {
Vec::new()
}
}
#[tokio::test]
async fn sync_delegate_runs_child_loop_and_returns_result() {
let script = vec![
Step::tool_calls("delegating", vec![testing::call("c1", "delegate", json!({"agent_id":"researcher","prompt":"find X"}))]),
Step::message("research says: X=42"),
Step::message("final answer with X=42"),
];
let store = Arc::new(InMemoryStore::new());
let manager = Arc::new(
LoopManager::builder()
.models(Arc::new(agent_loop::model::SingleModel::new(FakeModel::new("m", script))))
.store(store.clone())
.build()
.unwrap(),
);
let catalog: Arc<dyn AgentCatalog> = Arc::new(TestCatalog {
context: Arc::new(StaticSystemContext::new("You are a researcher.")),
model: None,
});
let delegate: Arc<dyn Tool> = Arc::new(DelegateTool::new(manager.clone(), catalog, manager.store(), 5));
let conv = ConversationId::new("d1");
let frame = manager.open_root(&conv, FrameSpec::root("assistant")).await.unwrap();
let p = TurnParams {
frame,
agent: "assistant".into(),
system: Arc::new(StaticSystemContext::new("root")),
tools: ToolRegistry::new().with_arc(delegate).into_toolset(),
model_hint: ModelHint::default(),
selector: None,
live_input: None,
extensions: Default::default(),
meta: TurnMeta::default(),
assembler: None,
};
let handle = manager.start_turn(conv.clone(), NewMessage::user("what is X?"), p).await.unwrap();
let outcome = tokio::time::timeout(Duration::from_secs(5), handle.join())
.await
.expect("delegate turn hung")
.unwrap();
let TurnOutcome::Final { content, .. } = outcome else { panic!("expected Final, got {outcome:?}") };
assert_eq!(content, "final answer with X=42");
// The parent's delegate call resolved Done with the CHILD's answer as result.
let done = store.calls_in_state(frame, &[CallState::Done]).await.unwrap();
assert_eq!(done.len(), 1);
assert_eq!(done[0].result.as_deref(), Some("research says: X=42"));
// The child frame exists, closed, with its Agent prompt + assistant answer.
let frames = store.active_frames(&conv).await.unwrap();
assert!(frames.iter().all(|f| f.spec.depth == 0), "child frame must be closed");
let history_all = store.load(frame).await.unwrap();
assert!(history_all.iter().any(|m| m.role == agent_loop::store::Role::Assistant && m.content == "final answer with X=42"));
}
#[tokio::test]
async fn delegate_batch_fans_out_concurrently() {
let script = vec![
Step::tool_calls("", vec![
testing::call("c1", "delegate", json!({"agent_id":"a1","prompt":"job one"})),
testing::call("c2", "delegate", json!({"agent_id":"a2","prompt":"job two"})),
]),
Step::message("result one"),
Step::message("result two"),
Step::message("both done"),
];
let store = Arc::new(InMemoryStore::new());
let manager = Arc::new(
LoopManager::builder()
.models(Arc::new(agent_loop::model::SingleModel::new(FakeModel::new("m", script))))
.store(store.clone())
.max_parallel_calls(2)
.build()
.unwrap(),
);
let catalog: Arc<dyn AgentCatalog> = Arc::new(TestCatalog {
context: Arc::new(StaticSystemContext::new("worker")),
model: None,
});
let delegate: Arc<dyn Tool> = Arc::new(DelegateTool::new(manager.clone(), catalog, manager.store(), 5));
let conv = ConversationId::new("d2");
let frame = manager.open_root(&conv, FrameSpec::root("assistant")).await.unwrap();
let p = TurnParams {
frame,
agent: "assistant".into(),
system: Arc::new(StaticSystemContext::new("root")),
tools: ToolRegistry::new().with_arc(delegate).into_toolset(),
model_hint: ModelHint::default(),
selector: None,
live_input: None,
extensions: Default::default(),
meta: TurnMeta::default(),
assembler: None,
};
let handle = manager.start_turn(conv, NewMessage::user("do both"), p).await.unwrap();
let outcome = tokio::time::timeout(Duration::from_secs(5), handle.join())
.await
.expect("delegate batch hung")
.unwrap();
assert!(matches!(outcome, TurnOutcome::Final { .. }), "got {outcome:?}");
// Both delegate calls resolved Done, each carrying one of the child results.
let done = store.calls_in_state(frame, &[CallState::Done]).await.unwrap();
assert_eq!(done.len(), 2);
let results: HashSet<String> = done.iter().filter_map(|c| c.result.clone()).collect();
assert_eq!(
results,
["result one".to_string(), "result two".to_string()].into_iter().collect()
);
}
// ── async delegation ──
/// Polls until `f` holds, so a background delivery does not need a sleep.
async fn eventually<F, Fut>(label: &str, f: F)
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = bool>,
{
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if f().await {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("timed out waiting for: {label}");
}
#[tokio::test]
async fn async_delegate_returns_a_receipt_then_delivers_the_result() {
// Parent and child get their own scripted model: the parent does NOT wait
// for the child, so one shared script would race on who pops which step.
let root = Arc::new(FakeModel::new("root", vec![
Step::tool_calls("", vec![testing::call("c1", "delegate", json!({
"agent_id": "worker", "prompt": "long job", "mode": "async", "title": "nightly",
}))]),
Step::message("started it"),
]));
let child = Arc::new(FakeModel::new("child", vec![Step::message("the long answer")]));
let store = Arc::new(InMemoryStore::new());
let manager = Arc::new(
LoopManager::builder()
.models(Arc::new(StaticModels::new(vec![
testing::handle(&root, "root"),
testing::handle(&child, "child"),
])))
.store(store.clone())
.build()
.unwrap(),
);
let catalog: Arc<dyn AgentCatalog> = Arc::new(TestCatalog {
context: Arc::new(StaticSystemContext::new("worker")),
model: Some(ModelHint::name("child")),
});
let sink: Arc<dyn AsyncResultSink> = Arc::new(StoreSink::new(manager.store()));
let exec: Arc<dyn AsyncExecutor> = Arc::new(InProcessExecutor::new(
manager.clone(),
catalog.clone(),
manager.store(),
sink,
ToolRegistry::new().into_toolset(),
));
let delegate: Arc<dyn Tool> = Arc::new(
DelegateTool::new(manager.clone(), catalog, manager.store(), 5).with_async(exec),
);
let conv = ConversationId::new("d3");
let frame = manager.open_root(&conv, FrameSpec::root("assistant")).await.unwrap();
let p = TurnParams {
frame,
agent: "assistant".into(),
system: Arc::new(StaticSystemContext::new("root")),
tools: ToolRegistry::new().with_arc(delegate).into_toolset(),
model_hint: ModelHint::default(),
selector: None,
live_input: None,
extensions: Default::default(),
meta: TurnMeta::default(),
assembler: None,
};
let handle = manager.start_turn(conv.clone(), NewMessage::user("run it"), p).await.unwrap();
let outcome = tokio::time::timeout(Duration::from_secs(5), handle.join())
.await
.expect("async delegate must not block the parent turn")
.unwrap();
let TurnOutcome::Final { content, .. } = outcome else { panic!("got {outcome:?}") };
assert_eq!(content, "started it");
// The delegating call resolved with a receipt, not with the child's answer.
let done = store.calls_in_state(frame, &[CallState::Done]).await.unwrap();
let receipt: Value =
serde_json::from_str(done[0].result.as_deref().unwrap()).expect("receipt is JSON");
assert_eq!(receipt["status"], "started");
assert_eq!(receipt["task_id"], 1);
// …and the answer lands later, as its own completed call.
let store_c = store.clone();
eventually("the delivered result", || {
let store = store_c.clone();
async move {
store
.load(frame)
.await
.unwrap()
.iter()
.any(|m| m.calls.iter().any(|c| c.name == agent_loop::delegate::DELIVERY_CALL))
}
})
.await;
let history = store.load(frame).await.unwrap();
let delivery = history
.iter()
.find(|m| m.calls.iter().any(|c| c.name == agent_loop::delegate::DELIVERY_CALL))
.unwrap();
assert!(delivery.synthetic, "the delivery is not a turn the user drove");
let call = &delivery.calls[0];
assert_eq!(call.state, CallState::Done);
let payload: Value = serde_json::from_str(call.result.as_deref().unwrap()).unwrap();
assert_eq!(payload["task_id"], 1);
assert_eq!(payload["title"], "nightly");
assert_eq!(payload["result"], "the long answer");
}
#[tokio::test]
async fn async_delegate_without_an_executor_is_refused() {
let script = vec![
Step::tool_calls("", vec![testing::call("c1", "delegate", json!({
"agent_id": "worker", "prompt": "job", "mode": "async",
}))]),
Step::message("could not start it"),
];
let store = Arc::new(InMemoryStore::new());
let manager = Arc::new(
LoopManager::builder()
.models(Arc::new(agent_loop::model::SingleModel::new(FakeModel::new("m", script))))
.store(store.clone())
.build()
.unwrap(),
);
let catalog: Arc<dyn AgentCatalog> = Arc::new(TestCatalog {
context: Arc::new(StaticSystemContext::new("worker")),
model: None,
});
// No `with_async`: the mode must fail, never silently run sync — a turn
// that asked not to wait would otherwise block on the child.
let delegate: Arc<dyn Tool> =
Arc::new(DelegateTool::new(manager.clone(), catalog, manager.store(), 5));
let conv = ConversationId::new("d4");
let frame = manager.open_root(&conv, FrameSpec::root("assistant")).await.unwrap();
let p = TurnParams {
frame,
agent: "assistant".into(),
system: Arc::new(StaticSystemContext::new("root")),
tools: ToolRegistry::new().with_arc(delegate).into_toolset(),
model_hint: ModelHint::default(),
selector: None,
live_input: None,
extensions: Default::default(),
meta: TurnMeta::default(),
assembler: None,
};
let handle = manager.start_turn(conv.clone(), NewMessage::user("run it"), p).await.unwrap();
tokio::time::timeout(Duration::from_secs(5), handle.join())
.await
.expect("turn hung")
.unwrap();
let failed = store.calls_in_state(frame, &[CallState::Failed]).await.unwrap();
assert_eq!(failed.len(), 1);
assert!(
failed[0].result.as_deref().unwrap().contains("async mode is not available"),
"{:?}",
failed[0].result
);
// Nothing was spawned: no child frame was ever opened.
assert!(store.active_frames(&conv).await.unwrap().iter().all(|f| f.spec.depth == 0));
}
use agent_loop::delegate::{AsyncExecutor, AsyncResultSink, InProcessExecutor, StoreSink};
use std::collections::HashSet;
+507
View File
@@ -0,0 +1,507 @@
//! Golden tests of the projection (blueprint §13): the exact wire shape of
//! every layer, for every provider knob. These assert full messages, not just
//! properties — a change in what a model receives must show up here.
use std::sync::Arc;
use agent_loop::activation::{Activation, ActivationSource, ToolRendering};
use agent_loop::context::{AssembleInput, ContextAssembler, LinearAssembler, SystemContext};
use agent_loop::ids::{ConversationId, FrameId, MessageId};
use agent_loop::model::ModelInfo;
use agent_loop::prelude::async_trait;
use agent_loop::projection::{
MediaBlob, MediaSource, Projection, ReasoningEcho, ResultLimit, ToolResultDigest,
};
use agent_loop::store::{
CallOutcome, FrameSpec, HistoryStore, NewCall, NewMessage, NewSummary, StoredCall,
StoredMessage,
};
use agent_loop::store_memory::InMemoryStore;
use agent_loop::tool::ToolOutput;
use serde_json::{Value, json};
// ── fixtures ─────────────────────────────────────────────────────────────────
async fn store_and_frame(name: &str) -> (Arc<dyn HistoryStore>, FrameId) {
let store = Arc::new(InMemoryStore::new());
let conv = ConversationId::new(name);
let frame = store.open_frame(&conv, None, FrameSpec::root("assistant")).await.unwrap();
(store, frame)
}
fn input(frame: FrameId, system: SystemContext, model: ModelInfo) -> AssembleInput {
AssembleInput { frame, system, model, round: 0 }
}
fn tool_def(name: &str) -> Value {
json!({"type":"function","function":{"name":name,"parameters":{"type":"object"}}})
}
/// The Skald-flavoured configuration: every knob off the default, so the test
/// exercises the parameterization rather than the defaults.
fn strict() -> Projection {
Projection {
summary_suffix: Some("[End of summary]".into()),
interrupted_text: "Error: tool call was interrupted.".into(),
rejected_default: "User rejected this tool call.".into(),
cancelled_default: "Tool call was cancelled by the user.".into(),
reasoning_placeholder: Some("(no reasoning recorded for this step)".into()),
reasoning_echo: ReasoningEcho::Both,
activation_anchor_tool: Some("activate_tools".into()),
..Projection::default()
}
}
struct Stub(Vec<Activation>);
#[async_trait]
impl ActivationSource for Stub {
async fn activations(&self, _frame: FrameId) -> agent_loop::Result<Vec<Activation>> {
Ok(self.0.clone())
}
}
// ── system layers ────────────────────────────────────────────────────────────
#[tokio::test]
async fn prompt_cache_turns_the_static_prefix_into_a_cache_breakpoint() {
let (store, frame) = store_and_frame("p1").await;
let plain = LinearAssembler::new()
.build(&store, &input(frame, SystemContext::base("BASE"), ModelInfo::default()))
.await
.unwrap();
assert_eq!(plain[0], json!({ "role": "system", "content": "BASE" }));
let cached = LinearAssembler::new()
.build(&store, &input(frame, SystemContext::base("BASE"), ModelInfo {
prompt_cache: true,
..ModelInfo::default()
}))
.await
.unwrap();
assert_eq!(
cached[0],
json!({
"role": "system",
"content": [{ "type": "text", "text": "BASE",
"cache_control": { "type": "ephemeral" } }],
})
);
}
#[tokio::test]
async fn static_and_dynamic_layers_land_on_their_sides_of_the_history() {
let (store, frame) = store_and_frame("p2").await;
store.append(frame, NewMessage::user("hi")).await.unwrap();
let system = SystemContext::base("BASE")
.with_static("FORMAT RULES")
.with_static("<scratchpad/>")
.with_dynamic("MEMORY")
.with_dynamic("NOW")
.with_reminder("REMEMBER");
let msgs = LinearAssembler::new()
.build(&store, &input(frame, system, ModelInfo::default()))
.await
.unwrap();
assert_eq!(msgs, vec![
json!({ "role": "system", "content": "BASE" }),
json!({ "role": "system", "content": "FORMAT RULES" }),
json!({ "role": "system", "content": "<scratchpad/>" }),
json!({ "role": "user", "content": "hi" }),
// The dynamic layers are ONE trailing block, joined by the separator.
json!({ "role": "system", "content": "MEMORY\n\n---\nNOW" }),
json!({ "role": "system", "content": "REMEMBER" }),
]);
}
#[tokio::test]
async fn summary_replaces_covered_history_and_carries_its_suffix() {
let (store, frame) = store_and_frame("p3").await;
let m1 = store.append(frame, NewMessage::user("old question")).await.unwrap();
store.append(frame, NewMessage::assistant("old answer", None)).await.unwrap();
store.append(frame, NewMessage::user("new question")).await.unwrap();
store
.save_summary(frame, NewSummary { text: "They discussed old stuff.".into(), covered_up_to: m1 })
.await
.unwrap();
let msgs = LinearAssembler::new()
.with_projection(strict())
.build(&store, &input(frame, SystemContext::base("BASE"), ModelInfo::default()))
.await
.unwrap();
assert_eq!(
msgs[1],
json!({
"role": "system",
"content": "[CONTEXT SUMMARY — earlier messages were compacted into this summary]\n\n\
They discussed old stuff.\n\n[End of summary]",
})
);
let joined = msgs.iter().filter_map(|m| m["content"].as_str()).collect::<Vec<_>>().join("|");
assert!(joined.contains("old answer"), "history after the cut must survive");
assert!(!joined.contains("old question"), "covered history must be gone");
}
#[tokio::test]
async fn the_window_never_opens_on_half_an_exchange() {
let (store, frame) = store_and_frame("p4").await;
store.append(frame, NewMessage::user("first")).await.unwrap();
let asst = store.append(frame, NewMessage::assistant("calling", None)).await.unwrap();
let call = store.append_call(asst, NewCall::new("t", json!({})).with_provider_id("c1")).await.unwrap();
store.resolve_call(call, &CallOutcome::Completed(ToolOutput::Text("r".into()))).await.unwrap();
store.append(frame, NewMessage::user("second")).await.unwrap();
// A window of 2 would start on the assistant+tool group: it is dropped.
let msgs = LinearAssembler::new()
.with_max_messages(2)
.build(&store, &input(frame, SystemContext::base("BASE"), ModelInfo::default()))
.await
.unwrap();
let roles: Vec<&str> = msgs.iter().filter_map(|m| m["role"].as_str()).collect();
assert_eq!(roles, ["system", "user"]);
}
// ── tool calls and results ───────────────────────────────────────────────────
/// Seeds one assistant turn with a call in each terminal state, plus a survivor.
async fn seed_states(store: &Arc<dyn HistoryStore>, frame: FrameId) -> MessageId {
store.append(frame, NewMessage::user("go")).await.unwrap();
let msg = store.append(frame, NewMessage::assistant("working", None)).await.unwrap();
let done = store.append_call(msg, NewCall::new("a", json!({})).with_provider_id("c1")).await.unwrap();
store.resolve_call(done, &CallOutcome::Completed(ToolOutput::Text("ok".into()))).await.unwrap();
let failed = store.append_call(msg, NewCall::new("b", json!({})).with_provider_id("c2")).await.unwrap();
store.resolve_call(failed, &CallOutcome::Failed("boom".into())).await.unwrap();
let rejected = store.append_call(msg, NewCall::new("c", json!({})).with_provider_id("c3")).await.unwrap();
store.resolve_call(rejected, &CallOutcome::Rejected { reason: String::new() }).await.unwrap();
let cancelled = store.append_call(msg, NewCall::new("d", json!({})).with_provider_id("c4")).await.unwrap();
store.resolve_call(cancelled, &CallOutcome::Cancelled).await.unwrap();
// Never resolved: a crash survivor.
store.append_call(msg, NewCall::new("e", json!({})).with_provider_id("c5")).await.unwrap();
msg
}
#[tokio::test]
async fn every_call_state_gets_a_result_the_model_can_read() {
let (store, frame) = store_and_frame("p5").await;
seed_states(&store, frame).await;
let msgs = LinearAssembler::new()
.with_projection(strict())
.build(&store, &input(frame, SystemContext::base("BASE"), ModelInfo::default()))
.await
.unwrap();
let results: Vec<(&str, &str)> = msgs
.iter()
.filter(|m| m["role"] == "tool")
.map(|m| (m["tool_call_id"].as_str().unwrap(), m["content"].as_str().unwrap()))
.collect();
assert_eq!(results, vec![
("c1", "ok"),
("c2", "Error: boom"),
// The rejection recorded an empty reason: the configured note stands in.
("c3", "User rejected this tool call."),
// A recorded note wins over the configured default.
("c4", "Cancelled by user."),
("c5", "Error: tool call was interrupted."),
]);
// The assistant turn itself: calls in order, and a stand-in reasoning
// because none was recorded.
let asst = msgs.iter().find(|m| m["role"] == "assistant").unwrap();
assert_eq!(asst["tool_calls"][0], json!({
"id": "c1", "type": "function",
"function": { "name": "a", "arguments": "{}" },
}));
assert_eq!(asst["reasoning_content"], "(no reasoning recorded for this step)");
assert_eq!(asst["reasoning"], "(no reasoning recorded for this step)");
}
#[tokio::test]
async fn reasoning_echo_is_per_provider_and_never_empty() {
let (store, frame) = store_and_frame("p6").await;
store.append(frame, NewMessage::user("q")).await.unwrap();
store.append(frame, NewMessage::assistant("a", Some("because".into()))).await.unwrap();
store.append(frame, NewMessage::user("q2")).await.unwrap();
// An empty stored reasoning must not produce an empty field.
store.append(frame, NewMessage::assistant("a2", Some(String::new()))).await.unwrap();
let one = LinearAssembler::new()
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo::default()))
.await
.unwrap();
let first = one.iter().find(|m| m["content"] == "a").unwrap();
assert_eq!(first["reasoning_content"], "because");
assert!(first.get("reasoning").is_none(), "ContentOnly must not echo `reasoning`");
let second = one.iter().find(|m| m["content"] == "a2").unwrap();
assert!(second.get("reasoning_content").is_none());
let both = LinearAssembler::new()
.with_projection(strict())
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo::default()))
.await
.unwrap();
let first = both.iter().find(|m| m["content"] == "a").unwrap();
assert_eq!(first["reasoning"], "because");
// No placeholder for a plain assistant turn — only tool-calling ones need it.
let second = both.iter().find(|m| m["content"] == "a2").unwrap();
assert!(second.get("reasoning_content").is_none());
}
struct Digest;
#[async_trait]
impl ToolResultDigest for Digest {
async fn condense(&self, name: &str, _args: &Value, result: &str) -> Option<String> {
Some(format!("[{name}: {} chars]", result.len()))
}
}
#[tokio::test]
async fn over_long_results_are_condensed_only_for_previous_turns() {
let (store, frame) = store_and_frame("p7").await;
// Turn 1 (previous), then turn 2 (current), both with a long result.
for (user, id) in [("first", "c1"), ("second", "c2")] {
store.append(frame, NewMessage::user(user)).await.unwrap();
let msg = store.append(frame, NewMessage::assistant("run", None)).await.unwrap();
let call = store
.append_call(msg, NewCall::new("read_file", json!({})).with_provider_id(id))
.await
.unwrap();
store
.resolve_call(call, &CallOutcome::Completed(ToolOutput::Text("x".repeat(100))))
.await
.unwrap();
}
let cfg = Projection {
max_tool_result: Some(ResultLimit { max_chars: 10, previous_turns_only: true }),
..Projection::default()
};
let msgs = LinearAssembler::new()
.with_projection(cfg.clone())
.with_digest(Arc::new(Digest))
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo::default()))
.await
.unwrap();
let results: Vec<&str> = msgs
.iter()
.filter(|m| m["role"] == "tool")
.map(|m| m["content"].as_str().unwrap())
.collect();
assert_eq!(results[0], "[read_file: 100 chars]", "a previous turn is condensed");
assert_eq!(results[1].len(), 100, "the current turn keeps its full output");
// Without a digest the crate truncates on a char boundary.
let msgs = LinearAssembler::new()
.with_projection(cfg)
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo::default()))
.await
.unwrap();
let first = msgs.iter().find(|m| m["role"] == "tool").unwrap();
assert_eq!(first["content"], "xxxxxxxxxx… [truncated]");
}
// ── dynamic tool loading ─────────────────────────────────────────────────────
/// An assistant turn with two calls, the activation being the SECOND one.
async fn seed_two_calls(store: &Arc<dyn HistoryStore>, frame: FrameId) -> MessageId {
store.append(frame, NewMessage::user("use gmail")).await.unwrap();
let anchor = store.append(frame, NewMessage::assistant("activating", None)).await.unwrap();
let other = store
.append_call(anchor, NewCall::new("read_file", json!({})).with_provider_id("c1"))
.await
.unwrap();
store.resolve_call(other, &CallOutcome::Completed(ToolOutput::Text("file".into()))).await.unwrap();
let act = store
.append_call(anchor, NewCall::new("activate_tools", json!({"groups":["gmail"]})).with_provider_id("c2"))
.await
.unwrap();
store.resolve_call(act, &CallOutcome::Completed(ToolOutput::Text("activated".into()))).await.unwrap();
anchor
}
#[tokio::test]
async fn deferred_reference_marks_the_activation_result_not_the_first_one() {
let (store, frame) = store_and_frame("p8").await;
let anchor = seed_two_calls(&store, frame).await;
let msgs = LinearAssembler::new()
.with_projection(strict())
.with_activation(Arc::new(Stub(vec![Activation {
anchor,
defs: vec![tool_def("mcp__gmail__send")],
}])))
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo {
tool_rendering: ToolRendering::DeferredToolReference,
..ModelInfo::default()
}))
.await
.unwrap();
let tools: Vec<&Value> = msgs.iter().filter(|m| m["role"] == "tool").collect();
assert!(tools[0].get("_tool_references").is_none(), "the read_file result is not the anchor");
assert_eq!(tools[1]["_tool_references"], json!(["mcp__gmail__send"]));
}
#[tokio::test]
async fn system_tool_block_is_appended_after_the_result_group() {
let (store, frame) = store_and_frame("p9").await;
let anchor = seed_two_calls(&store, frame).await;
let msgs = LinearAssembler::new()
.with_projection(strict())
.with_activation(Arc::new(Stub(vec![Activation {
anchor,
defs: vec![tool_def("mcp__gmail__send")],
}])))
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo {
tool_rendering: ToolRendering::SystemToolBlock,
..ModelInfo::default()
}))
.await
.unwrap();
let idx = msgs
.iter()
.position(|m| m["role"] == "system" && m.get("tools").is_some())
.expect("no system+tools block");
assert_eq!(msgs[idx]["tools"][0]["function"]["name"], "mcp__gmail__send");
assert!(msgs[idx].get("content").is_none(), "the block carries tools, not content");
assert_eq!(msgs[idx - 1]["role"], "tool", "it comes right after the group");
assert!(!msgs.iter().any(|m| m.get("_tool_references").is_some()));
}
#[tokio::test]
async fn inline_mode_injects_nothing_at_all() {
let (store, frame) = store_and_frame("p10").await;
let anchor = seed_two_calls(&store, frame).await;
let msgs = LinearAssembler::new()
.with_projection(strict())
.with_activation(Arc::new(Stub(vec![Activation {
anchor,
defs: vec![tool_def("mcp__gmail__send")],
}])))
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo::default()))
.await
.unwrap();
assert!(!msgs.iter().any(|m| m.get("tools").is_some()));
assert!(!msgs.iter().any(|m| m.get("_tool_references").is_some()));
}
// ── media ────────────────────────────────────────────────────────────────────
struct Png(&'static str);
#[async_trait]
impl MediaBlob for Png {
fn name(&self) -> &str { self.0 }
async fn size(&self) -> Option<u64> { Some(72) }
async fn head(&self) -> Option<Vec<u8>> { Some(b"\x89PNG\r\n\x1a\n........".to_vec()) }
async fn read_all(&self) -> Option<Vec<u8>> {
let mut v = b"\x89PNG\r\n\x1a\n".to_vec();
v.extend_from_slice(&[0xAA; 64]);
Some(v)
}
}
/// Every user message has one image; every tool call produces one.
struct Media;
#[async_trait]
impl MediaSource for Media {
async fn message_media(&self, _msg: &StoredMessage) -> Vec<Arc<dyn MediaBlob>> {
vec![Arc::new(Png("shot.png"))]
}
async fn call_media(&self, _calls: &[StoredCall]) -> Vec<Arc<dyn MediaBlob>> {
vec![Arc::new(Png("tool.png"))]
}
fn skipped_text(&self, _msg: &StoredMessage, skipped: &[usize]) -> Option<String> {
(!skipped.is_empty()).then(|| format!("\n[files: {}]", skipped.len()))
}
}
#[tokio::test]
async fn media_is_inlined_for_the_current_turn_and_textual_before_it() {
let (store, frame) = store_and_frame("p11").await;
store.append(frame, NewMessage::user("old picture")).await.unwrap();
store.append(frame, NewMessage::assistant("seen", None)).await.unwrap();
store.append(frame, NewMessage::user("new picture")).await.unwrap();
let msgs = LinearAssembler::new()
.with_media(Arc::new(Media))
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo {
capabilities: vec!["vision".into()],
..ModelInfo::default()
}))
.await
.unwrap();
// The previous turn keeps the textual note, no parts.
assert_eq!(msgs[1], json!({ "role": "user", "content": "old picture\n[files: 1]" }));
// The current turn inlines the bytes.
let current = msgs.last().unwrap();
assert_eq!(current["content"][0], json!({ "type": "text", "text": "new picture" }));
assert!(
current["content"][1]["image_url"]["url"]
.as_str()
.unwrap()
.starts_with("data:image/png;base64,")
);
}
#[tokio::test]
async fn a_model_without_vision_never_receives_bytes() {
let (store, frame) = store_and_frame("p12").await;
store.append(frame, NewMessage::user("picture")).await.unwrap();
let msgs = LinearAssembler::new()
.with_media(Arc::new(Media))
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo::default()))
.await
.unwrap();
assert_eq!(msgs[1], json!({ "role": "user", "content": "picture\n[files: 1]" }));
}
#[tokio::test]
async fn tool_produced_media_rides_a_synthetic_user_message_after_the_group() {
let (store, frame) = store_and_frame("p13").await;
store.append(frame, NewMessage::user("read the image")).await.unwrap();
let msg = store.append(frame, NewMessage::assistant("reading", None)).await.unwrap();
let call = store
.append_call(msg, NewCall::new("read_file", json!({"path":"a.png"})).with_provider_id("c1"))
.await
.unwrap();
store.resolve_call(call, &CallOutcome::Completed(ToolOutput::Text("image".into()))).await.unwrap();
let msgs = LinearAssembler::new()
.with_media(Arc::new(Media))
.build(&store, &input(frame, SystemContext::base("B"), ModelInfo {
capabilities: vec!["vision".into()],
..ModelInfo::default()
}))
.await
.unwrap();
let last = msgs.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"][0]["type"], "image_url");
assert_eq!(msgs[msgs.len() - 2]["role"], "tool", "it follows the result group");
}
+442
View File
@@ -0,0 +1,442 @@
//! Recovery suite (blueprint §8/§13): the post-crash store is built **by hand**
//! on `InMemoryStore` — a call left `Running`, a child frame nobody closed, two
//! siblings of an interrupted batch — and recovery is asked to make it
//! well-formed again and continue.
//!
//! No DB, no network: the states a real crash produces are exactly the states a
//! test can write, because every transition is a store write.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_loop::context::StaticSystemContext;
use agent_loop::delegate::{AgentCatalog, AgentKind, AgentProfile, AgentSummary, ToolSelection};
use agent_loop::ids::{ConversationId, FrameId, ToolCallId};
use agent_loop::manager::{LoopManager, TurnMeta, TurnParams};
use agent_loop::model::{ModelHint, StaticModels};
use agent_loop::prelude::async_trait;
use agent_loop::recovery::{HumanDecision, PendingPolicy, RecoveryPolicy, RunningPolicy};
use agent_loop::store::{
CallState, FrameSpec, HistoryStore, NewCall, NewMessage, StoredCall,
};
use agent_loop::store_memory::InMemoryStore;
use agent_loop::testing::{self, FakeModel, Step};
use agent_loop::tool::{
RestartHint, Tool, ToolCtx, ToolFailure, ToolOutput, ToolRegistry, ToolSet,
};
use serde_json::{Value, json};
// ── tools ────────────────────────────────────────────────────────────────────
/// Idempotent: safe to re-run after a crash. Counts its executions.
struct Counter {
runs: Arc<Mutex<usize>>,
}
#[async_trait]
impl Tool for Counter {
fn name(&self) -> &str { "counter" }
fn definition(&self) -> Value {
json!({"type":"function","function":{"name":"counter","parameters":{"type":"object"}}})
}
async fn call(&self, _args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
let mut runs = self.runs.lock().unwrap();
*runs += 1;
Ok(ToolOutput::Text(format!("run {runs}")))
}
}
/// Non-idempotent (a shell command already had its effect): must NOT be re-run.
struct SideEffect {
runs: Arc<Mutex<usize>>,
}
#[async_trait]
impl Tool for SideEffect {
fn name(&self) -> &str { "shell" }
fn definition(&self) -> Value {
json!({"type":"function","function":{"name":"shell","parameters":{"type":"object"}}})
}
fn restart_hint(&self) -> RestartHint { RestartHint::MarkInterrupted }
async fn call(&self, _args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
*self.runs.lock().unwrap() += 1;
Ok(ToolOutput::Text("ran".into()))
}
}
/// Stands in for the delegate: recovery never calls it (a spawned frame is the
/// cascade's business), so running it at all is a bug.
struct NeverCalled;
#[async_trait]
impl Tool for NeverCalled {
fn name(&self) -> &str { "delegate" }
fn definition(&self) -> Value {
json!({"type":"function","function":{"name":"delegate","parameters":{"type":"object"}}})
}
async fn call(&self, _args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
panic!("recovery re-ran a sub-agent dispatch instead of cascading its frame");
}
}
// ── catalog ──────────────────────────────────────────────────────────────────
/// Every child agent runs on the `child` model with its own prompt — so a test
/// can prove a resumed sub-agent came back as ITSELF (B3), not as the root.
struct Catalog;
#[async_trait]
impl AgentCatalog for Catalog {
async fn get(
&self,
id: &str,
_child_frame: FrameId,
_ctx: &ToolCtx,
) -> agent_loop::Result<AgentProfile> {
Ok(AgentProfile {
id: id.into(),
kind: AgentKind::Task,
context: Arc::new(StaticSystemContext::new(format!("You are {id}."))),
tools: ToolSelection::inherit(),
toolset: None,
model: Some(ModelHint::name("child")),
selector: None,
assembler: None,
})
}
async fn list(&self, _kind: AgentKind) -> Vec<AgentSummary> { Vec::new() }
}
// ── harness ──────────────────────────────────────────────────────────────────
struct H {
manager: Arc<LoopManager>,
store: Arc<InMemoryStore>,
tools: Arc<dyn ToolSet>,
conv: ConversationId,
root: FrameId,
counter: Arc<Mutex<usize>>,
shell: Arc<Mutex<usize>>,
/// The child's script — a test asserting "the model was NOT called" leaves
/// it empty, and `FakeModel` panics if anything pops from it.
child: Arc<FakeModel>,
}
impl H {
async fn new(root_script: Vec<Step>, child_script: Vec<Step>) -> Self {
let store = Arc::new(InMemoryStore::new());
let root_model = Arc::new(FakeModel::new("root", root_script));
let child = Arc::new(FakeModel::new("child", child_script));
let manager = Arc::new(
LoopManager::builder()
.models(Arc::new(StaticModels::new(vec![
testing::handle(&root_model, "root"),
testing::handle(&child, "child"),
])))
.store(store.clone())
.build()
.unwrap(),
);
let counter = Arc::new(Mutex::new(0));
let shell = Arc::new(Mutex::new(0));
let tools: Arc<dyn ToolSet> = ToolRegistry::new()
.with(Counter { runs: counter.clone() })
.with(SideEffect { runs: shell.clone() })
.with(NeverCalled)
.into_toolset();
let conv = ConversationId::new("rec");
let root = store
.open_frame(&conv, None, FrameSpec::root("assistant"))
.await
.unwrap();
Self { manager, store, tools, conv, root, counter, shell, child }
}
fn params(&self) -> TurnParams {
TurnParams {
frame: self.root,
agent: "assistant".into(),
system: Arc::new(StaticSystemContext::new("You are the assistant.")),
tools: self.tools.clone(),
model_hint: ModelHint::default(),
selector: None,
live_input: None,
extensions: Default::default(),
meta: TurnMeta::default(),
assembler: None,
}
}
/// An assistant message with one call left in flight — what a crash leaves.
async fn interrupted_call(&self, frame: FrameId, name: &str) -> ToolCallId {
self.store.append(frame, NewMessage::user("do it")).await.unwrap();
let msg = self
.store
.append(frame, NewMessage::assistant("calling", None))
.await
.unwrap();
self.store.append_call(msg, NewCall::new(name, json!({}))).await.unwrap()
}
/// A child frame spawned by `call`, with its prompt already appended.
async fn child_frame(&self, agent: &str, call: ToolCallId) -> FrameId {
let frame = self
.store
.open_frame(&self.conv, Some(self.root), FrameSpec {
agent: agent.into(),
prompt: Some("go find out".into()),
depth: 1,
parent_call: Some(call),
meta: Value::Null,
})
.await
.unwrap();
self.store.append(frame, NewMessage::agent("go find out")).await.unwrap();
frame
}
async fn call(&self, id: ToolCallId) -> StoredCall {
self.store.get_call(id).await.unwrap().unwrap()
}
async fn recover_with(&self, policy: RecoveryPolicy) -> agent_loop::recovery::RecoveryReport {
let recovery = self.manager.recovery(Arc::new(Catalog), policy);
tokio::time::timeout(Duration::from_secs(5), recovery.run(&self.conv, &self.params()))
.await
.expect("recovery hung")
.unwrap()
}
async fn recover(&self) -> agent_loop::recovery::RecoveryReport {
self.recover_with(RecoveryPolicy::default()).await
}
}
// ── interrupted calls ────────────────────────────────────────────────────────
#[tokio::test]
async fn an_interrupted_idempotent_call_is_re_executed_then_the_turn_continues() {
let h = H::new(vec![Step::message("all done")], vec![]).await;
let call = h.interrupted_call(h.root, "counter").await;
let report = h.recover().await;
assert_eq!(*h.counter.lock().unwrap(), 1, "the call must run exactly once");
let call = h.call(call).await;
assert_eq!(call.state, CallState::Done);
assert_eq!(call.result.as_deref(), Some("run 1"));
assert_eq!(report.calls_reexecuted, 1);
assert_eq!(report.frames_resumed, 1, "the frame then ran a normal round");
}
#[tokio::test]
async fn an_interrupted_call_with_side_effects_is_failed_not_re_run() {
// D7: `shell` declares MarkInterrupted, so re-running it could repeat an
// effect that already happened.
let h = H::new(vec![Step::message("I stopped mid-command")], vec![]).await;
let call = h.interrupted_call(h.root, "shell").await;
let report = h.recover().await;
assert_eq!(*h.shell.lock().unwrap(), 0, "a non-idempotent tool must NOT be re-run");
let call = h.call(call).await;
assert_eq!(call.state, CallState::Failed);
assert!(call.result.as_deref().unwrap().contains("interrupted"), "{:?}", call.result);
assert_eq!(report.calls_failed, 1);
assert_eq!(report.calls_reexecuted, 0);
}
#[tokio::test]
async fn the_policy_can_refuse_to_re_run_anything() {
let h = H::new(vec![Step::message("continuing")], vec![]).await;
let call = h.interrupted_call(h.root, "counter").await;
h.recover_with(RecoveryPolicy {
on_running: RunningPolicy::MarkInterrupted,
..RecoveryPolicy::default()
})
.await;
assert_eq!(*h.counter.lock().unwrap(), 0, "the policy overrides the tool's hint");
assert_eq!(h.call(call).await.state, CallState::Failed);
}
// ── awaiting human ───────────────────────────────────────────────────────────
#[tokio::test]
async fn a_call_awaiting_a_human_is_asked_again() {
let h = H::new(vec![Step::message("approved and done")], vec![]).await;
let call = h.interrupted_call(h.root, "counter").await;
h.store.set_call_state(call, CallState::AwaitingHuman).await.unwrap();
let report = h.recover().await;
// ReAsk re-runs it through the gate — here an allowing one, so it executes.
assert_eq!(*h.counter.lock().unwrap(), 1);
assert_eq!(h.call(call).await.state, CallState::Done);
assert_eq!(report.calls_reexecuted, 1);
assert!(!report.left_pending);
}
#[tokio::test]
async fn leave_pending_stops_and_touches_nothing() {
// No model step scripted: running the loop would panic, which is the point —
// a frame with an unanswered call must not be driven.
let h = H::new(vec![], vec![]).await;
let call = h.interrupted_call(h.root, "counter").await;
h.store.set_call_state(call, CallState::AwaitingHuman).await.unwrap();
let report = h.recover_with(RecoveryPolicy {
on_awaiting_human: PendingPolicy::LeavePending,
..RecoveryPolicy::default()
})
.await;
assert!(report.left_pending);
assert_eq!(report.frames_resumed, 0);
assert_eq!(*h.counter.lock().unwrap(), 0);
assert_eq!(h.call(call).await.state, CallState::AwaitingHuman, "still the human's to answer");
}
// ── the cascade ──────────────────────────────────────────────────────────────
#[tokio::test]
async fn an_interrupted_sub_agent_finishes_as_itself_then_the_parent_continues() {
let h = H::new(
vec![Step::message("the root's final answer")],
vec![Step::message("the child's answer")],
)
.await;
let call = h.interrupted_call(h.root, "delegate").await;
let child = h.child_frame("researcher", call).await;
let report = h.recover().await;
// The child ran under ITS agent's prompt and model (B3), not the root's.
let seen = h.child.requests();
assert_eq!(seen.len(), 1, "the child model ran exactly once");
assert!(
serde_json::to_string(&seen[0].messages).unwrap().contains("You are researcher."),
"the resumed frame must run its own agent's context: {:?}",
seen[0].messages
);
// Its answer became the parent call's result, and the child frame is closed.
let call = h.call(call).await;
assert_eq!(call.state, CallState::Done);
assert_eq!(call.result.as_deref(), Some("the child's answer"));
assert!(!h.store.get_frame(child).await.unwrap().unwrap().active);
assert_eq!(report.frames_resumed, 2, "child then root");
}
#[tokio::test]
async fn a_child_that_finished_but_never_propagated_is_not_re_run() {
// The wedge: the turn died in the instant between the child's last message
// and its result reaching the parent. Re-running the model would ask it to
// answer a question it already answered — the empty child script asserts
// that never happens.
let h = H::new(vec![Step::message("root wraps up")], vec![]).await;
let call = h.interrupted_call(h.root, "delegate").await;
let child = h.child_frame("researcher", call).await;
h.store
.append(child, NewMessage::assistant("already done", None))
.await
.unwrap();
let report = h.recover().await;
let call = h.call(call).await;
assert_eq!(call.state, CallState::Done);
assert_eq!(call.result.as_deref(), Some("already done"));
assert_eq!(h.child.requests().len(), 0, "the child's LLM must not be called again");
assert_eq!(report.frames_resumed, 1, "only the parent ran");
}
#[tokio::test]
async fn an_interrupted_parallel_batch_is_reaped_and_the_parent_resumes() {
let h = H::new(vec![Step::message("carrying on without them")], vec![]).await;
// Two delegate calls in one round, two live children: impossible for a
// linear stack, so it can only be a batch caught mid-flight.
h.store.append(h.root, NewMessage::user("do both")).await.unwrap();
let msg = h.store.append(h.root, NewMessage::assistant("", None)).await.unwrap();
let c1 = h.store.append_call(msg, NewCall::new("delegate", json!({}))).await.unwrap();
let c2 = h.store.append_call(msg, NewCall::new("delegate", json!({}))).await.unwrap();
let f1 = h.child_frame("a1", c1).await;
let f2 = h.child_frame("a2", c2).await;
let report = h.recover().await;
assert_eq!(report.batches_reaped, 1);
for (call, frame) in [(c1, f1), (c2, f2)] {
let call = h.call(call).await;
assert_eq!(call.state, CallState::Failed);
assert!(call.result.as_deref().unwrap().contains("parallel batch"), "{:?}", call.result);
assert!(!h.store.get_frame(frame).await.unwrap().unwrap().active);
}
assert_eq!(h.child.requests().len(), 0, "a reaped batch is not re-run");
assert_eq!(report.frames_resumed, 1, "the root continues with the failures in view");
}
// ── resolve_pending ──────────────────────────────────────────────────────────
#[tokio::test]
async fn approving_after_a_restart_runs_the_call_and_continues() {
let h = H::new(vec![Step::message("done, as approved")], vec![]).await;
let call = h.interrupted_call(h.root, "counter").await;
h.store.set_call_state(call, CallState::AwaitingHuman).await.unwrap();
h.manager
.resolve_pending(call, HumanDecision::Approved, Arc::new(Catalog), &h.params())
.await
.unwrap();
assert_eq!(*h.counter.lock().unwrap(), 1);
let call = h.call(call).await;
assert_eq!(call.state, CallState::Done);
assert_eq!(call.result.as_deref(), Some("run 1"));
}
#[tokio::test]
async fn rejecting_after_a_restart_records_the_refusal_and_continues() {
let h = H::new(vec![Step::message("understood, I won't")], vec![]).await;
let call = h.interrupted_call(h.root, "shell").await;
h.store.set_call_state(call, CallState::AwaitingHuman).await.unwrap();
h.manager
.resolve_pending(
call,
HumanDecision::Rejected { reason: "no thanks".into() },
Arc::new(Catalog),
&h.params(),
)
.await
.unwrap();
assert_eq!(*h.shell.lock().unwrap(), 0);
let call = h.call(call).await;
assert_eq!(call.state, CallState::Rejected);
assert_eq!(call.result.as_deref(), Some("no thanks"));
}
#[tokio::test]
async fn resolving_an_already_terminal_call_is_a_no_op() {
let h = H::new(vec![], vec![]).await;
let call = h.interrupted_call(h.root, "counter").await;
h.store
.resolve_call(call, &agent_loop::store::CallOutcome::Cancelled)
.await
.unwrap();
let report = h
.manager
.resolve_pending(call, HumanDecision::Approved, Arc::new(Catalog), &h.params())
.await
.unwrap();
// Cancelled is terminal and never re-executed (blueprint §8.2).
assert_eq!(*h.counter.lock().unwrap(), 0);
assert_eq!(h.call(call).await.state, CallState::Cancelled);
assert_eq!(report.frames_resumed, 0);
}
+1
View File
@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
agent-loop = { path = "../agent-loop" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["sync", "macros"] }
+1 -1
View File
@@ -35,7 +35,7 @@ pub struct SendMessageOptions {
/// True for system-generated messages injected as user turns (notification briefings).
pub is_synthetic: bool,
/// Opaque structured metadata persisted on the user turn (e.g. file attachments).
/// ChatHub forwards it verbatim; the MessageBuilder/UI derive their own views.
/// ChatHub forwards it verbatim; the projection and the UI derive their own views.
pub metadata: Option<MessageMetadata>,
}
-193
View File
@@ -1,193 +0,0 @@
use async_trait::async_trait;
use serde_json::Value;
use tokio::sync::mpsc;
/// A single message in a conversation.
#[derive(Debug, Clone)]
pub struct Message {
pub role: Role,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Role {
System,
User,
Assistant,
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self { role: Role::System, content: content.into() }
}
pub fn user(content: impl Into<String>) -> Self {
Self { role: Role::User, content: content.into() }
}
pub fn assistant(content: impl Into<String>) -> Self {
Self { role: Role::Assistant, content: content.into() }
}
}
/// Options for a single chat completion request.
#[derive(Debug, Clone)]
pub struct ChatOptions {
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
/// Session/stack IDs for request logging. Set by the LLM loop; ignored by
/// providers — only the logging wrapper reads them.
pub session_id: Option<i64>,
pub stack_id: Option<i64>,
/// The authenticated user driving this request. Correlates the metadata row
/// in `system.db` with the payload in `{userid}.db`. Logging-only.
pub user_id: Option<String>,
/// UUID correlating the metadata row (`llm_requests`) with the payload row
/// (`llm_request_payloads`). Generated by the LLM loop before the call.
/// Logging-only.
pub request_id: Option<String>,
}
/// Raw HTTP metadata captured during a provider call.
/// Sensitive header values (api_key) are redacted before storage.
#[derive(Debug, Default)]
pub struct LlmRawMeta {
pub request_headers: Option<Value>,
pub request_body: Option<Value>,
pub response_headers: Option<Value>,
pub response_body: Option<Value>,
}
/// The response from a chat completion (text only).
#[derive(Debug, Clone)]
pub struct ChatResponse {
pub content: String,
pub input_tokens: Option<u32>,
pub output_tokens: Option<u32>,
/// True when the model stopped due to hitting the token limit.
pub truncated: bool,
/// Chain-of-thought produced by reasoning models (e.g. DeepSeek thinking mode).
/// Must be echoed back in the assistant message on subsequent turns.
pub reasoning_content: Option<String>,
/// Tokens served from the provider's prompt cache (Anthropic: cache_read_input_tokens,
/// OpenAI: prompt_tokens_details.cached_tokens). None when the provider does not
/// report cache metrics.
pub cache_read_tokens: Option<u32>,
/// Tokens written into the provider's prompt cache (Anthropic only:
/// cache_creation_input_tokens). None for providers that do not expose this.
pub cache_creation_tokens: Option<u32>,
/// Cost of the request in USD, when the provider reports it (OpenRouter
/// returns it under `usage.cost`). None for providers that do not bill
/// per-request or do not expose the figure.
pub cost: Option<f64>,
}
/// A single tool call requested by the LLM.
#[derive(Debug, Clone)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
/// An incremental piece of a streaming completion, pushed by providers that
/// support SSE streaming. Purely best-effort UI feedback: the final `LlmTurn`
/// remains the authoritative result.
#[derive(Debug, Clone)]
pub enum StreamDelta {
/// Visible answer text.
Text(String),
/// Chain-of-thought / reasoning tokens (thinking models).
Reasoning(String),
}
/// Result of one LLM turn when tools are available.
#[derive(Debug)]
pub enum LlmTurn {
Message(ChatResponse),
ToolCalls {
content: String,
calls: Vec<ToolCall>,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
reasoning_content: Option<String>,
cache_read_tokens: Option<u32>,
cache_creation_tokens: Option<u32>,
cost: Option<f64>,
},
}
/// Stateless LLM client. Implementations hold only connection config (base URL,
/// API key). No memory, no database, no session state.
#[async_trait]
pub trait ChatbotClient: Send + Sync {
async fn chat(
&self,
messages: &[Message],
options: &ChatOptions,
) -> anyhow::Result<ChatResponse>;
/// Extracts the request cost in USD from a provider's raw JSON response,
/// when the provider reports it. OpenRouter (and other OpenAI-compatible
/// gateways) return it under `usage.cost`; the default reads that path and
/// yields None when absent. Providers with a different shape override this.
fn extract_cost(&self, response: &Value) -> Option<f64> {
response["usage"]["cost"].as_f64()
}
/// Chat with tool support. Default implementation ignores tools and falls
/// back to `chat()`.
async fn chat_with_tools(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<LlmTurn> {
let simple: Vec<Message> = messages
.iter()
.filter_map(|m| {
let role = m["role"].as_str()?;
let content = m["content"].as_str().unwrap_or("").to_string();
match role {
"system" => Some(Message::system(content)),
"user" => Some(Message::user(content)),
"assistant" => Some(Message::assistant(content)),
_ => None,
}
})
.collect();
let _ = tools;
let resp = self.chat(&simple, options).await?;
Ok(LlmTurn::Message(resp))
}
/// Like `chat_with_tools` but also returns raw HTTP metadata for logging.
/// Providers that make real HTTP calls should override this.
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
self.chat_with_tools(messages, tools, options).await.map(|t| (t, None))
}
/// Like `chat_with_tools_raw`, but the provider may push incremental
/// [`StreamDelta`]s into `delta_tx` as tokens arrive (SSE streaming).
/// Senders should use `try_send` and drop deltas when the channel is full —
/// streaming is best-effort UI feedback and must never backpressure the
/// HTTP read. The returned `LlmTurn` is always the complete, authoritative
/// result. The default ignores the channel and falls back to the buffered
/// call, so providers without streaming behave exactly as before.
async fn chat_with_tools_raw_streaming(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
delta_tx: mpsc::Sender<StreamDelta>,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let _ = delta_tx;
self.chat_with_tools_raw(messages, tools, options).await
}
}
+1
View File
@@ -9,6 +9,7 @@ pub type ToolFuture = Pin<Box<dyn std::future::Future<Output = anyhow::Result<St
/// A single LLM-callable tool injected by a specific interface (Telegram, Web, Cron, …).
///
/// The handler closure captures interface-specific state (e.g. `Arc<Bot>` + `ChatId`).
#[derive(Clone)]
pub struct InterfaceTool {
/// OpenAI-format tool definition sent to the LLM in the tools array.
pub definition: Value,
+3 -2
View File
@@ -1,11 +1,12 @@
/// Application name, sent as `X-Title` HTTP header to LLM/image/audio providers.
pub const APP_NAME: &str = "Skald";
/// Lives in `agent-loop` (the LLM clients' home, blueprint D13); re-exported here
/// so existing users don't change.
pub use agent_loop::APP_NAME;
pub mod approval;
pub mod bus;
pub mod config_api;
pub mod system_bus;
pub mod chatbot;
pub mod chat_hub;
pub mod command;
pub mod events;
+4 -2
View File
@@ -3,7 +3,8 @@ use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use crate::chatbot::ChatbotClient;
use agent_loop::model::Model;
use crate::image_generate::{ImageGenerate, ImageGenerateModelRecord};
use crate::tts::{TextToSpeech, TtsModelRecord, RemoteTtsModelInfo};
use crate::transcribe::{Transcribe, TranscribeModelRecord, RemoteTranscribeModelInfo};
@@ -139,7 +140,8 @@ pub struct ProviderField {
// ── BuiltLlmClient ────────────────────────────────────────────────────────────
pub struct BuiltLlmClient {
pub client: Arc<dyn ChatbotClient>,
/// A stateless `agent_loop` model client (blueprint D13).
pub client: Arc<dyn Model>,
pub prompt_cache: bool,
}
+5
View File
@@ -8,3 +8,8 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
[dev-dependencies]
# The crate-level doc example (`#[tokio::main]`) compiles under `cargo test`.
tokio = { version = "1", features = ["macros", "rt"] }
anyhow = "1"
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "llm-client"
version = "0.1.0"
edition = "2024"
[dependencies]
core-api = { path = "../core-api" }
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy", "json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
anyhow = "1"
tracing = "0.1"
tokio = { version = "1", features = ["sync"] }
futures-util = "0.3"
-168
View File
@@ -1,168 +0,0 @@
pub mod anthropic;
pub mod lm_studio;
pub mod ollama;
pub mod openai;
// Re-export the trait and all associated types from core-api so existing
// callers that import from `llm_client` continue to work unchanged.
pub use core_api::chatbot::{
ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, Role, StreamDelta,
ToolCall,
};
use serde_json::Value;
/// Incremental SSE decoder: feed raw response bytes, get back the payload of
/// every complete `data:` line seen (`[DONE]` included — callers decide).
/// Buffers partial lines across chunks; `event:` lines and comments are
/// skipped (both OpenAI and Anthropic put the event type inside the JSON).
#[derive(Default)]
pub struct SseDecoder {
buf: Vec<u8>,
}
impl SseDecoder {
pub fn new() -> Self {
Self::default()
}
pub fn feed(&mut self, bytes: &[u8]) -> Vec<String> {
self.buf.extend_from_slice(bytes);
let mut out = Vec::new();
while let Some(pos) = self.buf.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = self.buf.drain(..=pos).collect();
if let Some(payload) = parse_sse_line(&line) {
out.push(payload);
}
}
out
}
/// Flush a trailing line not terminated by `\n` at end-of-stream.
pub fn finish(&mut self) -> Vec<String> {
let rest = std::mem::take(&mut self.buf);
parse_sse_line(&rest).into_iter().collect()
}
}
/// A complete SSE line is valid UTF-8 (a multibyte sequence never contains a
/// `\n` byte), but decode lossily anyway — a corrupt line is skipped, not fatal.
fn parse_sse_line(line: &[u8]) -> Option<String> {
let line = String::from_utf8_lossy(line);
let line = line.trim_end_matches('\r').trim();
let data = line.strip_prefix("data:")?.trim_start();
if data.is_empty() { None } else { Some(data.to_string()) }
}
/// Converts a reqwest `HeaderMap` into a `serde_json::Value` object.
pub fn headers_to_json(headers: &reqwest::header::HeaderMap) -> Value {
let map: serde_json::Map<String, Value> = headers
.iter()
.map(|(k, v)| (
k.as_str().to_string(),
v.to_str().unwrap_or("<binary>").into(),
))
.collect();
Value::Object(map)
}
/// Turns a raw error-response body into a JSON `Value` for the payload log:
/// the parsed JSON when the provider returned JSON (the common case — an
/// `{"error": …}` object), else the raw text wrapped as a JSON string so a
/// non-JSON body (HTML gateway page, plain text) is still preserved verbatim.
pub fn error_response_body(text: String) -> Value {
serde_json::from_str::<Value>(&text).unwrap_or(Value::String(text))
}
/// Returns a redacted preview of an API key: first 7 chars + "***".
pub fn redact_key(key: &str) -> String {
if key.len() > 7 {
format!("{}***", &key[..7])
} else {
"***".to_string()
}
}
/// A structured LLM call failure carrying the HTTP `status` of the response.
///
/// Clients that read the status themselves (rather than via `error_for_status`)
/// return this so callers can classify retriability on the numeric code instead of
/// substring-matching a formatted message — which mis-fires when a model id, token
/// count or URL merely contains "401"/"404"/… (bug B6). Non-HTTP failures (network,
/// JSON parse, cancellation) stay ordinary `anyhow` errors with no status.
#[derive(Debug, Default)]
pub struct LlmError {
/// HTTP status code, when the failure came from an HTTP response.
pub status: Option<u16>,
/// Human-readable detail (provider tag + body), used for logs and the UI.
pub message: String,
/// Request/response payload captured at the failing call, so the debug log
/// can show what was actually sent even when the provider rejected it (e.g.
/// a 400). `None` for failures with no HTTP round-trip (network, cancellation,
/// parse) — those carry no body to surface.
pub raw_meta: Option<LlmRawMeta>,
}
impl std::fmt::Display for LlmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for LlmError {}
/// Extracts the HTTP status of an LLM failure, if any: a structured
/// [`LlmError::status`] first, else any `reqwest::Error` in the source chain (the
/// clients that fail via `error_for_status()?`). Returns `None` for a non-HTTP
/// error (network, parse, cancellation), which callers should treat as retriable.
pub fn http_status(err: &anyhow::Error) -> Option<u16> {
for cause in err.chain() {
if let Some(le) = cause.downcast_ref::<LlmError>() {
return le.status;
}
if let Some(re) = cause.downcast_ref::<reqwest::Error>() {
if let Some(s) = re.status() {
return Some(s.as_u16());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::SseDecoder;
#[test]
fn sse_decoder_buffers_partial_lines_across_chunks() {
let mut dec = SseDecoder::new();
// A payload split mid-JSON across two chunks yields one complete line.
assert!(dec.feed(br#"data: {"a": 1"#).is_empty());
assert_eq!(dec.feed(b"}\r\n").len(), 1);
}
#[test]
fn sse_decoder_skips_events_comments_and_keeps_done() {
let mut dec = SseDecoder::new();
let out = dec.feed(b"event: message_start\n: ping\n\ndata: {\"type\":\"ping\"}\ndata: [DONE]\n");
assert_eq!(out, vec!["{\"type\":\"ping\"}".to_string(), "[DONE]".to_string()]);
assert!(dec.finish().is_empty());
}
#[test]
fn sse_decoder_finish_flushes_unterminated_tail() {
let mut dec = SseDecoder::new();
assert!(dec.feed(b"data: tail-without-newline").is_empty());
assert_eq!(dec.finish(), vec!["tail-without-newline".to_string()]);
}
#[test]
fn sse_decoder_handles_multibyte_split() {
let mut dec = SseDecoder::new();
// "€" is 3 bytes in UTF-8; split across the chunk boundary.
let payload = "data: {\"t\":\"\"}\n".as_bytes();
let (a, b) = payload.split_at(12);
assert!(dec.feed(a).is_empty());
assert_eq!(dec.feed(b), vec!["{\"t\":\"\"}".to_string()]);
}
}
-65
View File
@@ -1,65 +0,0 @@
use async_trait::async_trait;
use serde_json::Value;
use tokio::sync::mpsc;
use crate::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, StreamDelta, openai::OpenAiClient};
/// LM Studio client.
///
/// LM Studio exposes an OpenAI-compatible `/v1` endpoint, so this is a thin
/// wrapper that defaults to `http://localhost:1234/v1` and requires no API key.
pub struct LmStudioClient {
inner: OpenAiClient,
}
impl LmStudioClient {
/// `base_url` defaults to `http://localhost:1234/v1` if `None`.
pub fn new(base_url: Option<impl Into<String>>) -> Self {
let url = base_url
.map(|u| u.into())
.unwrap_or_else(|| "http://localhost:1234/v1".to_string());
Self { inner: OpenAiClient::new(url, "", None, false) }
}
}
#[async_trait]
impl ChatbotClient for LmStudioClient {
async fn chat(
&self,
messages: &[Message],
options: &ChatOptions,
) -> anyhow::Result<ChatResponse> {
self.inner.chat(messages, options).await
}
async fn chat_with_tools(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<LlmTurn> {
self.inner.chat_with_tools(messages, tools, options).await
}
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
self.inner.chat_with_tools_raw(messages, tools, options).await
}
/// LM Studio is OpenAI-compatible: streaming forwards to the inner client.
/// If a local build rejects `stream_options`, the inner pre-delta buffered
/// retry covers it transparently.
async fn chat_with_tools_raw_streaming(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
delta_tx: mpsc::Sender<StreamDelta>,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
self.inner.chat_with_tools_raw_streaming(messages, tools, options, delta_tx).await
}
}
-76
View File
@@ -1,76 +0,0 @@
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::{ChatOptions, ChatResponse, ChatbotClient, Message, Role};
/// Ollama client using the native `/api/chat` endpoint.
///
/// Defaults to `http://localhost:11434`. No API key required.
pub struct OllamaClient {
base_url: String,
http: reqwest::Client,
}
impl OllamaClient {
/// `base_url` defaults to `http://localhost:11434` if `None`.
pub fn new(base_url: Option<impl Into<String>>) -> Self {
let url = base_url
.map(|u| u.into())
.unwrap_or_else(|| "http://localhost:11434".to_string());
Self { base_url: url, http: reqwest::Client::new() }
}
}
#[async_trait]
impl ChatbotClient for OllamaClient {
async fn chat(
&self,
messages: &[Message],
options: &ChatOptions,
) -> anyhow::Result<ChatResponse> {
let msgs: Vec<Value> = messages
.iter()
.map(|m| {
let role = match m.role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
};
json!({ "role": role, "content": m.content })
})
.collect();
let mut options_obj = json!({});
if let Some(t) = options.temperature { options_obj["temperature"] = t.into(); }
if let Some(n) = options.max_tokens { options_obj["num_predict"] = n.into(); }
let body = json!({
"model": options.model,
"messages": msgs,
"stream": false,
"options": options_obj,
});
let url = format!("{}/api/chat", self.base_url.trim_end_matches('/'));
let resp: Value = self
.http
.post(&url)
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
let content = resp["message"]["content"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing content in Ollama response"))?
.to_string();
let input_tokens = resp["prompt_eval_count"].as_u64().map(|n| n as u32);
let output_tokens = resp["eval_count"].as_u64().map(|n| n as u32);
Ok(ChatResponse { content, input_tokens, output_tokens, truncated: false, reasoning_content: None, cache_read_tokens: None, cache_creation_tokens: None, cost: None })
}
}
-485
View File
@@ -1,485 +0,0 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use futures_util::StreamExt;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use tracing::{debug, info, trace, warn};
use crate::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, Role, SseDecoder, StreamDelta, ToolCall, error_response_body, headers_to_json, redact_key};
use core_api::APP_NAME;
/// OpenAI ChatGPT client (also compatible with any OpenAI-spec endpoint).
pub struct OpenAiClient {
base_url: String,
api_key: String,
extra_params: Option<serde_json::Value>,
/// When true, Anthropic-compatible prompt-caching hints are injected:
/// - `anthropic-beta: prompt-caching-2024-07-31` header is sent.
/// - The last tool definition is tagged with `cache_control: {"type":"ephemeral"}`.
/// - System message content is expected to already be a content array with
/// `cache_control` on the static block (set by `build_openai_messages`).
/// Used for OpenRouter when routing to Anthropic models.
enable_prompt_cache: bool,
http: reqwest::Client,
}
impl OpenAiClient {
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, extra_params: Option<serde_json::Value>, enable_prompt_cache: bool) -> Self {
Self {
base_url: base_url.into(),
api_key: api_key.into(),
extra_params,
enable_prompt_cache,
http: reqwest::Client::new(),
}
}
/// Merges `extra_params` (if any) into `body`. Only top-level object keys are merged.
fn apply_extra(&self, body: &mut serde_json::Value) {
if let Some(serde_json::Value::Object(extra)) = &self.extra_params {
if let Some(b) = body.as_object_mut() {
for (k, v) in extra {
b.insert(k.clone(), v.clone());
}
}
}
}
fn url(&self) -> String {
format!("{}/chat/completions", self.base_url.trim_end_matches('/'))
}
/// Shared request body for the buffered and the streaming path. Caller adds
/// `max_tokens`/`temperature`/`extra_params` afterwards via `finalize_body`.
fn base_body(&self, model: &str, messages: &[Value], tools: &[Value]) -> Value {
let mut body = json!({
"model": model,
"messages": messages,
});
if !tools.is_empty() {
// When prompt caching is enabled, tag the last tool with cache_control
// so the entire tools array is included in the Anthropic KV cache prefix.
let tools_value: Value = if self.enable_prompt_cache {
let mut tagged = tools.to_vec();
if let Some(last) = tagged.last_mut() {
last["cache_control"] = json!({"type": "ephemeral"});
}
tagged.into()
} else {
tools.into()
};
body["tools"] = tools_value;
body["tool_choice"] = "auto".into();
}
body
}
fn finalize_body(&self, mut body: Value, options: &ChatOptions) -> Value {
if let Some(t) = options.max_tokens { body["max_tokens"] = t.into(); }
if let Some(t) = options.temperature { body["temperature"] = t.into(); }
self.apply_extra(&mut body);
body
}
/// Request metadata for logging (shared by buffered and streaming paths).
fn logged_headers(&self) -> Value {
let mut logged_headers = json!({
"authorization": format!("Bearer {}", redact_key(&self.api_key)),
"content-type": "application/json",
});
if self.enable_prompt_cache {
logged_headers["anthropic-beta"] = "prompt-caching-2024-07-31".into();
}
logged_headers
}
async fn send_request(&self, body: &Value) -> reqwest::Result<reqwest::Response> {
let mut req = self.http.post(self.url()).bearer_auth(&self.api_key).header("X-Title", APP_NAME);
if self.enable_prompt_cache {
req = req.header("anthropic-beta", "prompt-caching-2024-07-31");
}
req.json(body).send().await
}
/// SSE streaming path behind `chat_with_tools_raw_streaming`. Accumulates
/// content/reasoning/tool-call fragments into the same `LlmTurn` the
/// buffered path would return, while forwarding text/reasoning deltas to
/// `delta_tx` (try_send, best-effort). `emitted` tracks whether any delta
/// was pushed, so the caller can distinguish a pre-stream failure (safe to
/// retry buffered) from a mid-stream one (partial output already shown).
async fn stream_chat(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
delta_tx: &mpsc::Sender<StreamDelta>,
emitted: &mut bool,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let mut body = self.base_body(&options.model, messages, tools);
body["stream"] = json!(true);
body["stream_options"] = json!({ "include_usage": true });
let body = self.finalize_body(body, options);
debug!(model = %options.model, tools = tools.len(), prompt_cache = self.enable_prompt_cache, "openai: sending streaming chat_with_tools request");
trace!(body = %body, "openai: streaming chat_with_tools request body");
let request_body = body.clone();
let request_headers = self.logged_headers();
let http_resp = self.send_request(&body).await?;
let response_headers = headers_to_json(http_resp.headers());
let status = http_resp.status();
if !status.is_success() {
let resp_text = http_resp.text().await?;
return Err(crate::LlmError {
status: Some(status.as_u16()),
message: format!(
"openai: HTTP {status} from {url}\nbody: {resp_text}",
url = self.url(),
),
raw_meta: Some(LlmRawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(error_response_body(resp_text)),
}),
}.into());
}
let mut content = String::new();
let mut reasoning = String::new();
// index → (id, name, arguments fragment buffer)
let mut tool_calls: BTreeMap<u64, (String, String, String)> = BTreeMap::new();
let mut finish_reason: Option<String> = None;
let mut usage: Option<Value> = None;
let mut sse = SseDecoder::new();
let mut byte_stream = http_resp.bytes_stream();
// One SSE `data:` payload. Fragments update the accumulators; text and
// reasoning also go out as deltas. Unparseable chunks are skipped —
// the assembled turn stays consistent.
let mut handle_payload = |payload: &str, emitted: &mut bool| {
if payload == "[DONE]" {
return;
}
let Ok(v) = serde_json::from_str::<Value>(payload) else { return };
if let Some(u) = v.get("usage").filter(|u| !u.is_null()) {
usage = Some(u.clone());
}
let Some(choice) = v["choices"].as_array().and_then(|a| a.first()) else { return };
if let Some(fr) = choice["finish_reason"].as_str() {
finish_reason = Some(fr.to_string());
}
let delta = &choice["delta"];
if let Some(t) = delta["content"].as_str().filter(|t| !t.is_empty()) {
content.push_str(t);
*emitted = true;
let _ = delta_tx.try_send(StreamDelta::Text(t.to_string()));
}
// Same normalization as the buffered path: DeepSeek uses
// `reasoning_content`, MiniMax M3 and others `reasoning`.
if let Some(t) = delta["reasoning_content"].as_str()
.or_else(|| delta["reasoning"].as_str())
.filter(|t| !t.is_empty())
{
reasoning.push_str(t);
*emitted = true;
let _ = delta_tx.try_send(StreamDelta::Reasoning(t.to_string()));
}
if let Some(tc_arr) = delta["tool_calls"].as_array() {
for tc in tc_arr {
let idx = tc["index"].as_u64().unwrap_or(0);
let entry = tool_calls.entry(idx).or_default();
if let Some(id) = tc["id"].as_str() { entry.0 = id.to_string(); }
if let Some(n) = tc["function"]["name"].as_str() { entry.1 = n.to_string(); }
if let Some(a) = tc["function"]["arguments"].as_str() { entry.2.push_str(a); }
}
}
};
while let Some(chunk) = byte_stream.next().await {
let chunk = chunk?;
for payload in sse.feed(&chunk) {
handle_payload(&payload, emitted);
}
}
for payload in sse.finish() {
handle_payload(&payload, emitted);
}
let finish = finish_reason.as_deref().unwrap_or("stop");
let input_tokens = usage.as_ref().and_then(|u| u["prompt_tokens"].as_u64()).map(|n| n as u32);
let output_tokens = usage.as_ref().and_then(|u| u["completion_tokens"].as_u64()).map(|n| n as u32);
let cache_read_tokens = usage.as_ref()
.and_then(|u| u["prompt_tokens_details"]["cached_tokens"].as_u64())
.map(|n| n as u32);
let cost = usage.as_ref().and_then(|u| u["cost"].as_f64());
let reasoning_content = if reasoning.is_empty() { None } else { Some(reasoning) };
info!(model = %options.model, ?input_tokens, ?output_tokens, finish_reason = finish, "openai: streaming response completed");
if finish == "length" {
warn!(model = %options.model, ?output_tokens, "openai: response truncated (max_tokens reached)");
}
// Reassemble the streamed message for the payload log, so a streamed call
// leaves the same debugging trail as a buffered one — including
// reasoning_content and tool_calls, which previously existed only as
// transient deltas and never appeared in the logged body. Built here,
// before `turn` consumes the accumulators (clones are cheap vs. the round-trip).
let logged_tool_calls: Vec<Value> = tool_calls.iter()
.map(|(_idx, (id, name, args))| json!({
"id": id,
"type": "function",
"function": { "name": name, "arguments": args },
}))
.collect();
let mut logged_message = json!({ "role": "assistant", "content": content.clone() });
if let Some(rc) = &reasoning_content {
logged_message["reasoning_content"] = rc.clone().into();
}
if !logged_tool_calls.is_empty() {
logged_message["tool_calls"] = Value::Array(logged_tool_calls);
}
let turn = if !tool_calls.is_empty() {
let calls = tool_calls
.into_values()
.map(|(id, name, args)| ToolCall {
id,
name,
arguments: serde_json::from_str(&args).unwrap_or(Value::Object(Default::default())),
})
.collect();
LlmTurn::ToolCalls { content, calls, input_tokens, output_tokens, reasoning_content, cache_read_tokens, cache_creation_tokens: None, cost }
} else {
let truncated = finish == "length";
LlmTurn::Message(ChatResponse { content, input_tokens, output_tokens, truncated, reasoning_content, cache_read_tokens, cache_creation_tokens: None, cost })
};
// Synthesize a buffered-shaped response body for the payload log, so a
// streamed call leaves the same debugging trail as a buffered one.
let response_body = json!({
"streamed": true,
"choices": [{ "finish_reason": finish, "message": logged_message }],
"usage": usage,
});
let raw_meta = LlmRawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(response_body),
};
Ok((turn, Some(raw_meta)))
}
}
#[async_trait]
impl ChatbotClient for OpenAiClient {
async fn chat(
&self,
messages: &[Message],
options: &ChatOptions,
) -> anyhow::Result<ChatResponse> {
let msgs: Vec<Value> = messages
.iter()
.map(|m| {
let role = match m.role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
};
json!({ "role": role, "content": m.content })
})
.collect();
let mut body = json!({
"model": options.model,
"messages": msgs,
});
if let Some(t) = options.max_tokens { body["max_tokens"] = t.into(); }
if let Some(t) = options.temperature { body["temperature"] = t.into(); }
self.apply_extra(&mut body);
debug!(model = %options.model, "openai: sending chat request");
trace!(body = %body, "openai: chat request body");
let resp: Value = self
.http
.post(self.url())
.bearer_auth(&self.api_key)
.header("X-Title", APP_NAME)
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
let content = match resp["choices"][0]["message"]["content"].as_str() {
Some(s) => s.to_string(),
None => {
warn!(raw_response = %resp, "openai: chat() response has null content");
String::new()
}
};
let input_tokens = resp["usage"]["prompt_tokens"].as_u64().map(|n| n as u32);
let output_tokens = resp["usage"]["completion_tokens"].as_u64().map(|n| n as u32);
let cache_read_tokens = resp["usage"]["prompt_tokens_details"]["cached_tokens"].as_u64().map(|n| n as u32);
let truncated = resp["choices"][0]["finish_reason"].as_str() == Some("length");
let cost = self.extract_cost(&resp);
info!(model = %options.model, ?input_tokens, ?output_tokens, ?cost, truncated, "openai: chat response received");
Ok(ChatResponse { content, input_tokens, output_tokens, truncated, reasoning_content: None, cache_read_tokens, cache_creation_tokens: None, cost })
}
async fn chat_with_tools(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<LlmTurn> {
self.chat_with_tools_raw(messages, tools, options).await.map(|(t, _)| t)
}
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let body = self.finalize_body(self.base_body(&options.model, messages, tools), options);
debug!(model = %options.model, tools = tools.len(), prompt_cache = self.enable_prompt_cache, "openai: sending chat_with_tools request");
trace!(body = %body, "openai: chat_with_tools request body");
// Capture request metadata for logging.
let request_body = body.clone();
let request_headers = self.logged_headers();
let http_resp = self.send_request(&body).await?;
let response_headers = headers_to_json(http_resp.headers());
let status = http_resp.status();
let resp_text = http_resp.text().await?;
if !status.is_success() {
return Err(crate::LlmError {
status: Some(status.as_u16()),
message: format!(
"openai: HTTP {status} from {url}\nbody: {resp_text}",
url = self.url(),
),
raw_meta: Some(LlmRawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(error_response_body(resp_text)),
}),
}.into());
}
let resp: Value = serde_json::from_str(&resp_text)
.map_err(|e| anyhow::anyhow!("openai: failed to parse response JSON: {e}\nbody: {resp_text}"))?;
let response_body: Value = serde_json::from_str(&resp_text).unwrap_or(Value::Null);
let raw_meta = LlmRawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(response_body),
};
let input_tokens = resp["usage"]["prompt_tokens"].as_u64().map(|n| n as u32);
let output_tokens = resp["usage"]["completion_tokens"].as_u64().map(|n| n as u32);
let cache_read_tokens = resp["usage"]["prompt_tokens_details"]["cached_tokens"].as_u64().map(|n| n as u32);
let cost = self.extract_cost(&resp);
let choice = &resp["choices"][0];
let message = &choice["message"];
let finish = choice["finish_reason"].as_str().unwrap_or("stop");
info!(model = %options.model, ?input_tokens, ?output_tokens, finish_reason = finish, "openai: chat_with_tools response received");
if finish == "length" {
warn!(model = %options.model, ?output_tokens, "openai: response truncated (max_tokens reached)");
}
// Thinking/reasoning content varies by provider:
// - DeepSeek: "reasoning_content" (must be echoed back on subsequent turns, even as "")
// - MiniMax M3 and others: "reasoning"
// We normalize to a single field and echo under both names in message_builder.
let reasoning_content = message["reasoning_content"].as_str()
.or_else(|| message["reasoning"].as_str())
.map(str::to_string);
let tool_calls_array = message["tool_calls"].as_array().filter(|a| !a.is_empty());
// Some models (e.g. Qwen via OpenRouter) return finish_reason "stop" even when
// tool_calls are present, so check the array directly rather than relying on finish_reason.
let turn = if finish == "tool_calls" || tool_calls_array.is_some() {
let content = message["content"].as_str().unwrap_or("").to_string();
let calls = tool_calls_array
.ok_or_else(|| anyhow::anyhow!("finish_reason=tool_calls but tool_calls array missing or empty"))?
.iter()
.map(|tc| {
let id = tc["id"].as_str().unwrap_or("").to_string();
let name = tc["function"]["name"].as_str().unwrap_or("").to_string();
let args: Value = tc["function"]["arguments"]
.as_str()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Object(Default::default()));
ToolCall { id, name, arguments: args }
})
.collect();
LlmTurn::ToolCalls { content, calls, input_tokens, output_tokens, reasoning_content, cache_read_tokens, cache_creation_tokens: None, cost }
} else {
// content can be null for thinking/reasoning models or when finish_reason="length".
// Fall back to empty string rather than erroring — the partial response is still
// useful and a hard error breaks the session.
let content = match message["content"].as_str() {
Some(s) => s.to_string(),
None => {
tracing::warn!(
finish_reason = finish,
?input_tokens,
?output_tokens,
raw_message = %message,
"OpenAI response has null content",
);
String::new()
}
};
let truncated = finish == "length";
LlmTurn::Message(ChatResponse { content, input_tokens, output_tokens, truncated, reasoning_content, cache_read_tokens, cache_creation_tokens: None, cost })
};
Ok((turn, Some(raw_meta)))
}
async fn chat_with_tools_raw_streaming(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
delta_tx: mpsc::Sender<StreamDelta>,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let mut emitted = false;
match self.stream_chat(messages, tools, options, &delta_tx, &mut emitted).await {
Ok(ok) => Ok(ok),
// Nothing was ever streamed: some OpenAI-compatible providers reject
// `stream`/`stream_options` outright — retry buffered so they keep
// working exactly as before. A mid-stream failure (deltas already
// shown) instead propagates to the model-fallback logic.
Err(e) if !emitted => {
debug!(model = %options.model, error = %e, "openai: streaming failed before any delta; retrying buffered");
self.chat_with_tools_raw(messages, tools, options).await
}
Err(e) => Err(e),
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
//! # Required secret
//!
//! Set before enabling the plugin:
//! ```
//! ```text
//! set_secret("HUGGINGFACE_TOKEN", "hf_...")
//! ```
//! Get a token at <https://huggingface.co/settings/tokens>.
+6 -1
View File
@@ -78,6 +78,11 @@ base64 = "0.22"
sha2 = "0.10"
notify = "8"
honcho-client = { path = "../honcho-client" }
llm-client = { path = "../llm-client" }
agent-loop = { path = "../agent-loop" }
core-api = { path = "../core-api" }
mcp-client = { path = "../mcp-client" }
[dev-dependencies]
# Tests that build reqwest clients (rustls-no-provider) need a process-wide
# crypto provider, installed in main() in production.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
+1 -1
View File
@@ -270,7 +270,7 @@ fn resolve_includes(content: &str) -> Result<String> {
} else if trimmed == "<!-- AGENTS_LIST -->" {
out.push_str(&render_agents_list()?);
} else if trimmed == "<!-- MCP_LIST -->" {
// Replaced at request time in build_openai_messages with dynamic
// Replaced at request time by the system-context source with dynamic
// active/hidden sections. Leave a sentinel so the injection point
// is preserved and positioned correctly in the prompt.
out.push_str("__MCP_LIST__\n");
+1 -1
View File
@@ -10,7 +10,7 @@
//! pile up while the turn runs are drained, one row each, at the turn's round
//! boundaries (`drain_leading_user`) and injected live into the running turn.
//! Coalescing for the LLM (merging consecutive user rows into one `role:user`)
//! happens later in the `MessageBuilder`, not here, so the DB keeps each message
//! happens later in the projection, not here, so the DB keeps each message
//! distinct while the model still sees a single clean user turn.
//!
//! Serialization of the turns themselves still lives in
+33 -6
View File
@@ -18,7 +18,9 @@ use crate::cron::TaskManager;
use crate::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, config, sources};
use crate::events::{GlobalEvent, ServerEvent};
use crate::notification::Notification;
use crate::session::handler::{ChatSessionHandler, InterfaceTool, PendingMsg, PendingUserInput};
use crate::session::handler::{
ApprovalDecision, ChatSessionHandler, InterfaceTool, PendingMsg, PendingUserInput,
};
use crate::session::manager::ChatSessionManager;
use crate::tools::tool_names as tn;
@@ -370,7 +372,7 @@ impl ChatHub {
}
/// Resume any interrupted turn for a source's active session.
/// Calls `resume_turn` which re-executes pending tool calls (approval or
/// Calls `recover_turn`, which re-executes pending tool calls (approval or
/// clarification) and re-runs the LLM loop if needed.
/// Safe to call unconditionally — returns immediately if there is nothing to resume.
/// Events are published to the global broadcast bus so existing subscribers
@@ -382,7 +384,7 @@ impl ChatHub {
};
// Guard against double-driving. A client sends `resume` on connect whenever
// history shows a pending/interrupted tool — including when the turn is still
// live and merely awaiting an approval. Without this check `resume_turn` would
// live and merely awaiting an approval. Without this check the recovery would
// block on the `processing` lock and, once the approval unblocks the original
// turn and it finishes, run a spurious *second* turn on the just-completed
// conversation. If a turn is already in flight it owns the session and emits
@@ -408,11 +410,36 @@ impl ChatHub {
let tx = Self::bridge_to_global(self.global_tx.clone(), source, session_id);
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
let interface_tools = self.execute_task_tools(session_id, &handler).await;
handler.resume_turn(None, None, interface_tools, tx).await
handler.recover_turn(interface_tools, tx).await
}
/// Apply a human decision to a tool call nothing is waiting on anymore (an
/// approval answered after a restart), then continue the conversation.
/// Events reach the reconnected client through the global bus, as for
/// [`Self::resume_session`].
pub async fn resolve_pending_call(
&self,
session_id: i64,
call: i64,
decision: ApprovalDecision,
) -> anyhow::Result<()> {
let decision = match decision {
ApprovalDecision::Approved => agent_loop::recovery::HumanDecision::Approved,
ApprovalDecision::Rejected { note } => agent_loop::recovery::HumanDecision::Rejected {
reason: ApprovalDecision::rejection_message(&note),
},
};
let source = chat_sessions::find_by_id(&self.db, session_id).await?
.map(|s| s.source)
.unwrap_or_else(|| "web".to_string());
let tx = Self::bridge_to_global(self.global_tx.clone(), source, session_id);
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
let interface_tools = self.execute_task_tools(session_id, &handler).await;
handler.resolve_pending_call(call, decision, interface_tools, tx).await
}
/// Builds the `execute_task` interface tool for a session, mirroring the injection
/// done for live turns (`run_agent_turn`). Empty when no TaskManager is configured
/// done for live turns. Empty when no TaskManager is configured
/// so `execute_task mode=async` can be rebuilt by `build_execution` during resume.
async fn execute_task_tools(
&self,
@@ -728,7 +755,7 @@ impl ChatHub {
let count = notes.len();
// Build a synthetic assistant message with a reasoning trace and a
// pre-completed read_notification tool call carrying the notifications as results.
// The agent is then woken via resume() — resume_turn sees the tool calls on
// The agent is then woken via resume() — recovery sees the tool calls on
// the last assistant message and runs the LLM loop so the agent can respond.
let result_json = serde_json::to_string(&notes).unwrap_or_else(|_| "[]".to_string());
-166
View File
@@ -1,166 +0,0 @@
//! Transparent logging wrapper for any [`ChatbotClient`].
//!
//! [`LoggingChatbotClient`] intercepts every `chat_with_tools_raw` call, captures
//! the raw HTTP request/response from the inner provider, persists a **metadata-only**
//! row to `llm_requests` in `system.db` (fire-and-forget), then returns the raw data
//! to the caller so it can write the **payload** to the user's own database.
//!
//! The split keeps conversation content (payloads) behind the user key while
//! metadata (cost, tokens, timing) stays in the admin-readable registry.
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use serde_json::Value;
use sqlx::SqlitePool;
use tokio::sync::mpsc;
use tracing::warn;
use crate::db::llm_requests;
use super::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, StreamDelta};
// ─────────────────────────────────────────────────────────────────────────────
pub struct LoggingChatbotClient {
inner: Arc<dyn ChatbotClient>,
pool: Arc<SqlitePool>,
model_name: String,
}
impl LoggingChatbotClient {
pub fn new(
inner: Arc<dyn ChatbotClient>,
pool: Arc<SqlitePool>,
model_name: impl Into<String>,
) -> Self {
Self { inner, pool, model_name: model_name.into() }
}
/// Shared logging tail of both raw entry points: writes the metadata-only
/// row to `system.db` (fire-and-forget), then passes the result through.
async fn log_and_return(
&self,
options: &ChatOptions,
duration: Duration,
result: anyhow::Result<(LlmTurn, Option<LlmRawMeta>)>,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let duration_ms = duration.as_millis() as i64;
let session_id = options.session_id;
let stack_id = options.stack_id;
let user_id = options.user_id.clone();
let request_id = options.request_id.clone();
let model_name = self.model_name.clone();
let pool = Arc::clone(&self.pool);
match result {
Ok((turn, meta)) => {
let (input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) = match &turn {
LlmTurn::Message(r) => (r.input_tokens, r.output_tokens, r.cache_read_tokens, r.cache_creation_tokens),
LlmTurn::ToolCalls { input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, .. } =>
(*input_tokens, *output_tokens, *cache_read_tokens, *cache_creation_tokens),
};
tokio::spawn(async move {
if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id,
stack_id,
model_name,
error_text: None,
input_tokens: input_tokens.map(|n| n as i64),
output_tokens: output_tokens.map(|n| n as i64),
duration_ms,
cache_read_tokens: cache_read_tokens.map(|n| n as i64),
cache_creation_tokens: cache_creation_tokens.map(|n| n as i64),
}).await {
warn!(error = %e, "llm_requests: failed to insert log row");
}
});
Ok((turn, meta))
}
Err(e) => {
let error_text = e.to_string();
tokio::spawn(async move {
if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id,
stack_id,
model_name,
error_text: Some(error_text),
input_tokens: None,
output_tokens: None,
duration_ms,
cache_read_tokens: None,
cache_creation_tokens: None,
}).await {
warn!(error = %log_err, "llm_requests: failed to insert error log row");
}
});
Err(e)
}
}
}
}
#[async_trait]
impl ChatbotClient for LoggingChatbotClient {
/// Passthrough — logging only applies to the tool-calling path.
async fn chat(
&self,
messages: &[Message],
options: &ChatOptions,
) -> anyhow::Result<ChatResponse> {
self.inner.chat(messages, options).await
}
/// Passthrough that drops the raw meta. Used by callers that do not need
/// payload capture (e.g. the compactor).
async fn chat_with_tools(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<LlmTurn> {
let (turn, _) = self.chat_with_tools_raw(messages, tools, options).await?;
Ok(turn)
}
/// Intercepts the call, delegates to `inner.chat_with_tools_raw` to capture
/// HTTP wire data, writes a **metadata-only** row to `system.db`, then returns
/// the raw data so the caller can persist payloads to the user's own database.
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let start = Instant::now();
let result = self.inner.chat_with_tools_raw(messages, tools, options).await;
self.log_and_return(options, start.elapsed(), result).await
}
/// Streaming twin of `chat_with_tools_raw`: forwards `delta_tx` untouched to
/// the inner client (deltas are not logged — only the final turn is), then
/// applies the same metadata logging. Without this override the trait
/// default would silently fall back to the buffered call.
async fn chat_with_tools_raw_streaming(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
delta_tx: mpsc::Sender<StreamDelta>,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let start = Instant::now();
let result = self.inner.chat_with_tools_raw_streaming(messages, tools, options, delta_tx).await;
self.log_and_return(options, start.elapsed(), result).await
}
}
-7
View File
@@ -1,7 +0,0 @@
pub mod logging;
// Re-export from the independent llm-client crate.
pub use llm_client::{
ChatOptions, ChatResponse, ChatbotClient, LlmError, LlmRawMeta, LlmTurn, Message, StreamDelta,
ToolCall, anthropic, http_status, lm_studio, ollama, openai,
};
+83 -423
View File
@@ -1,62 +1,52 @@
//! Context compaction — reduces LLM context size by summarising old messages.
//!
//! # Responsibility
//! [`ContextCompactor`] is a stateless service (all state lives in the DB).
//! It is shared via `Arc` across all [`ChatSessionHandler`]s.
//! [`ContextCompactor`] is Skald's **policy**: when to compact (the token
//! threshold, the ephemeral guard), which model summarises, and telling the
//! rest of the app it happened. The mechanics — split point, transcript,
//! prompt, the summariser call, the saved row — are the library's
//! (`agent_loop::compaction`), so a compaction is the same operation whether
//! Skald or another host triggers it.
//!
//! It is triggered **at the start of a turn** when the previous turn's
//! `input_tokens` exceeds the configured threshold (Opzione C from the design
//! doc), or manually via `force_compact`. Ephemeral sessions (cron, tic)
//! It is a stateless service (all state lives in the DB), shared via `Arc`
//! across every [`ChatSessionHandler`](crate::session::handler). Triggered at
//! the **start of a turn** when the previous turn's `input_tokens` exceeded the
//! threshold, or manually via `force_compact`. Ephemeral sessions (cron, tic)
//! are always skipped.
//!
//! # Compaction flow
//! ```text
//! handle_message()
//! └─► ContextCompactor::try_compact(pool, stack_id, last_input_tokens)
//!
//! ├─ guard: tokens < threshold → return Ok(false)
//! ├─ guard: is_ephemeral → return Ok(false)
//!
//! └─► do_compact(pool, session_id, stack_id, effective_tokens)
//! ├─ load latest summary (if any)
//! load raw messages since last summary boundary
//! │ (or all messages if no prior summary)
//! ├─ split: to_summarise = messages[0 .. len - keep_recent]
//! │ to_keep_raw = messages[len - keep_recent ..]
//! ├─ if to_summarise is empty → return Ok(false)
//! ├─ build compaction prompt (system hard-coded + user = conversation text)
//! ├─ call LLM (no tools, strength-based AUTO selection)
//! ├─ save summary to chat_summaries
//! └─ publish BusEvent::CompactionDone
//!
//! force_compact() skips the threshold guard and calls do_compact() directly.
//! └─► ContextCompactor::try_compact(manager, …, last_input_tokens)
//! ├─ guard: is_ephemeral → Ok(false)
//! ├─ guard: tokens (or estimate) < threshold → Ok(false)
//! └─► manager.new_compaction(conv, frame).run()
//! ├─ split at the keep_recent boundary, on a user/agent message
//! ├─ summarise (one call, no tools)
//! ├─ save the summary row
//! hooks.on_compacted → DTL re-anchor (loop_adapters::hooks)
//! ```
//!
//! # build_openai_messages after compaction
//! ```text
//! latest_summary = chat_summaries::latest_for_stack(pool, stack_id)
//! if let Some(s) = latest_summary:
//! inject <summary>…</summary> after system prompt
//! load messages with id > s.covers_up_to_message_id
//! else:
//! load all messages (current behaviour)
//! apply max_history_messages drain as safety floor (only when compaction is disabled)
//! ```
//! The next turn needs nothing from this: the assembler reads the latest
//! summary from the store and projects it in front of the surviving messages.
use std::sync::Arc;
use agent_loop::compaction::{CompactionMode, should_compact};
use agent_loop::manager::LoopManager;
use agent_loop::model::ModelHint;
use serde_json::json;
use sqlx::SqlitePool;
use tracing::{debug, info, warn};
use tracing::{info, warn};
use core_api::{ConfigProperty, ConfigSet, PropertyType};
use crate::chat_event_bus::{ChatEventBus, CompactionEvent};
use crate::chatbot::ChatOptions;
use crate::config::CompactionConfig;
use crate::config_store::GlobalConfigManager;
use crate::db::{chat_history, chat_llm_tools, chat_summaries};
use crate::db::chat_history;
use crate::llm::LlmManager;
use crate::loop_adapters::history::SqliteHistory;
use crate::loop_adapters::selector::SkaldSelector;
/// Registry `config` key holding the name of the LLM model to use for
/// compaction summaries. Set from the Settings page (instance-wide); empty /
@@ -83,105 +73,14 @@ pub fn config_set() -> ConfigSet {
}
}
// ── Compaction constants (ported from Hermes context_compressor.py) ──────────
//
// SUMMARY_PREFIX — prepended to every stored summary when injected as context.
// Tells the LLM this is historical reference, not live instructions.
// SUMMARIZER_PREAMBLE — system/user-message preamble for the summarisation LLM call.
// SUMMARY_TEMPLATE — structured section template the LLM must follow.
// ── The summariser's wording ─────────────────────────────────────────────────
/// Prefix prepended to the summary content when it is injected into the
/// message array as context for the main agent. Exposed as `pub` so that
/// `build_openai_messages` can use the same wording.
pub const SUMMARY_PREFIX: &str = "\
[CONTEXT COMPACTION REFERENCE ONLY] Earlier turns were compacted \
into the summary below. This is a handoff from a previous context \
window treat it as background reference, NOT as active instructions. \
Do NOT answer questions or fulfill requests mentioned in this summary; \
they were already addressed. \
Your current task is identified in the '## Active Task' section of the \
summary resume exactly from there. \
Your system prompt and any injected memory files are ALWAYS authoritative \
never deprioritize them due to this compaction note. \
Respond ONLY to the latest user message that appears AFTER this summary. \
The current session state (files, config, etc.) may reflect work \
described here avoid repeating it:";
/// Prefix prepended to a stored summary when it is projected back into the
/// context. Re-exported from the library, which owns the wording along with the
/// preamble and the section template: the assembler on the other side of the
/// projection reads the same constant, so the two can never drift.
pub use agent_loop::compaction::SUMMARY_PREFIX;
/// Preamble shared by both first-compaction and iterative-update prompts.
/// Wording is deliberately plain to avoid content-filter false positives.
const SUMMARIZER_PREAMBLE: &str = "\
You are a summarization agent creating a context checkpoint. \
Treat the conversation turns below as source material for a \
compact record of prior work. \
Produce only the structured summary; do not add a greeting, \
preamble, or prefix. \
Write the summary in the same language the user was using in the \
conversation do not translate or switch to English. \
NEVER include API keys, tokens, passwords, secrets, credentials, \
or connection strings in the summary replace any that appear \
with [REDACTED]. Note that the user may have had credentials present, \
but do not preserve their values.";
/// Structured section template the summariser must fill in.
const SUMMARY_TEMPLATE: &str = "\
## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or \
task assignment verbatim the exact words they used. If multiple tasks \
were requested and only some are done, list only the ones NOT yet completed. \
Continuation should pick up exactly here. Example: \
\"User asked: 'Now refactor the auth module to use JWT instead of sessions'\" \
If no outstanding task exists, write \"None.\"]
## Goal
[What the user is trying to accomplish overall]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
## Completed Actions
[Numbered list of concrete actions taken include tool used, target, and outcome.
Format each as: N. ACTION target outcome [tool: name]
Example:
1. READ config.rs:45 found == should be != [tool: read_file]
2. EDIT config.rs:45 changed == to != [tool: write_file]
3. BUILD `cargo build` succeeded, 0 errors [tool: execute_cmd]
Be specific with file paths, commands, line numbers, and results.]
## Active State
[Current working state include:
- Working directory and branch (if applicable)
- Modified/created files with brief note on each
- Build/test status
- Any running processes or servers
- Environment details that matter]
## In Progress
[Work currently underway what was being done when compaction fired]
## Blocked
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
## Key Decisions
[Important technical decisions and WHY they were made]
## Resolved Questions
[Questions the user asked that were ALREADY answered include the answer so it is not repeated]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write \"None.\"]
## Relevant Files
[Files read, modified, or created with brief note on each]
## Remaining Work
[What remains to be done framed as context, not instructions]
## Critical Context
[Any specific values, error messages, configuration details, or data that would \
be lost without explicit preservation. NEVER include API keys, tokens, passwords, \
or credentials write [REDACTED] instead.]
Write only the summary body. Do not include any preamble or prefix.";
// ── Public API ────────────────────────────────────────────────────────────────
@@ -212,6 +111,7 @@ impl ContextCompactor {
/// Returns `true` if a new summary was written, `false` if skipped.
pub async fn try_compact(
&self,
manager: &Arc<LoopManager>,
pool: &SqlitePool,
session_id: i64,
stack_id: i64,
@@ -222,17 +122,12 @@ impl ContextCompactor {
return Ok(false);
}
let effective_tokens = if last_input_tokens > 0 {
last_input_tokens
} else {
let est = chat_history::estimate_tokens_for_stack(pool, stack_id).await?;
debug!(stack_id, estimate = est, "compactor: no usage data, using char estimate");
est
};
if effective_tokens < self.config.threshold_tokens {
// A provider that reported no usage leaves only the character estimate.
let estimated = chat_history::estimate_tokens_for_stack(pool, stack_id).await?;
if !should_compact(Some(last_input_tokens), estimated, self.config.threshold_tokens) {
return Ok(false);
}
let effective_tokens = if last_input_tokens > 0 { last_input_tokens } else { estimated };
info!(
stack_id,
@@ -241,7 +136,7 @@ impl ContextCompactor {
"compactor: threshold exceeded, starting compaction"
);
self.do_compact(pool, session_id, stack_id, effective_tokens).await
self.do_compact(manager, session_id, stack_id, effective_tokens).await
}
/// Force compaction regardless of the token threshold.
@@ -250,6 +145,7 @@ impl ContextCompactor {
/// Returns `true` if a new summary was written, `false` if skipped.
pub async fn force_compact(
&self,
manager: &Arc<LoopManager>,
pool: &SqlitePool,
session_id: i64,
stack_id: i64,
@@ -266,306 +162,70 @@ impl ContextCompactor {
"compactor: manual compaction triggered"
);
self.do_compact(pool, session_id, stack_id, effective_tokens).await
self.do_compact(manager, session_id, stack_id, effective_tokens).await
}
/// Core compaction logic shared by `try_compact` and `force_compact`.
/// Loads messages, splits at the keep_recent boundary, calls the summariser
/// LLM, persists the summary, and publishes a `CompactionDone` event.
/// Runs the library's compaction on the frame with Skald's model policy,
/// then publishes the result on the app's event bus.
///
/// Model: the instance-wide Settings pick (`compaction_model`) wins; empty,
/// unset, or naming a model that no longer exists all degrade to AUTO
/// selection by `compaction.strength` from config.yml.
async fn do_compact(
&self,
pool: &SqlitePool,
manager: &Arc<LoopManager>,
session_id: i64,
stack_id: i64,
effective_tokens: u32,
) -> anyhow::Result<bool> {
let prior_summary = chat_summaries::latest_for_stack(pool, stack_id).await?;
let hint = self.model_hint().await;
let conv = SqliteHistory::conversation(session_id);
let messages = match &prior_summary {
Some(s) => chat_history::for_stack_since(pool, stack_id, s.covers_up_to_message_id).await?,
None => chat_history::for_stack(pool, stack_id).await?,
};
let keep = self.config.keep_recent;
if messages.len() <= keep {
debug!(
stack_id,
messages = messages.len(),
keep,
"compactor: not enough messages to summarise beyond keep_recent, skipping"
);
return Ok(false);
}
let raw_split = messages.len() - keep;
let split = (0..=raw_split)
.rev()
.find(|&i| {
i == 0 || matches!(
messages[i].role,
chat_history::Role::User | chat_history::Role::Agent
)
})
.unwrap_or(0);
if split == 0 {
debug!(stack_id, "compactor: no suitable split point found, skipping");
return Ok(false);
}
let to_summarise = &messages[..split];
let last_covered_id = to_summarise.last().expect("to_summarise is non-empty").id;
let conversation_text = self
.format_for_summary(pool, to_summarise, prior_summary.as_ref().map(|s| s.content.as_str()))
let outcome = manager
.new_compaction(conv, agent_loop::ids::FrameId(stack_id))
.mode(CompactionMode::Auto { keep_tail: self.config.keep_recent })
// Strength is Skald's, captured here (D14): a pin bypasses it.
.selector(Arc::new(SkaldSelector::new(
Arc::clone(&self.llm_manager),
self.config.strength,
)))
.model(hint)
.log(json!({ "session_id": session_id, "stack_id": stack_id }))
.run()
.await?;
// Model for the summary call: the instance-wide Settings pick
// (`compaction_model`) wins; empty/unset falls back to AUTO selection by
// `compaction.strength` from config.yml. A configured model that no
// longer exists (renamed/deleted) degrades to the same AUTO path.
let configured = self.config_store.get(COMPACTION_MODEL_KEY).await
.ok()
.flatten()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let (client_name, llm) = match configured {
Some(name) => match self.llm_manager.resolve(Some(&name), None).await {
Ok(r) => r,
Err(e) => {
warn!(model = %name, error = %e, "compactor: configured compaction model unavailable, falling back to AUTO selection");
self.llm_manager.resolve(None, self.config.strength).await?
}
},
None => self.llm_manager.resolve(None, self.config.strength).await?,
};
info!(
stack_id,
client = %client_name,
messages_covered = to_summarise.len(),
last_covered_id,
"compactor: calling LLM for summary"
);
let messages_payload = vec![
json!({ "role": "user", "content": conversation_text }),
];
let options = ChatOptions {
model: llm.model.clone(),
max_tokens: None,
temperature: Some(0.3),
session_id: Some(session_id),
stack_id: Some(stack_id),
user_id: None,
request_id: None,
};
let turn = llm.client.chat_with_tools(&messages_payload, &[], &options).await
.map_err(|e| {
warn!(stack_id, error = %e, "compactor: LLM call failed");
e
})?;
let summary_text = match turn {
crate::chatbot::LlmTurn::Message(resp) => resp.content,
crate::chatbot::LlmTurn::ToolCalls { content, .. } => {
warn!(stack_id, "compactor: unexpected tool calls in summary response, using content");
content
}
};
if summary_text.trim().is_empty() {
warn!(stack_id, "compactor: LLM returned empty summary, skipping save");
return Ok(false);
}
let summary_id = chat_summaries::save(pool, stack_id, &summary_text, last_covered_id).await?;
// DTL: activations pinned to a message that was just compacted away would
// otherwise lose their render position (the Kimi `system`+`tools` block).
// Re-anchor them onto the first surviving message. Best-effort — a failure
// only means the model may re-activate a tool after compaction.
let first_surviving_id = messages[split].id;
if let Err(e) = crate::db::activated_tools::reanchor_compacted(
pool, stack_id, last_covered_id, first_surviving_id,
).await {
warn!(stack_id, error = %e, "compactor: failed to re-anchor DTL activations");
}
info!(
stack_id,
summary_id,
last_covered_id,
"compactor: summary saved"
);
let Some(outcome) = outcome else { return Ok(false) };
self.event_bus.compaction_done(CompactionEvent {
session_id,
stack_id,
summary_id,
covers_up_to_message_id: last_covered_id,
triggered_by_tokens: effective_tokens,
summary_id: outcome.summary_id.get(),
covers_up_to_message_id: outcome.covered_up_to.get(),
triggered_by_tokens: effective_tokens,
});
Ok(true)
}
// ── Private helpers ───────────────────────────────────────────────────────
/// The summariser's model pin, or `ModelHint::default()` (AUTO) when none is
/// configured or the configured one is gone.
async fn model_hint(&self) -> ModelHint {
let configured = self
.config_store
.get(COMPACTION_MODEL_KEY)
.await
.ok()
.flatten()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let Some(name) = configured else { return ModelHint::default() };
/// Builds the full prompt for the summarisation LLM call (Hermes-style).
///
/// Returns a single string intended to be sent as a `user` message.
/// The preamble, conversation transcript, and structured template are all
/// concatenated, matching how Hermes' `_generate_summary` works.
///
/// * First compaction — `prior_summary` is `None`.
/// * Subsequent compaction — `prior_summary` contains the previous summary body
/// (without `SUMMARY_PREFIX`) so the LLM can produce an updated, non-nested summary.
async fn format_for_summary(
&self,
pool: &SqlitePool,
messages: &[chat_history::ChatMessage],
prior_summary: Option<&str>,
) -> anyhow::Result<String> {
let transcript = self.serialize_for_summary(pool, messages).await?;
let prompt = if let Some(prev) = prior_summary {
format!(
"{SUMMARIZER_PREAMBLE}\n\n\
You are updating a context compaction summary. A previous compaction produced \
the summary below. New conversation turns have occurred since then and need \
to be incorporated.\n\n\
PREVIOUS SUMMARY:\n{prev}\n\n\
NEW TURNS TO INCORPORATE:\n{transcript}\n\n\
Update the summary using this exact structure. PRESERVE all existing information \
that is still relevant. ADD new completed actions to the numbered list (continue \
numbering). Move items from \"In Progress\" to \"Completed Actions\" when done. \
Move answered questions to \"Resolved Questions\". Update \"Active State\" to \
reflect current state. Remove information only if it is clearly obsolete. \
CRITICAL: Update \"## Active Task\" to reflect the user's most recent unfulfilled \
request this is the most important field for task continuity.\n\n\
{SUMMARY_TEMPLATE}"
)
} else {
format!(
"{SUMMARIZER_PREAMBLE}\n\n\
Create a structured checkpoint summary for the conversation after earlier turns \
are compacted. The summary should preserve enough detail for continuity without \
re-reading the original turns.\n\n\
TURNS TO SUMMARIZE:\n{transcript}\n\n\
Use this exact structure:\n\n\
{SUMMARY_TEMPLATE}"
)
};
Ok(prompt)
}
/// Serialises conversation messages into Hermes-style labeled text for the summariser.
///
/// Format:
/// ```text
/// [USER]: text…
///
/// [ASSISTANT]: text…
/// [Tool calls:
/// tool_name(args…)
/// ]
///
/// [TOOL RESULT tc_N]: result…
/// ```
///
/// Long content is truncated with a head+tail strategy (preserving the start and
/// end of the text) rather than a simple prefix cut.
async fn serialize_for_summary(
&self,
pool: &SqlitePool,
messages: &[chat_history::ChatMessage],
) -> anyhow::Result<String> {
let mut parts: Vec<String> = Vec::new();
for msg in messages {
match msg.role {
chat_history::Role::User | chat_history::Role::Agent => {
let content = truncate_head_tail(msg.content.trim(), 6000, 1500);
parts.push(format!("[USER]: {content}"));
}
chat_history::Role::Assistant => {
let mut content = truncate_head_tail(msg.content.trim(), 6000, 1500);
let tool_calls = chat_llm_tools::for_message(pool, msg.id).await?;
if !tool_calls.is_empty() {
let tc_lines: String = tool_calls
.iter()
.map(|tc| {
let args = tc.arguments.as_deref()
.map(|a| truncate(a, 1200))
.unwrap_or_default();
format!(" {}({})", tc.name, args)
})
.collect::<Vec<_>>()
.join("\n");
content.push_str(&format!("\n[Tool calls:\n{tc_lines}\n]"));
}
parts.push(format!("[ASSISTANT]: {content}"));
// Tool results as separate labeled entries — mirrors Hermes'
// `[TOOL RESULT {call_id}]` entries in the serialised transcript.
for tc in &tool_calls {
let result = match tc.status.as_str() {
"done" => tc.result.as_deref()
.map(|r| truncate_head_tail(r, 4000, 1500))
.unwrap_or_default(),
_ => "(failed or interrupted)".to_string(),
};
parts.push(format!("[TOOL RESULT tc_{}]: {result}", tc.id));
}
}
match self.llm_manager.resolve(Some(&name), None).await {
Ok((resolved, _)) => ModelHint::name(resolved),
Err(e) => {
warn!(model = %name, error = %e,
"compactor: configured compaction model unavailable, falling back to AUTO");
ModelHint::default()
}
}
Ok(parts.join("\n\n"))
}
}
/// Truncate a string to at most `max_chars`, appending "…" if truncated.
fn truncate(s: &str, max_chars: usize) -> String {
let s = s.trim();
if s.chars().count() <= max_chars {
s.to_string()
} else {
let end = s.char_indices()
.nth(max_chars)
.map(|(i, _)| i)
.unwrap_or(s.len());
format!("{}", &s[..end])
}
}
/// Keep the first `head_chars` and last `tail_chars` of a string, inserting
/// `\n...[truncated]...\n` in the middle when the string is longer than their sum.
///
/// Mirrors Hermes' `_CONTENT_HEAD` + `_CONTENT_TAIL` strategy so the summariser
/// always sees both the beginning context and the ending result of verbose outputs.
fn truncate_head_tail(s: &str, head_chars: usize, tail_chars: usize) -> String {
let s = s.trim();
let char_count = s.chars().count();
let total = head_chars + tail_chars;
if char_count <= total {
return s.to_string();
}
let head_end = s.char_indices()
.nth(head_chars)
.map(|(i, _)| i)
.unwrap_or(s.len());
let tail_start = s.char_indices()
.nth(char_count - tail_chars)
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}\n...[truncated]...\n{}", &s[..head_end], &s[tail_start..])
}
+24 -56
View File
@@ -413,7 +413,7 @@ async fn run_job(
});
// Drain events concurrently. rx closes when the last tx clone is dropped,
// which happens only after resume_turn() completes the full sub-agent chain.
// which happens only after the turn completes the full sub-agent chain.
while let Some(_) = rx.recv().await {}
let handle_result = jh.await
@@ -465,7 +465,7 @@ async fn run_job(
if let Some(parent_id) = job.parent_session_id {
if let Some(hub) = hub {
inject_async_result(
pool,
&task_mgr.pool,
hub,
parent_id,
job.id,
@@ -512,69 +512,37 @@ async fn run_job(
}
}
/// Injects an async task result into the parent session using the same pattern as
/// the notification system: writes a synthetic assistant message + completed
/// `task_completed` tool call directly to the DB, then calls `hub.resume()` so
/// the parent LLM wakes up and events are properly bridged to the WebSocket.
/// Delivers an async task's result to the parent session through the loop's
/// [`AsyncResultSink`] seam (blueprint §7.2): the library writes the synthetic
/// assistant message + completed `task_completed` call, and Skald's
/// [`DurableSink`] resumes the parent so the model reads it right away.
///
/// Failures are logged, never propagated: the job itself succeeded, and losing
/// the delivery must not mark it failed.
async fn inject_async_result(
pool: &SqlitePool,
pool: &Arc<SqlitePool>,
hub: &Arc<ChatHub>,
parent_session_id: i64,
task_id: i64,
task_title: &str,
result: &str,
) {
// Resolve source_id from the parent session row.
let source_id = match crate::db::chat_sessions::find_by_id(pool, parent_session_id).await {
Ok(Some(s)) => s.source,
Ok(None) => { error!("inject_async_result: session {parent_session_id} not found"); return; }
Err(e) => { error!("inject_async_result: DB error: {e}"); return; }
};
use agent_loop::delegate::{AsyncResultSink, CompletedTask};
use crate::loop_adapters::async_task::DurableSink;
use crate::loop_adapters::history::SqliteHistory;
// Get the active stack for the parent session.
let stack = match crate::db::chat_sessions_stack::active_for_session(pool, parent_session_id).await {
Ok(Some(s)) => s,
Ok(None) => { error!("inject_async_result: no active stack for session {parent_session_id}"); return; }
Err(e) => { error!("inject_async_result: stack lookup failed: {e}"); return; }
};
info!(parent_session_id, task_id, task_title, "delivering async task result");
// Write a synthetic assistant message (reasoning trace).
let reasoning = format!(
"The system is notifying me that async task #{task_id} ('{}') has completed. \
Let me process the result via task_completed.",
task_title,
);
let assistant_id = match crate::db::chat_history::append(
pool, stack.id, &crate::db::chat_history::Role::Assistant,
"", true, Some(&reasoning),
).await {
Ok(id) => id,
Err(e) => { error!("inject_async_result: append assistant failed: {e}"); return; }
};
// Write the completed task_completed tool call with the result payload.
let result_json = serde_json::to_string(&serde_json::json!({
"task_id": task_id,
"title": task_title,
"result": result,
})).unwrap_or_else(|_| "{}".to_string());
let tool_call_id = match crate::db::chat_llm_tools::append(
pool, assistant_id, "task_completed",
&serde_json::json!({"task_id": task_id}).to_string(),
).await {
Ok(id) => id,
Err(e) => { error!("inject_async_result: append tool call failed: {e}"); return; }
};
if let Err(e) = crate::db::chat_llm_tools::complete(pool, tool_call_id, &result_json, "string").await {
error!("inject_async_result: complete tool call failed: {e}"); return;
}
info!(parent_session_id, task_id, task_title, "inject_async_result: resuming parent session");
if let Err(e) = hub.resume(&source_id).await {
error!("inject_async_result: hub.resume failed: {e}");
let sink = DurableSink::new(Arc::clone(pool), Arc::clone(hub));
let delivered = sink
.deliver(SqliteHistory::conversation(parent_session_id), CompletedTask {
id: agent_loop::ids::TaskId(task_id),
title: task_title.to_string(),
result: result.to_string(),
})
.await;
if let Err(e) = delivered {
error!(parent_session_id, task_id, "async result delivery failed: {e}");
}
}
+1 -1
View File
@@ -192,7 +192,7 @@ pub async fn for_stack_all(
}
/// Ok messages for a stack frame whose id is strictly greater than `after_id`,
/// ordered chronologically. Used by `build_openai_messages` when a compaction
/// ordered chronologically. Used by the projection when a compaction
/// summary exists: only the "raw" messages after the summary boundary are loaded.
pub async fn for_stack_since(
pool: &SqlitePool,
+1 -1
View File
@@ -12,7 +12,7 @@ pub struct ChatSummary {
pub stack_id: i64,
pub content: String,
/// All chat_history rows with `id <= covers_up_to_message_id` are covered
/// by this summary. `build_openai_messages` loads only rows *after* this id.
/// by this summary. The projection loads only rows *after* this id.
pub covers_up_to_message_id: i64,
pub created_at: String,
}
+1 -1
View File
@@ -1,7 +1,7 @@
//! DB operations for the `llm_requests` table (metadata only).
//!
//! Every `chat_with_tools` call is logged here by the
//! [`crate::chatbot::logging::LoggingChatbotClient`] wrapper.
//! [`crate::llm::logging::LoggingModel`] decorator.
//! Payloads (request/response bodies + headers) live in `llm_request_payloads`
//! in the owner bucket (`{userid}.db`), correlated by `request_id`.
//! Rows are retained for `llm.request_log.retention_days` days (default 14).
+1 -1
View File
@@ -14,7 +14,6 @@ pub mod agents;
pub mod approval;
pub mod chat_event_bus;
pub mod chat_hub;
pub mod chatbot;
pub mod clarification;
pub mod command;
pub mod compactor;
@@ -30,6 +29,7 @@ pub mod inbox;
pub mod latex;
pub mod llm;
pub mod location;
pub mod loop_adapters;
pub mod memory;
pub mod mcp;
pub mod notification;
+113
View File
@@ -0,0 +1,113 @@
//! Transparent logging decorator for any [`agent_loop::model::Model`].
//!
//! [`LoggingModel`] intercepts every `complete` call, measures the duration,
//! and persists a **metadata-only** row to `llm_requests` in `system.db`
//! (fire-and-forget). Per-request correlation (session/stack/user id) travels
//! in [`ModelRequest::log`], set by the caller; the payload (request/response
//! bodies) is returned to the caller inside [`ModelResponse::raw`] /
//! [`ModelError::raw`] so it can be written to the user's own database.
//!
//! The split keeps conversation content (payloads) behind the user key while
//! metadata (cost, tokens, timing) stays in the admin-readable registry.
//! (Successor of `chatbot::logging::LoggingChatbotClient`, blueprint D13.)
use std::sync::Arc;
use std::time::Instant;
use async_trait::async_trait;
use sqlx::SqlitePool;
use tokio::sync::mpsc;
use tracing::warn;
use agent_loop::model::{Model, ModelError, ModelRequest, ModelResponse, StreamDelta};
use crate::db::llm_requests;
pub struct LoggingModel {
inner: Arc<dyn Model>,
pool: Arc<SqlitePool>,
model_name: String,
}
impl LoggingModel {
pub fn new(inner: Arc<dyn Model>, pool: Arc<SqlitePool>, model_name: impl Into<String>) -> Self {
Self { inner, pool, model_name: model_name.into() }
}
}
#[async_trait]
impl Model for LoggingModel {
async fn complete(
&self,
req: &ModelRequest,
deltas: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
let start = Instant::now();
let result = self.inner.complete(req, deltas).await;
let duration_ms = start.elapsed().as_millis() as i64;
// Per-request correlation set by the caller (llm_call / compactor).
let log = req.log.clone().unwrap_or_default();
let session_id = log["session_id"].as_i64();
let stack_id = log["stack_id"].as_i64();
let user_id = log["user_id"].as_str().map(str::to_string);
let request_id = Some(req.request_id.clone());
let model_name = self.model_name.clone();
let pool = Arc::clone(&self.pool);
match &result {
Ok(resp) => {
let usage = resp.usage();
let (input_tokens, output_tokens, cache_read, cache_write) = (
usage.input_tokens.map(|n| n as i64),
usage.output_tokens.map(|n| n as i64),
usage.cache_read.map(|n| n as i64),
usage.cache_write.map(|n| n as i64),
);
tokio::spawn(async move {
if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id,
stack_id,
model_name,
error_text: None,
input_tokens,
output_tokens,
duration_ms,
cache_read_tokens: cache_read,
cache_creation_tokens: cache_write,
}).await {
warn!(error = %e, "llm_requests: failed to insert log row");
}
});
}
Err(e) => {
let error_text = e.to_string();
tokio::spawn(async move {
if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id,
stack_id,
model_name,
error_text: Some(error_text),
input_tokens: None,
output_tokens: None,
duration_ms,
cache_read_tokens: None,
cache_creation_tokens: None,
}).await {
warn!(error = %log_err, "llm_requests: failed to insert error log row");
}
});
}
}
result
}
fn is_retriable(&self, err: &ModelError) -> bool {
self.inner.is_retriable(err)
}
}
+4 -4
View File
@@ -8,11 +8,11 @@ use sqlx::SqlitePool;
use tokio::sync::RwLock;
use tracing::{info, warn};
use crate::chatbot::ChatbotClient;
use crate::chatbot::logging::LoggingChatbotClient;
use agent_loop::model::Model;
use core_api::provider::LlmStrength;
use crate::provider::{ApiProvider, ProviderRegistry, ReasoningMode};
use super::logging::LoggingModel;
use super::providers::RemoteLlmModelInfo;
use super::{ClientStatus, LlmEntry, LlmModelInfo, LlmModelRecord, LlmProviderInfo, LlmProviderRecord};
use super::db;
@@ -512,8 +512,8 @@ fn build_entry(
let prompt_cache = built.prompt_cache;
let extra = model.extra_params.clone();
let client: Arc<dyn ChatbotClient> = match log_pool {
Some(pool) => Arc::new(LoggingChatbotClient::new(inner, pool, &model.name)),
let client: Arc<dyn Model> = match log_pool {
Some(pool) => Arc::new(LoggingModel::new(inner, pool, &model.name)),
None => inner,
};
+4 -2
View File
@@ -1,10 +1,12 @@
pub(crate) mod db;
pub mod logging;
pub mod manager;
pub mod providers;
use std::sync::Arc;
use crate::chatbot::ChatbotClient;
use agent_loop::model::Model;
use crate::provider::ServiceType;
pub use core_api::provider::{LlmProviderRecord, LlmModelRecord, LlmStrength, ReasoningMode};
@@ -13,7 +15,7 @@ pub use manager::{LlmManager, sort_models_for_agent};
/// A resolved, ready-to-use LLM client with its associated metadata.
#[derive(Clone)]
pub struct LlmEntry {
pub client: Arc<dyn ChatbotClient>,
pub client: Arc<dyn Model>,
pub model: String,
pub model_db_id: i64,
pub strength: Option<LlmStrength>,
@@ -2,7 +2,7 @@ use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use crate::chatbot::anthropic::AnthropicClient;
use agent_loop::models::AnthropicModel;
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
@@ -111,7 +111,7 @@ impl ApiProvider for AnthropicProvider {
// stays uncached, as before.
let prompt_cache = model.capabilities.iter().any(|c| c == "tool_search");
Ok(BuiltLlmClient {
client: Arc::new(AnthropicClient::with_extra_body(key, extra)),
client: Arc::new(AnthropicModel::with_extra_body(key, model.model_id.clone(), extra)),
prompt_cache,
})
})())
@@ -18,7 +18,7 @@ use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use tracing::{info, warn};
use crate::chatbot::openai::OpenAiClient;
use agent_loop::models::OpenAiModel;
use crate::llm::providers::{extra_with_reasoning, RemoteLlmModelInfo};
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::provider::{
@@ -575,7 +575,7 @@ impl ApiProvider for DeclaredProvider {
let extra = extra_with_reasoning(self, model);
let prompt_cache = self.spec.prompt_cache;
Ok(BuiltLlmClient {
client: Arc::new(OpenAiClient::new(self.base_url(record), key, extra, prompt_cache)),
client: Arc::new(OpenAiModel::with_options(self.base_url(record), key, model.model_id.clone(), extra, prompt_cache)),
prompt_cache,
})
})())
+3 -3
View File
@@ -15,7 +15,7 @@ use anyhow::{anyhow, Context, Result};
use core_api::provider::{ApiProvider, BuiltLlmClient, LlmModelRecord, LlmProviderRecord};
use crate::chatbot::openai::OpenAiClient;
use agent_loop::models::OpenAiModel;
/// Computes the `extra_params` an OpenAI-compatible client should be built with,
/// given a model's stored `extra_params` and its selected reasoning value. The
@@ -75,7 +75,7 @@ pub(crate) async fn fetch_openai_models(
.ok_or_else(|| anyhow!("unexpected {who} response shape"))
}
/// Builds an `OpenAiClient` for an OpenAI-compatible provider: requires the
/// Builds an `OpenAiModel` for an OpenAI-compatible provider: requires the
/// provider record's `api_key` and merges the model's stored `extra_params`
/// with the provider-translated reasoning fragment (see `extra_with_reasoning`).
pub(crate) fn build_openai_llm(
@@ -89,7 +89,7 @@ pub(crate) fn build_openai_llm(
.with_context(|| format!("provider '{}': api_key required for {}", record.name, provider.type_id()))?;
let extra = extra_with_reasoning(provider, model);
Ok(BuiltLlmClient {
client: Arc::new(OpenAiClient::new(base_url, key, extra, prompt_cache)),
client: Arc::new(OpenAiModel::with_options(base_url, key, model.model_id.clone(), extra, prompt_cache)),
prompt_cache,
})
}
@@ -2,7 +2,7 @@ use std::sync::Arc;
use anyhow::{Result, anyhow};
use crate::chatbot::ollama::OllamaClient;
use agent_loop::models::OllamaModel;
use crate::llm::{LlmModelRecord, LlmProviderRecord};
use crate::llm::providers::RemoteLlmModelInfo;
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ServiceType};
@@ -101,9 +101,9 @@ impl ApiProvider for OllamaProvider {
Ok(Some(Self::parse_model_info(&resp, model_id)))
}
fn build_llm(&self, record: &LlmProviderRecord, _model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
Some(Ok(BuiltLlmClient {
client: Arc::new(OllamaClient::new(record.base_url.as_deref())),
client: Arc::new(OllamaModel::new(record.base_url.as_deref(), model.model_id.clone())),
prompt_cache: false,
}))
}
@@ -0,0 +1,315 @@
//! DTL activation adapters (blueprint D15): the crate owns the wire protocol,
//! Skald owns the catalog (MCP servers + the reserved `config` group) and the
//! persistence (`activated_tools`, anchored at the triggering message).
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use agent_loop::activation::{Activation, ActivationSource, ToolActivator};
use agent_loop::ids::{FrameId, MessageId};
use agent_loop::tool::{ToolCtx, ToolFailure};
use serde_json::Value;
use sqlx::SqlitePool;
use crate::db::{activated_tools, chat_llm_tools};
use crate::mcp::McpProvider;
use crate::tools::tool_names::CONFIG_GROUP;
// ── ActivationSource ─────────────────────────────────────────────────────────
/// Reads the durable activations of one scope (root session or sub-agent
/// frame) and resolves them to OpenAI tool defs for the assembler's DTL
/// injection: which tool definitions an activation resolves to.
pub struct SkaldActivationSource {
pool: Arc<SqlitePool>,
mcp: Arc<dyn McpProvider>,
config_defs: Arc<Vec<Value>>,
session_id: i64,
/// `None` = root (session scope); `Some(stack_id)` = sub-agent frame.
stack: Option<i64>,
}
impl SkaldActivationSource {
pub fn new(
pool: Arc<SqlitePool>,
mcp: Arc<dyn McpProvider>,
config_defs: Arc<Vec<Value>>,
session_id: i64,
stack: Option<i64>,
) -> Self {
Self { pool, mcp, config_defs, session_id, stack }
}
}
#[agent_loop::async_trait]
impl ActivationSource for SkaldActivationSource {
async fn activations(&self, _frame: FrameId) -> agent_loop::Result<Vec<Activation>> {
let rows = activated_tools::list_active_at(&self.pool, self.session_id, self.stack, i64::MAX).await?;
// Group by anchor, dedup tool names per anchor (a server may reappear).
let mut out: Vec<Activation> = Vec::new();
for row in rows {
let defs: Vec<Value> = if row.kind == "builtin" && row.ref_ == CONFIG_GROUP {
self.config_defs.as_ref().clone()
} else {
self.mcp
.tools_for(std::slice::from_ref(&row.ref_))
.iter()
.map(|t| t.to_openai_definition())
.collect()
};
let anchor = MessageId(row.message_id);
match out.iter_mut().find(|a| a.anchor == anchor) {
Some(existing) => {
for d in defs {
let name = d["function"]["name"].as_str().unwrap_or("");
if !existing.defs.iter().any(|e| e["function"]["name"].as_str() == Some(name)) {
existing.defs.push(d);
}
}
}
None => out.push(Activation { anchor, defs }),
}
}
Ok(out)
}
}
// ── ToolActivator ────────────────────────────────────────────────────────────
/// Backend of the crate's shipped `activate_tools` tool: validates the groups
/// against the catalog, updates the in-memory grant set **immediately** (next
/// round sees the tools), and persists the activation anchored at the
/// triggering assistant message (derived from the call's `chat_llm_tools`
/// row). Unifies what today lives split between `tools/activate_tools.rs`
/// (grants) and `llm_loop.rs` (persistence).
pub struct SkaldToolActivator {
pool: Arc<SqlitePool>,
mcp: Arc<dyn McpProvider>,
grants: Arc<RwLock<HashSet<String>>>,
session_id: i64,
stack: Option<i64>,
}
impl SkaldToolActivator {
pub fn new(
pool: Arc<SqlitePool>,
mcp: Arc<dyn McpProvider>,
grants: Arc<RwLock<HashSet<String>>>,
session_id: i64,
stack: Option<i64>,
) -> Self {
Self { pool, mcp, grants, session_id, stack }
}
}
#[agent_loop::async_trait]
impl ToolActivator for SkaldToolActivator {
async fn activate(&self, groups: Vec<String>, ctx: &ToolCtx) -> Result<String, ToolFailure> {
if groups.is_empty() {
return Err(ToolFailure::Failed("activate_tools: `groups` is empty".into()));
}
let available: HashSet<String> = self.mcp.tools().iter().map(|t| t.server_name.clone()).collect();
// Immediate in-memory effect (the defs re-read at the next round picks
// the new grants up for free).
{
let mut set = self.grants.write().map_err(|_| ToolFailure::Failed("activate_tools: lock poisoned".into()))?;
for g in &groups {
set.insert(g.clone());
}
}
// Durable effect, anchored at the triggering assistant message. The
// anchor is derived from the call row — the crate's ToolCtx carries
// the call id, the message id is one lookup away.
let call = chat_llm_tools::get(&self.pool, ctx.call_id.get())
.await
.map_err(|e| ToolFailure::Failed(format!("activate_tools: anchor lookup failed: {e}")))?
.ok_or_else(|| ToolFailure::Failed("activate_tools: call row not found".into()))?;
for g in &groups {
let kind = if g == CONFIG_GROUP { "builtin" } else { "mcp" };
activated_tools::grant(&self.pool, self.session_id, self.stack, call.message_id, kind, g)
.await
.map_err(|e| ToolFailure::Failed(format!("activate_tools: grant failed: {e}")))?;
}
let activated: Vec<String> = groups
.iter()
.map(|n| {
if n == CONFIG_GROUP || available.contains(n) {
format!("{n}")
} else {
format!("{n} (registered but not yet running — tools will appear after reconnect)")
}
})
.collect();
let scope = match self.stack {
None => "session".to_string(),
Some(s) => format!("stack {s}"),
};
Ok(format!(
"Tool groups activated for this {scope}: {}. \
Their tools are available from the next tool-call round.",
activated.join(", ")
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use agent_loop::store::HistoryStore;
use agent_loop::tool::ToolOutput;
use mcp_client::McpTool;
use crate::db::{chat_history, chat_sessions_stack};
use crate::loop_adapters::history::SqliteHistory;
use crate::tools::ToolResult;
struct FakeMcp {
tools: Vec<McpTool>,
}
impl FakeMcp {
fn with_server(name: &str, tool_names: &[&str]) -> Self {
Self {
tools: tool_names
.iter()
.map(|t| McpTool {
server_name: name.to_string(),
name: t.to_string(),
description: String::new(),
input_schema: serde_json::json!({"type":"object"}),
title: None,
output_schema: None,
annotations: None,
task_support: None,
})
.collect(),
}
}
}
#[async_trait::async_trait]
impl McpProvider for FakeMcp {
fn tools(&self) -> Vec<McpTool> { self.tools.clone() }
fn tools_for(&self, names: &[String]) -> Vec<McpTool> {
self.tools.iter().filter(|t| names.contains(&t.server_name)).cloned().collect()
}
fn server_descriptions(&self) -> HashMap<String, Option<String>> { HashMap::new() }
fn server_infos(&self) -> Vec<Value> { Vec::new() }
fn tool_display_name(&self, _server: &str, _tool: &str) -> Option<String> { None }
async fn call(&self, _server: &str, _tool: &str, _args: Value) -> anyhow::Result<ToolResult> {
unimplemented!()
}
}
fn temp_db_path(tag: &str) -> String {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
p.to_string_lossy().into_owned()
}
fn cleanup(path: &str) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{path}{suffix}"));
}
}
struct Fixture {
pool: Arc<SqlitePool>,
frame: FrameId,
msg: MessageId,
call: agent_loop::ids::ToolCallId,
path: String,
}
async fn fixture(tag: &str) -> Fixture {
let path = temp_db_path(tag);
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
sqlx::query("INSERT INTO chat_sessions (id) VALUES (1)").execute(&*pool).await.unwrap();
let frame_row = chat_sessions_stack::create(&pool, 1, "assistant", None, 0, None).await.unwrap();
let msg = chat_history::append(&pool, frame_row.id, &chat_history::Role::Assistant, "activating", false, None)
.await
.unwrap();
let call = chat_llm_tools::append(&pool, msg, "activate_tools", "{}").await.unwrap();
Fixture {
pool,
frame: FrameId(frame_row.id),
msg: MessageId(msg),
call: agent_loop::ids::ToolCallId(call),
path,
}
}
#[tokio::test]
async fn activate_grants_in_memory_and_persists_anchored() {
let f = fixture("act-grant").await;
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("gmail", &["send", "read"]));
let grants = Arc::new(RwLock::new(HashSet::new()));
let activator = SkaldToolActivator::new(f.pool.clone(), mcp, grants.clone(), 1, None);
let ctx = ToolCtx {
conversation: agent_loop::ids::ConversationId::new("session:1"),
frame: f.frame,
agent: "assistant".into(),
call_id: f.call,
cancel: tokio_util::sync::CancellationToken::new(),
extensions: Default::default(),
};
let text = activator.activate(vec!["gmail".into(), CONFIG_GROUP.into()], &ctx).await.unwrap();
assert!(text.contains("gmail ✓"));
// In-memory effect.
assert!(grants.read().unwrap().contains("gmail"));
assert!(grants.read().unwrap().contains(CONFIG_GROUP));
// Durable effect, anchored at the assistant message.
let refs = activated_tools::list_refs_session(&f.pool, 1).await.unwrap();
assert_eq!(refs.len(), 2);
let acts = activated_tools::list_active_at(&f.pool, 1, None, i64::MAX).await.unwrap();
assert!(acts.iter().all(|a| a.message_id == f.msg.get()));
f.pool.close().await;
cleanup(&f.path);
}
#[tokio::test]
async fn activation_source_resolves_defs_per_anchor() {
let f = fixture("act-src").await;
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("gmail", &["send", "read"]));
activated_tools::grant(&f.pool, 1, None, f.msg.get(), "mcp", "gmail").await.unwrap();
activated_tools::grant(&f.pool, 1, None, f.msg.get(), "builtin", CONFIG_GROUP).await.unwrap();
let config_defs = Arc::new(vec![serde_json::json!({
"type":"function","function":{"name":"cron_list","parameters":{"type":"object"}}
})]);
let src = SkaldActivationSource::new(f.pool.clone(), mcp, config_defs, 1, None);
let acts = src.activations(f.frame).await.unwrap();
assert_eq!(acts.len(), 1, "same anchor → one merged entry");
let names: Vec<&str> = acts[0]
.defs
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect();
assert!(names.contains(&"send") || names.iter().any(|n| n.contains("send")), "{names:?}");
assert!(names.contains(&"cron_list"), "{names:?}");
// The SqliteHistory + LinearAssembler path agrees on the anchor type.
let store = SqliteHistory::new(f.pool.clone());
let history = store.load(f.frame).await.unwrap();
assert_eq!(history[0].id, f.msg);
let _ = ToolOutput::Text("unused".into());
f.pool.close().await;
cleanup(&f.path);
}
}
@@ -0,0 +1,128 @@
//! Skald's async delegation seam (blueprint §7.2) — `execute_task mode=async`.
//!
//! The library defines *what* an out-of-band task is ([`AsyncExecutor`] submits
//! it, [`AsyncResultSink`] delivers its result); this says *how* Skald runs one:
//!
//! - [`CronExecutor`] — a row in `scheduled_jobs`, run by the cron machinery.
//! Durable by construction: the row survives a restart and `recover_interrupted`
//! re-runs a job that was in flight when the process died. That is the whole
//! reason Skald does not use the crate's `InProcessExecutor`, which is lossy.
//! - [`DurableSink`] — the crate's store write plus Skald's wake-up: the result
//! is history the instant it lands, and the parent session is resumed so the
//! model actually reads it.
//!
//! The `TaskManager` arrives late (it needs a `ChatSessionManager`, which builds
//! the loop runtime — the same cycle `ChatHub` resolves with its own
//! `OnceLock`), so the executor is constructed empty and filled in at wiring
//! time. Submitting before that is a wiring bug and says so.
use std::sync::{Arc, OnceLock};
use agent_loop::delegate::{
AsyncExecutor, AsyncResultSink, AsyncSpec, CompletedTask, StoreSink, TaskHandle,
};
use agent_loop::ids::{ConversationId, TaskId};
use agent_loop::store::HistoryStore;
use sqlx::SqlitePool;
use crate::chat_hub::ChatHub;
use crate::cron::TaskManager;
use crate::loop_adapters::history::SqliteHistory;
use crate::loop_adapters::scope::TurnScope;
// ── CronExecutor ─────────────────────────────────────────────────────────────
/// Runs a delegated task as a `scheduled_jobs` row of kind `async`.
pub struct CronExecutor {
tasks: OnceLock<Arc<TaskManager>>,
}
impl CronExecutor {
pub fn new() -> Self {
Self { tasks: OnceLock::new() }
}
/// Called once at wiring time (see the module docs). A second call is
/// ignored — the first manager is the one the user's jobs belong to.
pub fn set_task_manager(&self, tasks: Arc<TaskManager>) {
let _ = self.tasks.set(tasks);
}
}
impl Default for CronExecutor {
fn default() -> Self {
Self::new()
}
}
#[agent_loop::async_trait]
impl AsyncExecutor for CronExecutor {
async fn submit(&self, spec: AsyncSpec) -> agent_loop::Result<TaskHandle> {
let tasks = self
.tasks
.get()
.ok_or_else(|| anyhow::anyhow!("async tasks are not available in this session"))?;
let session_id = SqliteHistory::session_id(&spec.conversation)?;
// The child inherits the parent's run context (security group, project
// root): a background task must not run with more reach than the turn
// that asked for it.
let run_context = match TurnScope::from(&spec.extensions) {
Some(scope) => scope.run_context.read().await.as_ref().map(|rc| rc.to_db()),
None => None,
};
let title = spec
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| format!("{} task", spec.agent));
let description = spec.description.clone().unwrap_or_default();
let job = tasks.add_job_async(
&title,
&description,
&spec.prompt,
&spec.agent,
session_id,
run_context.as_deref(),
)?;
Ok(TaskHandle { id: TaskId(job.id), title: job.title })
}
}
// ── DurableSink ──────────────────────────────────────────────────────────────
/// Delivers a finished task into its parent conversation: the crate writes the
/// synthetic assistant message + completed call, then the parent session is
/// resumed so the model reads the result now rather than on its next message.
///
/// `ChatHub::resume` skips a session with a turn already in flight, which is the
/// right rule here too: a live loop reads the store each round and picks the
/// result up on its own.
pub struct DurableSink {
inner: StoreSink,
pool: Arc<SqlitePool>,
hub: Arc<ChatHub>,
}
impl DurableSink {
pub fn new(pool: Arc<SqlitePool>, hub: Arc<ChatHub>) -> Self {
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
Self { inner: StoreSink::new(store), pool, hub }
}
}
#[agent_loop::async_trait]
impl AsyncResultSink for DurableSink {
async fn deliver(&self, parent: ConversationId, task: CompletedTask) -> agent_loop::Result<()> {
self.inner.deliver(parent.clone(), task).await?;
let session_id = SqliteHistory::session_id(&parent)?;
let source = crate::db::chat_sessions::find_by_id(&self.pool, session_id)
.await?
.map(|s| s.source)
.ok_or_else(|| anyhow::anyhow!("deliver: session {session_id} not found"))?;
self.hub.resume(&source).await
}
}
@@ -0,0 +1,279 @@
//! Skald's side of the crate's built-in tools: the `HumanChannel`
//! (clarification manager + interactive `AgentQuestion`), scratchpad/todos
//! tools, and the legacy-name aliases (`execute_task` sync/async composition,
//! `ask_user_clarification`, interface tools).
use std::sync::Arc;
use agent_loop::async_trait;
use agent_loop::delegate::DelegateTool;
use agent_loop::events::{EventSink, LoopEvent};
use agent_loop::human::{HumanChannel, HumanGone, Question};
use agent_loop::tool::{Tool, ToolCtx, ToolFailure, ToolOutput};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::clarification::ClarificationManager;
use core_api::interface_tool::ToolFuture;
// ── SkaldHumanChannel ────────────────────────────────────────────────────────
/// The `ask_user` backend: registers in `ClarificationManager` (so the
/// question lands in the Inbox for EVERY session kind) and, for interactive
/// sessions, also emits `AgentQuestion` inline in the chat (via
/// `LoopEvent::Host`). Port of `dispatch_ask_user_clarification`.
pub struct SkaldHumanChannel {
clarification: Arc<ClarificationManager>,
session_id: i64,
agent_id: String,
source: String,
is_interactive: bool,
context_label: Arc<std::sync::RwLock<Option<String>>>,
}
impl SkaldHumanChannel {
pub fn new(
clarification: Arc<ClarificationManager>,
session_id: i64,
agent_id: impl Into<String>,
source: impl Into<String>,
is_interactive: bool,
context_label: Arc<std::sync::RwLock<Option<String>>>,
) -> Self {
Self {
clarification,
session_id,
agent_id: agent_id.into(),
source: source.into(),
is_interactive,
context_label,
}
}
}
#[async_trait]
impl HumanChannel for SkaldHumanChannel {
async fn ask(&self, q: Question, events: &EventSink) -> Result<String, HumanGone> {
let label = self.context_label.read().ok().and_then(|g| g.clone());
let (request_id, rx) = self
.clarification
.register(
self.session_id,
&self.agent_id,
&self.source,
label.as_deref(),
&q.title,
&q.question,
q.suggested.clone(),
)
.await;
if self.is_interactive {
events.emit(q.frame, None, LoopEvent::Host(json!({
"type": "agent_question",
"request_id": request_id,
"tool_call_id": q.call.get(),
"title": q.title,
"question": q.question,
"suggested_answers": q.suggested,
})));
}
// The answer arrives via WS (resolve_question) or the Inbox REST. A
// session-wide cancel (WS drop) closes the channel → HumanGone → the
// tool suspends and the call stays pending for resume.
rx.await.map_err(|_| HumanGone)
}
}
// ── UpdateScratchpadTool ─────────────────────────────────────────────────────
/// The session-scoped shared blackboard (port of `dispatch_update_scratchpad`).
pub struct UpdateScratchpadTool {
pool: Arc<SqlitePool>,
sid: i64,
}
impl UpdateScratchpadTool {
pub fn new(pool: Arc<SqlitePool>, sid: i64) -> Self { Self { pool, sid } }
}
#[async_trait]
impl Tool for UpdateScratchpadTool {
fn name(&self) -> &str { crate::tools::tool_names::UPDATE_SCRATCHPAD }
fn definition(&self) -> Value {
crate::session::handler::update_scratchpad_tool_def()
}
async fn call(&self, args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
let key = args["key"].as_str().unwrap_or("").to_string();
let value = args["value"].as_str().unwrap_or("").to_string();
crate::db::scratchpad::upsert(&self.pool, self.sid, &key, &value)
.await
.map(|_| ToolOutput::Text(format!("Scratchpad updated: {key}")))
.map_err(|e| ToolFailure::Failed(e.to_string()))
}
}
// ── WriteTodosTool ───────────────────────────────────────────────────────────
/// Stateless checklist echo (port of `dispatch_write_todos`).
pub struct WriteTodosTool;
#[async_trait]
impl Tool for WriteTodosTool {
fn name(&self) -> &str { crate::tools::tool_names::WRITE_TODOS }
fn definition(&self) -> Value {
crate::session::handler::write_todos_tool_def()
}
async fn call(&self, args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
let items = args["todos"].as_array().ok_or_else(|| {
ToolFailure::Failed("`write_todos` requires a `todos` array. Re-send the full list, e.g. [{\"content\":\"...\",\"status\":\"pending\"}].".into())
})?;
if items.is_empty() {
return Err(ToolFailure::Failed("`todos` is empty — send at least one item, or omit the call entirely.".into()));
}
let mut lines = Vec::with_capacity(items.len());
let (mut done, mut active, mut pending) = (0usize, 0usize, 0usize);
for item in items {
let content = item["content"].as_str().unwrap_or("").trim();
if content.is_empty() {
continue;
}
let marker = match item["status"].as_str() {
Some("completed") => { done += 1; "x" }
Some("in_progress") => { active += 1; "~" }
_ => { pending += 1; " " }
};
lines.push(format!("[{marker}] {content}"));
}
if lines.is_empty() {
return Err(ToolFailure::Failed("No valid todo items (every `content` was empty).".into()));
}
Ok(ToolOutput::Text(format!(
"Todo list ({total}): {done} done, {active} in progress, {pending} pending\n{body}",
total = lines.len(),
body = lines.join("\n"),
)))
}
}
// ── SkaldAskUserTool ─────────────────────────────────────────────────────────
/// The legacy `ask_user_clarification`: the crate's `AskUserTool` mechanics
/// (AwaitingHuman + Suspend) with Skald's exact legacy definition.
pub struct SkaldAskUserTool {
inner: agent_loop::human::AskUserTool,
}
impl SkaldAskUserTool {
pub fn new(channel: Arc<dyn HumanChannel>, store: Arc<dyn agent_loop::store::HistoryStore>) -> Self {
Self {
inner: agent_loop::human::AskUserTool::new(channel, store)
.with_name(crate::tools::tool_names::ASK_USER_CLARIFICATION),
}
}
}
#[async_trait]
impl Tool for SkaldAskUserTool {
fn name(&self) -> &str { crate::tools::tool_names::ASK_USER_CLARIFICATION }
fn definition(&self) -> Value {
crate::session::handler::ask_user_clarification_tool_def()
}
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
self.inner.call(args, ctx).await
}
}
// ── ExecuteTaskAliasTool ─────────────────────────────────────────────────────
/// The legacy `execute_task`, split by what the mode actually is.
///
/// `sync` and `async` are **delegation** — one agent handing work to another —
/// so both go to the crate's `DelegateTool` (which runs the child in place, or
/// submits it to the async executor). `cron` is **scheduling**: it creates a
/// recurring job and delegates nothing, so it stays on the interface-tool
/// handler that owns the schedule. Without that handler (a non-interactive
/// session, where cron was never offered) the mode is refused.
pub struct ExecuteTaskAliasTool {
delegate: DelegateTool,
definition: Value,
cron_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
}
impl ExecuteTaskAliasTool {
pub fn new(
delegate: DelegateTool,
definition: Value,
cron_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
) -> Self {
Self { delegate, definition, cron_handler }
}
}
#[async_trait]
impl Tool for ExecuteTaskAliasTool {
fn name(&self) -> &str { crate::tools::tool_names::EXECUTE_TASK }
fn definition(&self) -> Value { self.definition.clone() }
fn concurrency_safe(&self, args: &Value) -> bool {
// Only a sync delegate is a plain "slow tool" the fan-out may batch.
!matches!(args["mode"].as_str(), Some("async") | Some("cron"))
}
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
if args["mode"].as_str() == Some("cron") {
let Some(handler) = &self.cron_handler else {
return Err(ToolFailure::Failed(
"execute_task: cron mode is not available in this session".into(),
));
};
return handler(args)
.await
.map(ToolOutput::Text)
.map_err(|e| ToolFailure::Failed(e.to_string()));
}
self.delegate.call(args, ctx).await
}
}
// ── LegacyInterfaceTool ──────────────────────────────────────────────────────
/// Wraps a ChatHub-provided `InterfaceTool` (definition + handler closure) as
/// a crate-native tool — interface tools keep their exact legacy behavior
/// during the migration.
pub struct LegacyInterfaceTool {
definition: Value,
handler: Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>,
}
impl LegacyInterfaceTool {
pub fn new(it: core_api::interface_tool::InterfaceTool) -> Self {
Self { definition: it.definition, handler: it.handler }
}
}
#[async_trait]
impl Tool for LegacyInterfaceTool {
fn name(&self) -> &str {
self.definition["function"]["name"].as_str().unwrap_or("")
}
fn definition(&self) -> Value { self.definition.clone() }
async fn call(&self, args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
(self.handler)(args)
.await
.map(ToolOutput::Text)
.map_err(|e| ToolFailure::Failed(e.to_string()))
}
}
@@ -0,0 +1,266 @@
//! `SkaldAgentCatalog` — the crate's `AgentCatalog` over `agents/*`
//! (port of `build_sub_agent_config`, blueprint §10): builds the child's
//! profile — its own prompt (never the parent's, B3), derived tool set
//! (root-only strip + sub-agent augmentation + approval visibility), own
//! strength selector (D14), own DTL-scoped assembler and activator.
//!
//! Built **once per user**: everything about the delegating turn comes from the
//! call's [`TurnScope`], never captured here.
use std::collections::HashSet;
use std::sync::{Arc, RwLock, Weak};
use agent_loop::context::ContextAssembler;
use agent_loop::delegate::{
AgentCatalog, AgentKind, AgentProfile, AgentSummary, DelegateTool, ToolSelection,
};
use agent_loop::ids::FrameId;
use agent_loop::model::ModelHint;
use agent_loop::tool::{Tool as LoopTool, ToolCtx};
use agent_loop::activation::ActivateToolsTool;
use core_api::user_fs::SharedFs;
use sqlx::SqlitePool;
use crate::approval::ApprovalManager;
use crate::clarification::ClarificationManager;
use crate::llm::LlmManager;
use crate::loop_adapters::activation::SkaldToolActivator;
use crate::loop_adapters::builtins::{SkaldAskUserTool, SkaldHumanChannel};
use crate::loop_adapters::history::SqliteHistory;
use crate::loop_adapters::runtime::LoopConfig;
use crate::loop_adapters::scope::TurnScope;
use crate::loop_adapters::selector::SkaldSelector;
use crate::loop_adapters::system::AgentSystemContext;
use crate::loop_adapters::toolset::SkaldToolSet;
use crate::mcp::McpProvider;
use crate::tools::ToolRegistry;
use crate::tools::tool_names as tn;
/// The catalog's own dependencies — all of them user-scoped.
pub struct SkaldAgentCatalog {
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
llm_manager: Arc<LlmManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
mcp: Arc<dyn McpProvider>,
registry: Arc<ToolRegistry>,
core_tools: Vec<Arc<dyn crate::tools::Tool>>,
/// The swappable fs cell, so a §6 remount reaches sub-agents too.
fs: SharedFs,
config: LoopConfig,
/// The delegate tool, injected post-construction. **Weak** on purpose: the
/// delegate holds the catalog, so an `Arc` here would be a cycle that never
/// frees (and this graph lives as long as the user).
delegate: RwLock<Weak<DelegateTool>>,
}
impl SkaldAgentCatalog {
#[allow(clippy::too_many_arguments)]
pub fn new(
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
llm_manager: Arc<LlmManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
mcp: Arc<dyn McpProvider>,
registry: Arc<ToolRegistry>,
fs: SharedFs,
config: LoopConfig,
) -> Self {
let core_tools = registry.all_tools();
Self {
pool,
shared_pool,
user_id,
llm_manager,
approval,
clarification,
mcp,
registry,
core_tools,
fs,
config,
delegate: RwLock::new(Weak::new()),
}
}
/// Post-construction wiring of the delegate (catalog ↔ delegate cycle,
/// broken by the `Weak` above).
pub fn set_delegate(&self, delegate: &Arc<DelegateTool>) {
*self.delegate.write().unwrap() = Arc::downgrade(delegate);
}
}
#[agent_loop::async_trait]
impl AgentCatalog for SkaldAgentCatalog {
async fn get(
&self,
id: &str,
child_frame: FrameId,
ctx: &ToolCtx,
) -> agent_loop::Result<AgentProfile> {
let scope = TurnScope::from(&ctx.extensions)
.ok_or_else(|| anyhow::anyhow!("delegate: the turn published no scope"))?;
// Only `task` agents are dispatchable (rejects chat/system/unknown).
let meta = crate::agents::load_task_meta(id).map_err(|e| anyhow::anyhow!("{e}"))?;
// The child's own strength drives its selector (D14) — never the
// parent's resolved client.
let selector = Arc::new(SkaldSelector::new(self.llm_manager.clone(), meta.strength));
let model = meta.client.as_deref().map(ModelHint::name);
// The child's system context: its own prompt, no per-turn extras.
let context = Arc::new(AgentSystemContext {
agent_id: id.to_string(),
extra_static: None,
extra_dynamic: None,
tail_reminder: None,
substitutions: Default::default(),
pool: self.pool.clone(),
shared_pool: self.shared_pool.clone(),
user_id: self.user_id.clone(),
mcp: self.mcp.clone(),
project_root: scope.project_root.clone(),
// The scratchpad is the session's blackboard: a sub-agent reads and
// writes the SAME one as its parent.
scratchpad_sid: scope.scratchpad_sid,
datetime: self.config.datetime.clone(),
});
// The child's def list: parent's base minus root-only minus the
// re-derived augmentations (added back natively below), plus
// sub-agents-only tools, through the approval visibility filter.
let mut child_defs: Vec<serde_json::Value> = scope
.base_defs
.iter()
.filter(|d| {
let name = d["function"]["name"].as_str().unwrap_or("");
!scope.root_only.iter().any(|n| n == name)
&& name != tn::ASK_USER_CLARIFICATION
&& name != tn::EXECUTE_SUBTASK
&& name != tn::EXECUTE_TASK
})
.cloned()
.collect();
child_defs.extend(self.registry.openai_definitions_sub_agents_only());
{
let group_rules = crate::db::approval_rules::list_for_group(&self.shared_pool, None)
.await
.unwrap_or_default();
child_defs.retain(|def| {
let name = def["function"]["name"].as_str().unwrap_or("");
self.approval.is_tool_visible(&group_rules, name)
});
}
// Native child tools: clarification, sub-delegation (depth permitting),
// and the frame-scoped activate_tools with a FRESH grant set — a child
// never inherits the parent's activations.
let child_grants: Arc<RwLock<HashSet<String>>> = Arc::new(RwLock::new(
crate::db::activated_tools::list_refs_stack(&self.pool, child_frame.get())
.await
.unwrap_or_default()
.into_iter()
.collect(),
));
let mut native: Vec<Arc<dyn LoopTool>> = Vec::new();
{
let channel = Arc::new(SkaldHumanChannel::new(
self.clarification.clone(),
scope.session_id,
id,
&scope.source,
scope.is_interactive,
scope.context_label.clone(),
));
native.push(Arc::new(SkaldAskUserTool::new(
channel,
Arc::new(SqliteHistory::new(self.pool.clone())),
)));
}
// `execute_subtask` only while the child can still recurse. A dead Weak
// means the runtime is shutting down: the child simply cannot delegate.
if let Some(d) = self.delegate.read().unwrap().upgrade() {
// Legacy name AND legacy schema (D11): a sub-agent sees the same
// definition it has always seen, not the crate's generic one.
native.push(Arc::new(
d.as_ref()
.clone()
.with_name(tn::EXECUTE_SUBTASK)
.with_definition(crate::session::handler::execute_subtask_tool_def()),
));
}
native.push(Arc::new(ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
self.pool.clone(),
self.mcp.clone(),
child_grants.clone(),
scope.session_id,
Some(child_frame.get()),
)))));
let toolset: Arc<dyn agent_loop::tool::ToolSet> = Arc::new(
SkaldToolSet::new(
child_defs,
scope.config_defs.clone(),
self.mcp.clone(),
child_grants,
scope.memory_tools.as_ref().clone(),
scope.image_tools.as_ref().clone(),
Vec::new(),
self.core_tools.clone(),
)
.with_native_all(native),
);
let assembler: Arc<dyn ContextAssembler> = Arc::new(
crate::loop_adapters::projection_cfg::skald_assembler(
Arc::new(crate::loop_adapters::activation::SkaldActivationSource::new(
self.pool.clone(),
self.mcp.clone(),
scope.config_defs.clone(),
scope.session_id,
Some(child_frame.get()),
)),
Some(self.fs.load()),
self.config.max_history_messages,
self.config.compaction_enabled,
self.config.max_tool_result_chars,
),
);
Ok(AgentProfile {
id: id.to_string(),
kind: AgentKind::Task,
context,
tools: ToolSelection::inherit(),
model,
selector: Some(selector),
assembler: Some(assembler),
toolset: Some(toolset),
})
}
async fn list(&self, kind: AgentKind) -> Vec<AgentSummary> {
if kind != AgentKind::Task {
return Vec::new();
}
crate::agents::discover()
.unwrap_or_default()
.into_iter()
.filter(|a| matches!(a.agent_type, crate::agents::AgentType::Task))
.map(|a| AgentSummary { id: a.id, kind, description: a.description })
.collect()
}
async fn on_child_closed(&self, frame: FrameId) {
// Stack-scoped activations are ephemeral — deleted on frame exit.
if let Err(e) = crate::db::activated_tools::delete_for_stack(&self.pool, frame.get()).await {
tracing::warn!(frame = %frame, error = %e, "catalog: failed to delete stack activations");
}
}
}
+531
View File
@@ -0,0 +1,531 @@
//! `ApprovalGate` — Skald's approval flow behind the crate's `Gate` trait
//! (port of `handler/gate.rs::run_approval_gate`, blueprint §10):
//!
//! 1. `pre_approved` short-circuit (post-restart manual resolve);
//! 2. the approval engine decides (explicit Allow/Deny rules win);
//! 3. the RunContext fast-path relaxes `Require` to `Allow` for pre-authorized
//! fs paths (never overrides a Deny);
//! 4. `Require` → auto-deny, or mark `AwaitingHuman` + register + emit
//! `ApprovalRequired` + block on the human decision; a closed channel maps
//! to `GateDecision::Suspend` (the call stays `AwaitingHuman`, the turn
//! ends) — the old `GateOutcome::ChannelClosed`.
use std::sync::Arc;
use std::sync::atomic::Ordering;
use agent_loop::events::{EventSink, LoopEvent};
use agent_loop::gate::{Gate, GateDecision, PendingCall};
use agent_loop::store::{CallState, HistoryStore};
use core_api::user_fs::SharedFs;
use sqlx::SqlitePool;
use crate::approval::{ApprovalManager, GateResult};
use crate::loop_adapters::scope::TurnScope;
use crate::run_context::RunContext;
use crate::session::handler::ApprovalDecision;
use crate::tools::{ToolRegistry, is_file_read_tool, is_file_write_tool, tool_names as tn};
/// The gate's **long-lived** dependencies: it is built once per user, and reads
/// the turn's own state (session, source, group, run context) from the call's
/// [`TurnScope`] instead of capturing it.
pub struct ApprovalGate {
approval: Arc<ApprovalManager>,
store: Arc<dyn HistoryStore>,
tools: Arc<ToolRegistry>,
/// For the `PendingWrite` diff: owner pool (user-memory), shared pool
/// (shared-memory), and the caller's fs view (host paths).
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
fs: Option<SharedFs>,
}
impl ApprovalGate {
pub fn new(
approval: Arc<ApprovalManager>,
store: Arc<dyn HistoryStore>,
tools: Arc<ToolRegistry>,
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
fs: Option<SharedFs>,
) -> Self {
Self { approval, store, tools, pool, shared_pool, fs }
}
/// Reads the current content of a file for the `PendingWrite` diff, routed
/// exactly like the fs-tools (memory notes → the right pool, everything
/// else → the caller's host workspace, containment-checked).
async fn read_current_content(&self, path: &str) -> Option<String> {
use crate::tools::fs::{MemScope, classify_memory, resolve_host_path};
if let Some(m) = classify_memory(path) {
let pool = match m.scope {
MemScope::User => &self.pool,
MemScope::Shared => &self.shared_pool,
};
return crate::db::memory_docs::get(pool, &m.rel)
.await.ok().flatten().map(|d| d.content);
}
let fs = self.fs.as_ref()?;
let abs = resolve_host_path(&fs.load(), path).ok()?;
tokio::fs::read_to_string(&abs).await.ok()
}
/// Computes what a file would look like after the tool runs, without
/// writing it. `None` if indeterminable (e.g. edit on a missing file).
async fn compute_new_content(&self, name: &str, args: &serde_json::Value) -> Option<String> {
match name {
"write_file" => args["content"].as_str().map(|s| s.to_string()),
"edit_file" => {
let path = args["path"].as_str()?;
let old_text = args["old"].as_str()?;
let new_text = args["new"].as_str()?;
let current = self.read_current_content(path).await?;
if current.contains(old_text) {
Some(current.replacen(old_text, new_text, 1))
} else {
None
}
}
"insert_at_line" => {
let path = args["path"].as_str()?;
let line_num = args["line"].as_u64()? as usize;
let new_text = args["content"].as_str()?;
let placement = args["placement"].as_str().unwrap_or("after");
if line_num == 0 { return None; }
let current = self.read_current_content(path).await?;
let mut lines: Vec<&str> = current.split('\n').collect();
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
let insert_idx = if placement == "before" { idx } else { idx + 1 };
let new_lines: Vec<&str> = new_text.split('\n').collect();
for (i, l) in new_lines.iter().enumerate() {
lines.insert(insert_idx + i, l);
}
Some(lines.join("\n"))
}
"replace_lines" => {
let path = args["path"].as_str()?;
let from_line = args["from_line"].as_u64()? as usize;
let to_line = args["to_line"].as_u64()? as usize;
let new_text = args["new"].as_str()?;
if from_line == 0 || to_line < from_line { return None; }
let current = self.read_current_content(path).await?;
let mut lines: Vec<&str> = current.lines().collect();
let total = lines.len();
if from_line > total { return None; }
let to_clamped = to_line.min(total);
let new_lines: Vec<&str> = new_text.lines().collect();
lines.splice((from_line - 1)..to_clamped, new_lines);
let has_trailing = current.ends_with('\n');
let mut result = lines.join("\n");
if has_trailing { result.push('\n'); }
Some(result)
}
_ => None,
}
}
/// Emits the approval event for the tool kind: `PendingWrite` (via
/// `LoopEvent::Host`) for file-write tools and `execute_cmd`,
/// `ApprovalRequired` otherwise (port of `emit_approval_event`).
async fn emit_approval_event(
&self,
events: &EventSink,
call: &PendingCall,
request_id: i64,
) {
let name = call.name.as_str();
if is_file_write_tool(name) {
let path = call.args["path"].as_str().unwrap_or("").to_string();
let (old_content, new_content) = tokio::join!(
self.read_current_content(&path),
self.compute_new_content(name, &call.args),
);
if let Some(new_content) = new_content {
events.emit(call.frame, call.parent_frame, LoopEvent::Host(serde_json::json!({
"type": "pending_write",
"request_id": request_id,
"tool_call_id": call.id.get(),
"path": path,
"old_content": old_content,
"new_content": new_content,
})));
return;
}
} else if name == tn::EXECUTE_CMD {
let cmd = call.args["command"].as_str().unwrap_or("");
events.emit(call.frame, call.parent_frame, LoopEvent::Host(serde_json::json!({
"type": "pending_write",
"request_id": request_id,
"tool_call_id": call.id.get(),
"path": "$ execute_cmd",
"old_content": serde_json::Value::Null,
"new_content": format!("$ {cmd}"),
})));
return;
}
events.emit(call.frame, call.parent_frame, LoopEvent::ApprovalRequired {
id: call.id,
name: call.name.clone(),
args: call.args.clone(),
request_id,
});
}
}
#[agent_loop::async_trait]
impl Gate for ApprovalGate {
async fn check(&self, call: &PendingCall, events: &EventSink) -> GateDecision {
// No scope = a wiring bug. Denying is the only safe reading: an
// unscoped call cannot be evaluated against any policy.
let Some(scope) = TurnScope::from(&call.extensions) else {
return GateDecision::Reject {
reason: "approval: the turn published no scope; refusing to run the tool"
.to_string(),
};
};
// Post-restart manual resolve: already approved via a resolve endpoint.
if scope.pre_approved.lock().unwrap().remove(&call.id.get()) {
return GateDecision::Allow;
}
let category = self.tools.category_of(&call.name);
// The approval engine decides first: an explicit Deny/Allow rule wins.
let mut gate = self
.approval
.check(
scope.session_id,
category,
&call.agent,
&scope.source,
&call.name,
&call.args,
scope.group_id.as_deref(),
)
.await;
// RunContext fast-path: relax `Require` for pre-authorized fs paths
// (never overrides a Deny).
if matches!(gate, GateResult::Require) {
let path = call.args["path"].as_str().unwrap_or("");
let guard = scope.run_context.read().await.clone();
let dflt = RunContext::default();
let rc = guard.as_ref().unwrap_or(&dflt);
let pre_allowed = if is_file_read_tool(&call.name) {
rc.is_read_allowed(path)
} else if is_file_write_tool(&call.name) {
rc.is_write_allowed(path)
} else {
false
};
if pre_allowed {
gate = GateResult::Allow;
}
}
match gate {
GateResult::Allow => GateDecision::Allow,
GateResult::Deny => GateDecision::Reject {
reason: "Tool call denied by approval policy.".to_string(),
},
GateResult::Require => {
if scope.auto_deny.load(Ordering::Relaxed) {
return GateDecision::Reject {
reason: "Tool call auto-denied: this session does not support approval requests."
.to_string(),
};
}
// Durability FIRST: the call must survive a crash as pending.
if let Err(e) = self.store.set_call_state(call.id, CallState::AwaitingHuman).await {
return GateDecision::Reject {
reason: format!("approval: failed to mark call pending: {e}"),
};
}
let label = scope.context_label.read().ok().and_then(|g| g.clone());
let (request_id, approve_rx) = self
.approval
.register(
scope.session_id,
call.id.get(),
&call.name,
call.args.clone(),
&call.agent,
&scope.source,
label.as_deref(),
category,
)
.await;
self.emit_approval_event(events, call, request_id).await;
match approve_rx.await {
Ok(ApprovalDecision::Approved) => GateDecision::Allow,
Ok(ApprovalDecision::Rejected { note }) => GateDecision::Reject {
reason: ApprovalDecision::rejection_message(&note),
},
Err(_) => GateDecision::Suspend,
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
use tokio::sync::RwLock;
use super::*;
use agent_loop::events::EventSink;
use agent_loop::ids::{ConversationId, FrameId, ToolCallId};
use agent_loop::tool::Extensions;
use serde_json::json;
use sqlx::SqlitePool;
use crate::approval::{NewApprovalRule, RuleAction};
use crate::db::{chat_history, chat_llm_tools, chat_sessions_stack};
use crate::loop_adapters::history::SqliteHistory;
fn temp_db_path(tag: &str) -> String {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
p.to_string_lossy().into_owned()
}
fn cleanup(path: &str) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{path}{suffix}"));
}
}
/// The scope a turn publishes, with the knobs a test wants to vary.
fn scope(source: &str, auto_deny: bool) -> Arc<TurnScope> {
Arc::new(TurnScope {
session_id: 1,
source: source.to_string(),
is_interactive: source == "web",
agent_id: "assistant".into(),
scratchpad_sid: 1,
project_root: None,
context_label: Arc::new(std::sync::RwLock::new(None)),
run_context: Arc::new(RwLock::new(None)),
group_id: None,
pre_approved: Arc::new(Mutex::new(HashSet::new())),
auto_deny: Arc::new(AtomicBool::new(auto_deny)),
grants: Arc::new(std::sync::RwLock::new(HashSet::new())),
base_defs: Arc::new(Vec::new()),
config_defs: Arc::new(Vec::new()),
memory_tools: Arc::new(Vec::new()),
image_tools: Arc::new(Vec::new()),
root_only: Arc::new(Vec::new()),
})
}
/// A `PendingCall` carrying its turn's scope, as the kernel builds it.
fn pending(call_id: i64, frame: i64, scope: Arc<TurnScope>) -> PendingCall {
let mut extensions = Extensions::new();
extensions.insert(scope);
PendingCall {
id: ToolCallId(call_id),
name: "some_tool".into(),
args: json!({}),
frame: FrameId(frame),
parent_frame: None,
agent: "assistant".into(),
extensions,
}
}
struct Fixture {
gate: ApprovalGate,
events: EventSink,
pool: Arc<SqlitePool>,
call: PendingCall,
path: String,
approval: Arc<ApprovalManager>,
}
async fn fixture(tag: &str) -> Fixture {
let path = temp_db_path(tag);
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
// The `default` permission group is a FK target for approval_rules.group_id.
sqlx::query("INSERT INTO tool_permission_groups (id, name) VALUES ('default', 'Default')")
.execute(&*pool)
.await
.unwrap();
sqlx::query("INSERT INTO chat_sessions (id) VALUES (1)").execute(&*pool).await.unwrap();
let frame = chat_sessions_stack::create(&pool, 1, "assistant", None, 0, None).await.unwrap();
let msg = chat_history::append(&pool, frame.id, &chat_history::Role::Assistant, "a", false, None)
.await
.unwrap();
let call_id = chat_llm_tools::append(&pool, msg, "some_tool", "{}").await.unwrap();
let (tx, _) = tokio::sync::broadcast::channel(16);
let approval = Arc::new(ApprovalManager::new(pool.clone(), tx));
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
let tools = Arc::new(ToolRegistry::new());
let gate = ApprovalGate::new(
approval.clone(),
store,
tools,
pool.clone(),
pool.clone(),
None,
);
let (bus, _) = tokio::sync::broadcast::channel(16);
let events = EventSink::new(ConversationId::new("session:1"), bus);
let call = pending(call_id, frame.id, scope("web", false));
Fixture { gate, events, pool, call, path, approval }
}
#[tokio::test]
async fn explicit_deny_rule_rejects() {
let f = fixture("gate-deny").await;
f.approval
.add_rule(NewApprovalRule {
agent_id: None,
source: None,
tool_pattern: "some_tool".into(),
path_pattern: None,
action: RuleAction::Deny,
note: None,
priority: Some(1),
group_id: None,
})
.await
.unwrap();
let d = f.gate.check(&f.call, &f.events).await;
assert!(matches!(d, GateDecision::Reject { .. }));
f.pool.close().await;
cleanup(&f.path);
}
#[tokio::test]
async fn auto_deny_rejects_require() {
let path = temp_db_path("gate-autodeny");
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
sqlx::query("INSERT INTO chat_sessions (id) VALUES (1)").execute(&*pool).await.unwrap();
let frame = chat_sessions_stack::create(&pool, 1, "assistant", None, 0, None).await.unwrap();
let msg = chat_history::append(&pool, frame.id, &chat_history::Role::Assistant, "a", false, None)
.await
.unwrap();
let call_id = chat_llm_tools::append(&pool, msg, "some_tool", "{}").await.unwrap();
let (tx, _) = tokio::sync::broadcast::channel(16);
let approval = Arc::new(ApprovalManager::new(pool.clone(), tx));
let gate = ApprovalGate::new(
approval,
Arc::new(SqliteHistory::new(pool.clone())),
Arc::new(ToolRegistry::new()),
pool.clone(),
pool.clone(),
None,
);
let (bus, _) = tokio::sync::broadcast::channel(16);
let events = EventSink::new(ConversationId::new("session:1"), bus);
// A background source that cannot ask a human.
let call = pending(call_id, frame.id, scope("cron", true));
// No rules at all → the seeded-less default is Require; auto-deny rejects.
let d = gate.check(&call, &events).await;
assert!(matches!(d, GateDecision::Reject { .. }));
pool.close().await;
cleanup(&path);
}
/// A call with no scope means the turn was wired wrong. Denying is the only
/// safe reading — there is no policy to evaluate it against.
#[tokio::test]
async fn an_unscoped_call_is_denied() {
let f = fixture("gate-unscoped").await;
let mut call = f.call.clone();
call.extensions = Extensions::new();
let d = f.gate.check(&call, &f.events).await;
match d {
GateDecision::Reject { reason } => assert!(reason.contains("no scope"), "{reason}"),
other => panic!("expected Reject, got {other:?}"),
}
f.pool.close().await;
cleanup(&f.path);
}
#[tokio::test]
async fn human_approval_allows_and_marks_pending_first() {
let f = fixture("gate-human").await;
let approval = f.approval.clone();
let gate = Arc::new(f.gate);
let events = f.events.clone();
let call = f.call.clone();
let check = tokio::spawn(async move { gate.check(&call, &events).await });
// Wait for the request to register, then approve it.
let request_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let pending = approval.list_pending().await;
if let Some(p) = pending.first() {
break p.request_id;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.unwrap();
// The call is durably pending while the human decides.
let row = chat_llm_tools::get(&f.pool, f.call.id.get()).await.unwrap().unwrap();
assert_eq!(row.status, "pending");
approval.resolve(request_id, ApprovalDecision::Approved).await;
let d = check.await.unwrap();
assert!(matches!(d, GateDecision::Allow));
f.pool.close().await;
cleanup(&f.path);
}
#[tokio::test]
async fn human_rejection_rejects_with_note() {
let f = fixture("gate-reject").await;
let approval = f.approval.clone();
let gate = Arc::new(f.gate);
let events = f.events.clone();
let call = f.call.clone();
let check = tokio::spawn(async move { gate.check(&call, &events).await });
let request_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let pending = approval.list_pending().await;
if let Some(p) = pending.first() {
break p.request_id;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.unwrap();
approval
.resolve(request_id, ApprovalDecision::Rejected { note: "too risky".into() })
.await;
let d = check.await.unwrap();
match d {
GateDecision::Reject { reason } => assert!(reason.contains("too risky")),
other => panic!("expected Reject, got {other:?}"),
}
f.pool.close().await;
cleanup(&f.path);
}
}
@@ -0,0 +1,584 @@
//! `SqliteHistory` — `HistoryStore` over the EXISTING Skald tables (no
//! migration, blueprint §0/§10):
//!
//! | crate concept | Skald table |
//! |---|---|
//! | conversation `"session:{id}"` | `chat_sessions.id` (the id rides in the `ConversationId` string) |
//! | frame | `chat_sessions_stack` (`terminated_at IS NULL` = active) |
//! | message | `chat_history` (`status='failed'` = failed orphan) |
//! | tool call | `chat_llm_tools` (status strings map 1:1 on `CallState`) |
//! | summary | `chat_summaries` (`covers_up_to_message_id`) |
//!
//! The store is built on an **owner pool** (one per user, §11): all ids are
//! pool-local, so the adapter needs no user scoping. The wire tool-call id is
//! synthesized as `tc_{row_id}`, exactly like the current message builder.
use std::sync::Arc;
use agent_loop::model::Usage;
use agent_loop::store::{
CallOutcome, CallState, FrameRecord, FrameSpec, HistoryStore, NewCall, NewMessage, NewSummary,
Role, StoredCall, StoredMessage, StoredSummary,
};
use agent_loop::tool::ToolOutput;
use agent_loop::ids::{ConversationId, FrameId, MessageId, SummaryId, ToolCallId};
use serde_json::Value;
use sqlx::SqlitePool;
use crate::db::{chat_history, chat_llm_tools, chat_sessions_stack, chat_summaries};
/// `HistoryStore` on a Skald owner pool.
pub struct SqliteHistory {
pool: Arc<SqlitePool>,
}
impl SqliteHistory {
pub fn new(pool: Arc<SqlitePool>) -> Self { Self { pool } }
/// The conversation id of a session — the encoding, in one place.
pub fn conversation(session_id: i64) -> ConversationId {
ConversationId::new(format!("session:{session_id}"))
}
/// Parse `"session:{id}"` (the adapter's conversation encoding).
pub fn session_id(conv: &ConversationId) -> anyhow::Result<i64> {
conv.as_str()
.strip_prefix("session:")
.and_then(|s| s.parse::<i64>().ok())
.ok_or_else(|| anyhow::anyhow!("SqliteHistory: conversation id must be \"session:<i64>\", got '{conv}'"))
}
fn map_role(role: Role) -> anyhow::Result<chat_history::Role> {
match role {
Role::User => Ok(chat_history::Role::User),
Role::Assistant => Ok(chat_history::Role::Assistant),
Role::Agent => Ok(chat_history::Role::Agent),
// chat_history has no system role: system context is BUILT, never
// stored. Failing loudly beats silently mis-filing a message.
Role::System => anyhow::bail!(
"SqliteHistory: Role::System is not persistable — system context is not stored"
),
}
}
fn unmap_role(role: &chat_history::Role) -> Role {
match role {
chat_history::Role::User => Role::User,
chat_history::Role::Assistant => Role::Assistant,
chat_history::Role::Agent => Role::Agent,
}
}
fn map_state(state: CallState) -> &'static str {
match state {
CallState::Running => "running",
CallState::AwaitingHuman => "pending",
CallState::Done => "done",
CallState::Failed => "failed",
CallState::Cancelled => "cancelled",
CallState::Rejected => "rejected",
}
}
fn unmap_state(status: &str) -> CallState {
match status {
"pending" => CallState::AwaitingHuman,
"done" => CallState::Done,
"failed" => CallState::Failed,
"cancelled" => CallState::Cancelled,
"rejected" => CallState::Rejected,
_ => CallState::Running,
}
}
fn stored_call(c: chat_llm_tools::LlmToolCall) -> StoredCall {
let arguments: Value = c
.arguments
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Object(Default::default()));
// preview/media ride in `extras` (host free-form), mirroring how the
// current loop reads them back for the history projection.
let extras = serde_json::json!({
"preview_old": c.preview_old,
"preview_new": c.preview_new,
"media": c.media,
});
StoredCall {
id: ToolCallId(c.id),
message_id: MessageId(c.message_id),
provider_id: format!("tc_{}", c.id),
name: c.name,
arguments,
// The column holds the model's own string: the projection replays it
// verbatim, so the prompt-cache prefix stays byte-identical (a
// re-serialized Value would reorder the object keys).
arguments_raw: c.arguments,
state: Self::unmap_state(&c.status),
result: c.result,
result_kind: c.result_type,
extras,
}
}
fn stored_message(m: chat_history::ChatMessage, calls: Vec<StoredCall>) -> StoredMessage {
StoredMessage {
id: MessageId(m.id),
role: Self::unmap_role(&m.role),
content: m.content,
reasoning: m.reasoning_content,
synthetic: m.is_synthetic,
failed: m.status == "failed",
metadata: m.metadata.map(|meta| {
serde_json::to_value(meta).unwrap_or(Value::Null)
}),
usage: Usage {
input_tokens: m.input_tokens.map(|n| n as u32),
output_tokens: m.output_tokens.map(|n| n as u32),
cache_read: None,
cache_write: None,
cost_usd: m.cost,
truncated: false,
},
calls,
}
}
async fn with_calls(&self, msgs: Vec<chat_history::ChatMessage>) -> anyhow::Result<Vec<StoredMessage>> {
let mut out = Vec::with_capacity(msgs.len());
for m in msgs {
let calls = chat_llm_tools::for_message(&self.pool, m.id)
.await?
.into_iter()
.map(Self::stored_call)
.collect();
out.push(Self::stored_message(m, calls));
}
Ok(out)
}
}
#[agent_loop::async_trait]
impl HistoryStore for SqliteHistory {
// ── frames ──
async fn open_frame(
&self,
conv: &ConversationId,
parent: Option<FrameId>,
spec: FrameSpec,
) -> agent_loop::Result<FrameId> {
let session_id = Self::session_id(conv)?;
// Root frame: reuse the session's existing root stack row when present
// (sessions are provisioned with one), create it otherwise.
if parent.is_none()
&& let Some(root) = chat_sessions_stack::main_for_session(&self.pool, session_id).await?
{
return Ok(FrameId(root.id));
}
let frame = chat_sessions_stack::create(
&self.pool,
session_id,
&spec.agent,
spec.prompt.as_deref(),
spec.depth as i64,
spec.parent_call.map(|c| c.get()),
)
.await?;
Ok(FrameId(frame.id))
}
async fn close_frame(&self, frame: FrameId) -> agent_loop::Result<()> {
chat_sessions_stack::terminate(&self.pool, frame.get()).await?;
Ok(())
}
async fn get_frame(&self, frame: FrameId) -> agent_loop::Result<Option<FrameRecord>> {
let row = sqlx::query_as::<_, (i64, i64, String, Option<String>, i64, Option<i64>, Option<String>)>(
"SELECT id, session_id, agent_id, agent_prompt, depth, parent_tool_call_id, terminated_at
FROM chat_sessions_stack
WHERE id = ?",
)
.bind(frame.get())
.fetch_optional(&*self.pool)
.await?;
Ok(row.map(|(id, sid, agent, prompt, depth, parent_call, terminated)| FrameRecord {
id: FrameId(id),
conversation: ConversationId::new(format!("session:{sid}")),
parent: None,
spec: FrameSpec {
agent,
prompt,
depth: depth as u32,
parent_call: parent_call.map(ToolCallId),
meta: Value::Null,
},
active: terminated.is_none(),
}))
}
async fn active_frames(&self, conv: &ConversationId) -> agent_loop::Result<Vec<FrameRecord>> {
let session_id = Self::session_id(conv)?;
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, i64, Option<i64>)>(
"SELECT id, session_id, agent_id, agent_prompt, depth, parent_tool_call_id
FROM chat_sessions_stack
WHERE session_id = ? AND terminated_at IS NULL
ORDER BY depth ASC",
)
.bind(session_id)
.fetch_all(&*self.pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, sid, agent, prompt, depth, parent_call)| FrameRecord {
id: FrameId(id),
conversation: ConversationId::new(format!("session:{sid}")),
// The parent frame id is not stored directly (only the parent
// tool call); recovery walks the call when it needs the link.
parent: None,
spec: FrameSpec {
agent,
prompt,
depth: depth as u32,
parent_call: parent_call.map(ToolCallId),
meta: Value::Null,
},
active: true,
})
.collect())
}
async fn frame_of_call(&self, id: ToolCallId) -> agent_loop::Result<Option<FrameRecord>> {
let frame = sqlx::query_scalar::<_, i64>(
"SELECT h.stack_id
FROM chat_llm_tools t
JOIN chat_history h ON h.id = t.message_id
WHERE t.id = ?",
)
.bind(id.get())
.fetch_optional(&*self.pool)
.await?;
match frame {
Some(f) => self.get_frame(FrameId(f)).await,
None => Ok(None),
}
}
async fn deepest_active(&self, conv: &ConversationId) -> agent_loop::Result<Option<FrameRecord>> {
Ok(self
.active_frames(conv)
.await?
.into_iter()
.max_by_key(|f| f.spec.depth))
}
// ── messages ──
async fn append(&self, frame: FrameId, msg: NewMessage) -> agent_loop::Result<MessageId> {
let role = Self::map_role(msg.role)?;
// chat_history.metadata is a typed MessageMetadata column; the crate's
// free-form Value only round-trips when it parses back as one.
let metadata = msg
.metadata
.as_ref()
.and_then(|v| serde_json::from_value::<core_api::message_meta::MessageMetadata>(v.clone()).ok());
let id = chat_history::append_with_metadata(
&self.pool,
frame.get(),
&role,
&msg.content,
msg.synthetic,
msg.reasoning.as_deref(),
metadata.as_ref(),
)
.await?;
Ok(MessageId(id))
}
async fn set_usage(&self, msg: MessageId, usage: &Usage) -> agent_loop::Result<()> {
if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
chat_history::set_usage(&self.pool, msg.get(), i, o, 0, usage.cost_usd).await?;
}
Ok(())
}
async fn load(&self, frame: FrameId) -> agent_loop::Result<Vec<StoredMessage>> {
let msgs = chat_history::for_stack(&self.pool, frame.get()).await?;
self.with_calls(msgs).await
}
async fn load_since(&self, frame: FrameId, after: MessageId) -> agent_loop::Result<Vec<StoredMessage>> {
let msgs = chat_history::for_stack_since(&self.pool, frame.get(), after.get()).await?;
self.with_calls(msgs).await
}
async fn last(&self, frame: FrameId) -> agent_loop::Result<Option<StoredMessage>> {
let Some(m) = chat_history::last_message_for_stack(&self.pool, frame.get()).await? else {
return Ok(None);
};
Ok(self.with_calls(vec![m]).await?.into_iter().next())
}
async fn mark_failed(&self, msg: MessageId) -> agent_loop::Result<()> {
chat_history::mark_failed(&self.pool, msg.get()).await?;
Ok(())
}
// ── tool calls ──
async fn append_call(&self, msg: MessageId, call: NewCall) -> agent_loop::Result<ToolCallId> {
let args = serde_json::to_string(&call.arguments)?;
let id = chat_llm_tools::append(&self.pool, msg.get(), &call.name, &args).await?;
Ok(ToolCallId(id))
}
async fn resolve_call(&self, id: ToolCallId, outcome: &CallOutcome) -> agent_loop::Result<()> {
let pool = &self.pool;
match outcome {
CallOutcome::Completed(out) => {
chat_llm_tools::complete(pool, id.get(), &out.to_wire(), out.kind()).await?;
if let ToolOutput::Media { refs, .. } = out {
let media_json = serde_json::to_string(refs)?;
chat_llm_tools::set_media(pool, id.get(), &media_json).await?;
}
}
CallOutcome::Failed(e) => {
chat_llm_tools::fail(pool, id.get(), e).await?;
}
CallOutcome::Cancelled => {
chat_llm_tools::cancel(pool, id.get(), &outcome.result_text()).await?;
}
CallOutcome::Rejected { reason } => {
chat_llm_tools::reject(pool, id.get(), reason).await?;
}
}
Ok(())
}
async fn set_call_state(&self, id: ToolCallId, state: CallState) -> agent_loop::Result<()> {
anyhow::ensure!(
!state.is_terminal(),
"set_call_state is only for Running → AwaitingHuman, not terminal {state:?}"
);
sqlx::query("UPDATE chat_llm_tools SET status = ? WHERE id = ?")
.bind(Self::map_state(state))
.bind(id.get())
.execute(&*self.pool)
.await?;
Ok(())
}
async fn get_call(&self, id: ToolCallId) -> agent_loop::Result<Option<StoredCall>> {
Ok(chat_llm_tools::get(&self.pool, id.get()).await?.map(Self::stored_call))
}
async fn set_call_extras(&self, id: ToolCallId, extras: Value) -> agent_loop::Result<()> {
// Map the known extras onto the dedicated columns (preview, media);
// unknown keys are dropped (the table has no generic blob).
if extras.get("preview_old").is_some() || extras.get("preview_new").is_some() {
let old = extras["preview_old"].as_str();
let new = extras["preview_new"].as_str();
chat_llm_tools::set_preview(&self.pool, id.get(), old, new).await?;
}
if let Some(media) = extras["media"].as_str() {
chat_llm_tools::set_media(&self.pool, id.get(), media).await?;
}
Ok(())
}
async fn calls_in_state(&self, frame: FrameId, states: &[CallState]) -> agent_loop::Result<Vec<StoredCall>> {
// All calls of the frame, filtered in Rust: a frame's call set is
// bounded, and a static query keeps sqlx's dynamic-SQL audit happy.
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String)>(
"SELECT t.id, t.message_id, t.name, t.arguments, t.result, t.result_type, t.status
FROM chat_llm_tools t
JOIN chat_history h ON t.message_id = h.id
WHERE h.session_stack_id = ?
ORDER BY t.id ASC",
)
.bind(frame.get())
.fetch_all(&*self.pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, message_id, name, arguments, result, result_type, status)| {
Self::stored_call(chat_llm_tools::LlmToolCall {
id,
message_id,
name,
arguments,
result,
result_type,
status,
preview_old: None,
preview_new: None,
media: None,
})
})
.filter(|c| states.contains(&c.state))
.collect())
}
// ── summaries ──
async fn save_summary(&self, frame: FrameId, s: NewSummary) -> agent_loop::Result<SummaryId> {
let id = chat_summaries::save(&self.pool, frame.get(), &s.text, s.covered_up_to.get()).await?;
Ok(SummaryId(id))
}
async fn latest_summary(&self, frame: FrameId) -> agent_loop::Result<Option<StoredSummary>> {
let Some(s) = chat_summaries::latest_for_stack(&self.pool, frame.get()).await? else {
return Ok(None);
};
Ok(Some(StoredSummary {
id: SummaryId(s.id),
text: s.content,
covered_up_to: MessageId(s.covers_up_to_message_id),
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_db_path(tag: &str) -> String {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
p.to_string_lossy().into_owned()
}
fn cleanup(path: &str) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{path}{suffix}"));
}
}
async fn setup(tag: &str) -> (Arc<SqlitePool>, SqliteHistory, ConversationId, String) {
let path = temp_db_path(tag);
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
sqlx::query("INSERT INTO chat_sessions (id) VALUES (1)")
.execute(&*pool)
.await
.unwrap();
// The session's root frame (created at provisioning time in production).
chat_sessions_stack::create(&pool, 1, "assistant", None, 0, None).await.unwrap();
let store = SqliteHistory::new(pool.clone());
(pool, store, ConversationId::new("session:1"), path)
}
#[tokio::test]
async fn frames_open_reuse_root_and_close() {
let (pool, store, conv, path) = setup("hist-frames").await;
// Root: reuses the provisioned root frame.
let root = store.open_frame(&conv, None, FrameSpec::root("assistant")).await.unwrap();
// Child: creates a new frame at depth 1.
let child = store
.open_frame(&conv, Some(root), FrameSpec {
agent: "task".into(),
prompt: Some("do a thing".into()),
depth: 1,
parent_call: None,
meta: Value::Null,
})
.await
.unwrap();
assert_ne!(root, child);
let active = store.active_frames(&conv).await.unwrap();
assert_eq!(active.len(), 2);
assert_eq!(store.deepest_active(&conv).await.unwrap().unwrap().id, child);
store.close_frame(child).await.unwrap();
assert!(store.deepest_active(&conv).await.unwrap().unwrap().spec.depth == 0);
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn messages_calls_and_states_round_trip() {
let (pool, store, conv, path) = setup("hist-msgs").await;
let frame = store.open_frame(&conv, None, FrameSpec::root("assistant")).await.unwrap();
store.append(frame, NewMessage::user("hi")).await.unwrap();
let asst = store.append(frame, NewMessage::assistant("calling", Some("thinking…".into()))).await.unwrap();
let call = store
.append_call(asst, NewCall::new("read_file", serde_json::json!({"path": "a.txt"})))
.await
.unwrap();
// Running → AwaitingHuman (the only legal set_call_state).
store.set_call_state(call, CallState::AwaitingHuman).await.unwrap();
assert!(store.set_call_state(call, CallState::Done).await.is_err());
store
.resolve_call(call, &CallOutcome::Completed(ToolOutput::Text("file contents".into())))
.await
.unwrap();
let history = store.load(frame).await.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[1].reasoning.as_deref(), Some("thinking…"));
assert_eq!(history[1].calls.len(), 1);
let c = &history[1].calls[0];
assert_eq!(c.state, CallState::Done);
assert_eq!(c.result.as_deref(), Some("file contents"));
assert_eq!(c.provider_id, format!("tc_{}", c.id.get()));
assert_eq!(c.arguments["path"], serde_json::json!("a.txt"));
let done = store.calls_in_state(frame, &[CallState::Done]).await.unwrap();
assert_eq!(done.len(), 1);
// Orphan marking drops the message from the projection.
store.mark_failed(history[0].id).await.unwrap();
assert_eq!(store.load(frame).await.unwrap().len(), 1);
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn summaries_round_trip() {
let (pool, store, conv, path) = setup("hist-sum").await;
let frame = store.open_frame(&conv, None, FrameSpec::root("assistant")).await.unwrap();
let m1 = store.append(frame, NewMessage::user("old")).await.unwrap();
store.append(frame, NewMessage::assistant("answer", None)).await.unwrap();
let m3 = store.append(frame, NewMessage::user("new")).await.unwrap();
store
.save_summary(frame, NewSummary { text: "covered".into(), covered_up_to: m1 })
.await
.unwrap();
let latest = store.latest_summary(frame).await.unwrap().unwrap();
assert_eq!(latest.text, "covered");
assert_eq!(latest.covered_up_to, m1);
let since = store.load_since(frame, latest.covered_up_to).await.unwrap();
assert_eq!(since.len(), 2);
assert_eq!(since[1].id, m3);
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn system_role_is_rejected() {
let (pool, store, conv, path) = setup("hist-sys").await;
let frame = store.open_frame(&conv, None, FrameSpec::root("assistant")).await.unwrap();
let msg = NewMessage {
role: Role::System,
content: "nope".into(),
synthetic: true,
reasoning: None,
metadata: None,
};
assert!(store.append(frame, msg).await.is_err());
pool.close().await;
cleanup(&path);
}
}
@@ -0,0 +1,107 @@
//! Skald's `LoopHooks` — the two app-specific things that happen around the
//! loop, neither of which the kernel should know about:
//!
//! - [`SkaldWritePreviewHook`]: the file-write diff bracket (pre: capture the
//! old content; post: the new one, persisted via `set_call_extras`).
//! - [`DtlReanchorHook`]: after a compaction, move dynamic-tool activations off
//! the messages that just went away.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use agent_loop::events::PendingToolCall;
use agent_loop::hooks::{HookCtx, LoopHooks};
use agent_loop::ids::{FrameId, MessageId};
use agent_loop::store::CallOutcome;
use serde_json::json;
use sqlx::SqlitePool;
use tracing::warn;
use crate::loop_adapters::preview::{PreviewContext, cap_preview, read_current_content};
use crate::tools::is_file_write_tool;
/// Captures before/after snapshots around file-write tools so the diff
/// renders inline and survives a reload.
pub struct SkaldWritePreviewHook {
ctx: PreviewContext,
/// old-content captured in `pre_tool_call`, consumed in `post_tool_call`.
pending: Mutex<HashMap<i64, Option<String>>>,
}
impl SkaldWritePreviewHook {
pub fn new(ctx: PreviewContext) -> Self {
Self { ctx, pending: Mutex::new(HashMap::new()) }
}
}
#[agent_loop::async_trait]
impl LoopHooks for SkaldWritePreviewHook {
async fn pre_tool_call(&self, call: &mut PendingToolCall, _ctx: &HookCtx) -> agent_loop::hooks::HookVerdict {
if is_file_write_tool(&call.name)
&& let Some(path) = call.arguments["path"].as_str()
{
let old = cap_preview(read_current_content(&self.ctx, path).await);
self.pending.lock().unwrap().insert(call.id.get(), old);
}
agent_loop::hooks::HookVerdict::Allow
}
async fn post_tool_call(&self, call: &PendingToolCall, outcome: &CallOutcome, ctx: &HookCtx) {
let Some(old) = self.pending.lock().unwrap().remove(&call.id.get()) else {
return;
};
let Some(path) = call.arguments["path"].as_str() else {
return;
};
// `new` is captured only on success — a failed/cancelled write shows
// no diff (the file may not exist in its intended form).
let new = if matches!(outcome, CallOutcome::Completed(_)) {
cap_preview(read_current_content(&self.ctx, path).await)
} else {
None
};
let _ = ctx
.store
.set_call_extras(call.id, json!({ "preview_old": old, "preview_new": new }))
.await;
}
}
// ── DtlReanchorHook ──────────────────────────────────────────────────────────
/// Keeps dynamic tool loading working across a compaction.
///
/// An activation is pinned to the message whose round activated it — that is
/// where its `tool_reference` marker or its `system`+`tools` block renders. When
/// compaction summarises that message away, the activation would render nowhere
/// and the model would silently lose tools it had already loaded. Re-anchoring
/// them onto the first surviving message keeps them exactly where the
/// projection can still find them.
///
/// Best-effort: a failure costs the model one re-activation, never a wrong
/// answer, so it is logged rather than propagated.
pub struct DtlReanchorHook {
pool: Arc<SqlitePool>,
}
impl DtlReanchorHook {
pub fn new(pool: Arc<SqlitePool>) -> Self {
Self { pool }
}
}
#[agent_loop::async_trait]
impl LoopHooks for DtlReanchorHook {
async fn on_compacted(&self, frame: FrameId, covered: MessageId, first_surviving: MessageId) {
if let Err(e) = crate::db::activated_tools::reanchor_compacted(
&self.pool,
frame.get(),
covered.get(),
first_surviving.get(),
)
.await
{
warn!(frame = %frame, error = %e, "failed to re-anchor DTL activations after compaction");
}
}
}
@@ -0,0 +1,38 @@
//! `PendingUserInput` → the crate's `LiveInput` (D10 pull-based live input).
use std::sync::Arc;
use agent_loop::manager::LiveInput;
use agent_loop::store::NewMessage;
use crate::session::handler::PendingUserInput;
/// Drains the source's inbox into the running turn: one `NewMessage` per
/// queued user message, attachments/command metadata preserved.
pub struct PendingLiveInput {
inner: Arc<dyn PendingUserInput>,
}
impl PendingLiveInput {
pub fn new(inner: Arc<dyn PendingUserInput>) -> Self { Self { inner } }
}
#[agent_loop::async_trait]
impl LiveInput for PendingLiveInput {
async fn drain(&self) -> Vec<NewMessage> {
self.inner
.drain_user()
.await
.into_iter()
.map(|m| {
let mut msg = NewMessage::user(m.content);
if let Some(meta) = m.metadata
&& let Ok(v) = serde_json::to_value(meta)
{
msg.metadata = Some(v);
}
msg
})
.collect()
}
}
@@ -0,0 +1,346 @@
//! `SkaldMediaSource` — **which** files may reach a model
//! (`agent_loop::projection::MediaSource`).
//!
//! The split with the crate is the §6 containment boundary: the library decides
//! shape, capability and budget; this decides *authorization*, and only files
//! that pass are ever handed over as blobs.
//!
//! Two paths, two rules:
//!
//! - **uploaded attachments** must resolve, through the caller's [`UserFs`],
//! under their `~/uploads/` — where the upload seam writes them. An image
//! sitting anywhere else in the workspace is never inlined just because a
//! message mentions it.
//! - **tool-produced media** must land under one of the caller's workspace
//! roots (home, shared folders, projects, docs). The tool already resolved
//! and contained the path, so this is a fail-closed re-check against a
//! symlink swapped since the read.
//!
//! Both are re-checked here even though the paths came from trusted code: the
//! container is writable by the agent, so any host-side read must re-verify.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use agent_loop::projection::{MediaBlob, MediaSource};
use agent_loop::store::{StoredCall, StoredMessage};
use core_api::message_meta::{Attachment, MessageMetadata, attachments_block};
use core_api::tool::MediaRef;
use core_api::user_fs::{UPLOADS_SUBDIR, UserFs};
use tracing::debug;
/// A contained file, read lazily.
struct FileBlob {
name: String,
/// `None` = failed authorization; every read then returns `None`, so the
/// projection skips it (fail-closed, no panic, no partial inline).
path: Option<PathBuf>,
}
#[agent_loop::async_trait]
impl MediaBlob for FileBlob {
fn name(&self) -> &str {
&self.name
}
async fn size(&self) -> Option<u64> {
let path = self.path.as_ref()?;
tokio::fs::metadata(path).await.ok().map(|m| m.len())
}
async fn head(&self) -> Option<Vec<u8>> {
let path = self.path.as_ref()?;
let mut file = tokio::fs::File::open(path).await.ok()?;
let mut head = [0u8; 16];
let n = tokio::io::AsyncReadExt::read(&mut file, &mut head).await.ok()?;
Some(head[..n].to_vec())
}
async fn read_all(&self) -> Option<Vec<u8>> {
let path = self.path.as_ref()?;
tokio::fs::read(path).await.ok()
}
}
/// The uploads directory, canonicalized for prefix-checking.
fn uploads_root(fs: &UserFs) -> Option<PathBuf> {
std::fs::canonicalize(fs.home_host.join(UPLOADS_SUBDIR)).ok()
}
/// The caller's workspace roots: private home, each shared folder, each project,
/// and the read-only docs mount.
fn workspace_roots(fs: &UserFs) -> Vec<PathBuf> {
let canon =
|p: &Path| crate::tools::fs::canonicalize_for_policy(&p.to_string_lossy(), Path::new("/"));
let mut roots = vec![canon(&fs.home_host)];
for m in &fs.shared {
roots.push(canon(&m.host));
}
for m in &fs.projects {
roots.push(canon(&m.host));
}
if let Some(d) = &fs.docs_host {
roots.push(canon(d));
}
roots
}
/// One blob per attachment, **in attachment order** — an unauthorized one
/// yields a blob that reads as nothing, so positions stay aligned with the
/// caller's list and the projection simply skips it.
pub fn attachment_blobs(fs: &UserFs, attachments: &[Attachment]) -> Vec<Arc<dyn MediaBlob>> {
let root = uploads_root(fs);
attachments
.iter()
.map(|a| {
let path = root.as_ref().and_then(|root| {
let abs = crate::tools::fs::resolve_host_path(fs, &a.path).ok()?;
if abs.starts_with(root) {
Some(abs)
} else {
debug!(path = %a.path, "media not inlined: outside the uploads root");
None
}
});
Arc::new(FileBlob { name: a.name.clone(), path }) as Arc<dyn MediaBlob>
})
.collect()
}
/// Blobs for tool-produced media, dropping anything outside the workspace.
pub fn ref_blobs(fs: &UserFs, refs: &[MediaRef]) -> Vec<Arc<dyn MediaBlob>> {
let roots = workspace_roots(fs);
refs.iter()
.filter_map(|r| {
let canon = crate::tools::fs::canonicalize_for_policy(&r.host_path, Path::new("/"));
if !roots.iter().any(|root| crate::tools::fs::path_under(&canon, root)) {
debug!(path = %r.host_path, "tool media not inlined: outside the workspace");
return None;
}
let name = canon
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
Some(Arc::new(FileBlob { name, path: Some(canon) }) as Arc<dyn MediaBlob>)
})
.collect()
}
/// The caller's media authorization.
pub struct SkaldMediaSource {
fs: Arc<UserFs>,
}
impl SkaldMediaSource {
pub fn new(fs: Arc<UserFs>) -> Self {
Self { fs }
}
/// The attachments a stored message carries, in wire order.
fn attachments(msg: &StoredMessage) -> Vec<Attachment> {
msg.metadata
.as_ref()
.and_then(|v| serde_json::from_value::<MessageMetadata>(v.clone()).ok())
.map(|m| m.attachments)
.unwrap_or_default()
}
}
#[agent_loop::async_trait]
impl MediaSource for SkaldMediaSource {
async fn message_media(&self, msg: &StoredMessage) -> Vec<Arc<dyn MediaBlob>> {
// Positions matter: `skipped_text` indexes this same list.
attachment_blobs(&self.fs, &Self::attachments(msg))
}
async fn call_media(&self, calls: &[StoredCall]) -> Vec<Arc<dyn MediaBlob>> {
// Tool media rides `extras.media` as a JSON string of `MediaRef`s.
let refs: Vec<MediaRef> = calls
.iter()
.filter_map(|c| c.extras["media"].as_str())
.filter_map(|s| serde_json::from_str::<Vec<MediaRef>>(s).ok())
.flatten()
.collect();
ref_blobs(&self.fs, &refs)
}
fn skipped_text(&self, msg: &StoredMessage, skipped: &[usize]) -> Option<String> {
if skipped.is_empty() {
return None;
}
let attachments = Self::attachments(msg);
let left: Vec<Attachment> = skipped
.iter()
.filter_map(|&i| attachments.get(i).cloned())
.collect();
if left.is_empty() {
return None;
}
// The textual path block: the agent can still read these with a tool.
Some(attachments_block(&left))
}
}
#[cfg(test)]
mod tests {
//! What may be inlined — the §6 half. The library's budgets and part shapes
//! are tested in `agent_loop::projection::media`; these assert the
//! authorization: uploads only, workspace only, fail-closed on traversal.
use super::*;
use agent_loop::projection::{MediaBudget, media::partition};
fn att(path: &str) -> Attachment {
Attachment {
path: path.to_string(),
name: path.rsplit('/').next().unwrap().to_string(),
mimetype: None,
filesize: None,
}
}
fn png_bytes() -> Vec<u8> {
let mut v = b"\x89PNG\r\n\x1a\n".to_vec();
v.extend_from_slice(&[0xAA; 64]);
v
}
fn pdf_bytes() -> Vec<u8> {
let mut v = b"%PDF-1.7\n".to_vec();
v.extend_from_slice(&[0x00; 64]);
v
}
fn caps(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
/// A throwaway [`UserFs`] whose private home is `root/homes/u1`.
fn fs_home(home: &Path) -> UserFs {
UserFs::new(
"u1",
home.to_path_buf(),
"skald-u1",
PathBuf::from("/root"),
vec![],
vec![],
None,
)
}
/// `(inlined parts, skipped positions)` for a message's attachments.
async fn inline(
attachments: &[Attachment],
capabilities: &[String],
fs: &UserFs,
) -> (Vec<serde_json::Value>, Vec<usize>) {
let blobs = attachment_blobs(fs, attachments);
partition(&blobs, capabilities, &MediaBudget::default()).await
}
#[tokio::test]
async fn an_uploaded_png_reaches_a_vision_model() {
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
let home = tmp.join("homes/u1");
let dir = home.join("uploads/1");
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("a.png"), png_bytes()).await.unwrap();
let fs = fs_home(&home);
let (parts, skipped) = inline(&[att("uploads/1/a.png")], &caps(&["vision"]), &fs).await;
assert!(skipped.is_empty());
assert_eq!(parts.len(), 1);
assert!(
parts[0]["image_url"]["url"]
.as_str()
.unwrap()
.starts_with("data:image/png;base64,")
);
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test]
async fn only_the_uploads_directory_is_authorized() {
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
let home = tmp.join("homes/u1");
let dir = home.join("uploads/1");
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("a.png"), png_bytes()).await.unwrap();
// A real image inside the home but OUTSIDE the uploads dir.
tokio::fs::write(home.join("secret.png"), png_bytes()).await.unwrap();
let fs = fs_home(&home);
// No capability → everything stays textual.
let (parts, skipped) = inline(&[att("uploads/1/a.png")], &caps(&[]), &fs).await;
assert_eq!(skipped.len(), 1);
assert!(parts.is_empty());
// An image elsewhere in the home is never inlined…
let (parts, skipped) = inline(&[att("secret.png")], &caps(&["vision"]), &fs).await;
assert_eq!(skipped.len(), 1);
assert!(parts.is_empty());
// …and traversal out of the workspace is rejected fail-closed.
let (parts, skipped) =
inline(&[att("uploads/../../secret.png")], &caps(&["vision"]), &fs).await;
assert_eq!(skipped.len(), 1);
assert!(parts.is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test]
async fn a_pdf_needs_the_document_capability() {
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
let home = tmp.join("homes/u1");
let dir = home.join("uploads/1");
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("a.pdf"), pdf_bytes()).await.unwrap();
let fs = fs_home(&home);
let (parts, _) = inline(&[att("uploads/1/a.pdf")], &caps(&["document"]), &fs).await;
assert_eq!(parts[0]["type"], "file");
assert_eq!(parts[0]["file"]["filename"], "a.pdf");
// vision alone does not unlock PDFs.
let (_, skipped) = inline(&[att("uploads/1/a.pdf")], &caps(&["vision"]), &fs).await;
assert_eq!(skipped.len(), 1);
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test]
async fn tool_media_is_contained_to_the_workspace() {
let tmp = std::env::temp_dir().join(format!("skald-toolmedia-{}", uuid::Uuid::new_v4()));
let home = tmp.join("homes/u1");
tokio::fs::create_dir_all(&home).await.unwrap();
tokio::fs::write(home.join("pic.png"), png_bytes()).await.unwrap();
tokio::fs::write(tmp.join("outside.png"), png_bytes()).await.unwrap();
let fs = fs_home(&home);
let inside = MediaRef {
host_path: home.join("pic.png").to_string_lossy().into_owned(),
mime: "image/png".into(),
};
let outside = MediaRef {
host_path: tmp.join("outside.png").to_string_lossy().into_owned(),
mime: "image/png".into(),
};
let refs = |r: &MediaRef| ref_blobs(&fs, std::slice::from_ref(r));
let (parts, _) =
partition(&refs(&inside), &caps(&["vision"]), &MediaBudget::default()).await;
assert_eq!(parts.len(), 1);
assert_eq!(parts[0]["type"], "image_url");
// No capability → nothing inlined.
let (parts, _) = partition(&refs(&inside), &caps(&[]), &MediaBudget::default()).await;
assert!(parts.is_empty());
// A real image outside the workspace never becomes a blob at all.
assert!(ref_blobs(&fs, std::slice::from_ref(&outside)).is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
}
@@ -0,0 +1,48 @@
//! Skald-side adapters implementing the `agent-loop` trait surface: everything
//! the library asks a host for, answered the way Skald does it. The loop itself
//! — rounds, projection, delegation, recovery, compaction — is the crate's.
//!
//! - [`history::SqliteHistory`] — `HistoryStore` over the existing
//! `chat_sessions_stack` / `chat_history` / `chat_llm_tools` / `chat_summaries`
//! tables (no migration, §0).
//! - [`selector::SkaldSelector`] — `ModelSelector` over `LlmManager`, with the
//! agent's strength captured per-turn (D14).
//! - [`gate::ApprovalGate`] — `Gate` over `ApprovalManager` + the RunContext
//! fast-path + auto-deny + pre-approved (port of `handler/gate.rs`).
//! - [`toolset::SkaldToolSet`] — `ToolSet` over base/config defs + MCP grants +
//! memory/image/interface tools, with DTL rendering (port of
//! `AgentRunConfig::all_tool_defs`), plus the core-api→agent-loop tool bridge.
//! - [`activation`] — `ActivationSource` + `ToolActivator` over the
//! `activated_tools` table and the MCP provider (D15).
//! - [`projection_cfg`] — the wire knobs Skald's models need, handed to the
//! library's projection engine, plus the assembler every turn runs on.
//! Skald owns no projection code: [`media_source`] authorizes which files may
//! be inlined (§6 containment) and [`tool_digest`] condenses an over-long
//! tool result — the library does the shaping.
//! - [`async_task`] — `execute_task mode=async` as a durable cron job, and the
//! delivery of its result back into the parent conversation (§7.2).
//! - [`runtime::UserLoopRuntime`] — the one `LoopManager` per user (D12) these
//! are all assembled into, plus the per-turn parameters.
pub mod activation;
pub mod async_task;
pub mod builtins;
pub mod catalog;
pub mod gate;
pub mod history;
pub mod hooks;
pub mod live_input;
pub mod media_source;
pub mod preview;
#[cfg(test)]
mod projection_snapshots;
pub mod scope;
pub mod projection_cfg;
pub mod runtime;
pub mod selector;
pub mod system;
#[cfg(test)]
mod testkit;
pub mod tool_digest;
pub mod toolset;
pub mod translate;
@@ -0,0 +1,100 @@
//! File-write diff preview, shared by the approval gate (pre-approval diff)
//! and the write-preview hook (executed-write diff). Routes memory-vs-disk
//! exactly like the fs-tools.
use std::sync::Arc;
use core_api::user_fs::SharedFs;
use sqlx::SqlitePool;
use crate::tools::fs::{MemScope, classify_memory, resolve_host_path};
/// Max bytes captured per side of a file-write diff preview. Beyond this the
/// side is dropped (`None`) so a huge file never bloats a row or a WS payload.
pub const MAX_PREVIEW_BYTES: usize = 256 * 1024;
/// Drops a captured snapshot over the size cap (a truncated snapshot would
/// render a misleading diff).
pub fn cap_preview(s: Option<String>) -> Option<String> {
s.filter(|c| c.len() <= MAX_PREVIEW_BYTES)
}
/// The pieces a preview read needs: owner pool (user-memory), shared pool
/// (shared-memory), and the caller's fs view (host paths).
#[derive(Clone)]
pub struct PreviewContext {
pub pool: Arc<SqlitePool>,
pub shared_pool: Arc<SqlitePool>,
pub fs: Option<SharedFs>,
}
/// Reads the current content of a file for a diff, routed exactly like the
/// fs-tools. A resolve failure or a missing note/file yields `None`
/// (rendered as "new file").
pub async fn read_current_content(ctx: &PreviewContext, path: &str) -> Option<String> {
if let Some(m) = classify_memory(path) {
let pool = match m.scope {
MemScope::User => &ctx.pool,
MemScope::Shared => &ctx.shared_pool,
};
return crate::db::memory_docs::get(pool, &m.rel)
.await.ok().flatten().map(|d| d.content);
}
let fs = ctx.fs.as_ref()?;
let abs = resolve_host_path(&fs.load(), path).ok()?;
tokio::fs::read_to_string(&abs).await.ok()
}
/// Computes what a file would look like after the tool runs, without writing
/// it. `None` if indeterminable (e.g. edit on a missing file).
pub async fn compute_new_content(ctx: &PreviewContext, name: &str, args: &serde_json::Value) -> Option<String> {
match name {
"write_file" => args["content"].as_str().map(|s| s.to_string()),
"edit_file" => {
let path = args["path"].as_str()?;
let old_text = args["old"].as_str()?;
let new_text = args["new"].as_str()?;
let current = read_current_content(ctx, path).await?;
if current.contains(old_text) {
Some(current.replacen(old_text, new_text, 1))
} else {
None
}
}
"insert_at_line" => {
let path = args["path"].as_str()?;
let line_num = args["line"].as_u64()? as usize;
let new_text = args["content"].as_str()?;
let placement = args["placement"].as_str().unwrap_or("after");
if line_num == 0 { return None; }
let current = read_current_content(ctx, path).await?;
let mut lines: Vec<&str> = current.split('\n').collect();
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
let insert_idx = if placement == "before" { idx } else { idx + 1 };
let new_lines: Vec<&str> = new_text.split('\n').collect();
for (i, l) in new_lines.iter().enumerate() {
lines.insert(insert_idx + i, l);
}
Some(lines.join("\n"))
}
"replace_lines" => {
let path = args["path"].as_str()?;
let from_line = args["from_line"].as_u64()? as usize;
let to_line = args["to_line"].as_u64()? as usize;
let new_text = args["new"].as_str()?;
if from_line == 0 || to_line < from_line { return None; }
let current = read_current_content(ctx, path).await?;
let mut lines: Vec<&str> = current.lines().collect();
let total = lines.len();
if from_line > total { return None; }
let to_clamped = to_line.min(total);
let new_lines: Vec<&str> = new_text.lines().collect();
lines.splice((from_line - 1)..to_clamped, new_lines);
let has_trailing = current.ends_with('\n');
let mut result = lines.join("\n");
if has_trailing { result.push('\n'); }
Some(result)
}
_ => None,
}
}
@@ -0,0 +1,87 @@
//! Skald's projection configuration — the only place the app states what its
//! models need on the wire. The projection engine itself is the library's
//! (`agent_loop::projection`); this is the set of knobs, in one place, so a
//! provider quirk is a value change and not a code change.
use std::sync::Arc;
use agent_loop::activation::ActivationSource;
use agent_loop::context::LinearAssembler;
use agent_loop::projection::{MediaBudget, Projection, ReasoningEcho, ResultLimit};
use core_api::user_fs::UserFs;
use crate::compactor::SUMMARY_PREFIX;
use crate::loop_adapters::media_source::SkaldMediaSource;
use crate::loop_adapters::tool_digest::SkaldDigest;
use crate::tools::tool_names as tn;
/// Where the summary block ends and full history resumes.
const SUMMARY_SUFFIX: &str =
"[End of context summary — the following messages are the most recent exchanges in full.]";
/// A call still `running`/`pending` at projection time died mid-flight: the
/// wording tells the model it may retry, which a bare "interrupted" would not.
const INTERRUPTED: &str = "Error: tool call was interrupted (connection lost before user approval). \
Please retry the operation.";
/// The knobs Skald's model fleet needs.
///
/// - `max_history_messages` applies **only without compaction**: with the
/// compactor on, the summary is what bounds the context, and a window on top
/// of it would silently drop messages the summary does not cover.
/// - tool results are shrunk for previous turns only, so the in-flight turn
/// always sees its own output in full.
pub fn skald_projection(
max_history_messages: usize,
compaction_enabled: bool,
max_tool_result_chars: Option<usize>,
) -> Projection {
Projection {
summary_prefix: SUMMARY_PREFIX.to_string(),
summary_suffix: Some(SUMMARY_SUFFIX.to_string()),
max_messages: (!compaction_enabled).then_some(max_history_messages),
max_tool_result: max_tool_result_chars.map(|max_chars| ResultLimit {
max_chars,
previous_turns_only: true,
}),
interrupted_text: INTERRUPTED.to_string(),
rejected_default: "User rejected this tool call.".to_string(),
cancelled_default: "Tool call was cancelled by the user.".to_string(),
// DeepSeek's thinking mode rejects a replayed tool-calling turn whose
// reasoning_content is empty.
reasoning_placeholder: Some("(no reasoning recorded for this step)".to_string()),
// Some endpoints read `reasoning_content`, others `reasoning`; neither
// rejects the extra key, so Skald sends both.
reasoning_echo: ReasoningEcho::Both,
tail_separator: "\n\n---\n".to_string(),
media: MediaBudget::default(),
// The DTL marker belongs on the activation's own result, not on
// whichever tool result happens to come first in the round.
activation_anchor_tool: Some(tn::ACTIVATE_TOOLS.to_string()),
}
}
/// The assembler every Skald turn runs on: the configuration above plus the two
/// content hooks. `fs` is the caller's filesystem view — without it media is
/// never inlined (nothing can be authorized), which is the right default for a
/// context with no user workspace.
pub fn skald_assembler(
activation: Arc<dyn ActivationSource>,
fs: Option<Arc<UserFs>>,
max_history_messages: usize,
compaction_enabled: bool,
max_tool_result_chars: Option<usize>,
) -> LinearAssembler {
let mut assembler = LinearAssembler::new()
.with_projection(skald_projection(
max_history_messages,
compaction_enabled,
max_tool_result_chars,
))
.with_activation(activation)
.with_digest(Arc::new(SkaldDigest));
if let Some(fs) = fs {
assembler = assembler.with_media(Arc::new(SkaldMediaSource::new(fs)));
}
assembler
}
@@ -0,0 +1,152 @@
//! The projection's regression net **in the context of Skald**: a real owner
//! database, a real `UserFs`, real DTL rendering — asserted against the wire
//! arrays stored under `snapshots/`.
//!
//! The stored arrays were **frozen while the old `MessageBuilder` still ran
//! beside the new projection and a parity harness asserted they matched**, so
//! each one is a byte-for-byte record of what Skald sent before the projection
//! moved into the library. The harness died with the builder; the record is
//! what survives it.
//!
//! A failure here means the bytes a model receives changed. That is either a
//! bug or a deliberate change; if deliberate, rerun with
//! `UPDATE_PROJECTION_SNAPSHOTS=1` and **review the diff**.
//!
//! The state seeded per scenario lives in [`super::testkit`].
#![cfg(test)]
use serde_json::{Value, json};
use crate::llm::DtlMode;
use crate::loop_adapters::testkit::{
self, AgentFixture, Case, Db, MediaHome, TOOL_RESULT_LIMIT, assert_snapshot, project,
};
#[tokio::test]
async fn snapshot_plain_conversation() {
let agent = AgentFixture::new();
let db = Db::new("snap-plain").await;
testkit::seed_plain(&db).await;
let wire = project(&db, &agent, &Case::default()).await;
assert_snapshot("plain_conversation", &wire);
// Sanity: the fixture really produced the layers the snapshot means to pin.
assert!(wire.len() >= 5, "{wire:#?}");
}
#[tokio::test]
async fn snapshot_scratchpad_and_cache_hints() {
let agent = AgentFixture::new();
let db = Db::new("snap-scratch").await;
testkit::seed_scratchpad(&db).await;
let wire = project(&db, &agent, &Case { cache_hints: true, ..Case::default() }).await;
assert_snapshot("scratchpad_and_cache_hints", &wire);
assert!(
wire[0]["content"][0]["cache_control"].is_object(),
"the cache breakpoint must be on the static prefix: {:#?}",
wire[0]
);
assert!(wire[1]["content"].as_str().unwrap().contains("<scratchpad>"));
}
#[tokio::test]
async fn snapshot_tool_round_every_state() {
let agent = AgentFixture::new();
let db = Db::new("snap-tools").await;
testkit::seed_tool_round(&db).await;
let wire = project(&db, &agent, &Case::default()).await;
assert_snapshot("tool_round_every_state", &wire);
}
#[tokio::test]
async fn snapshot_interrupted_call_survives_a_restart() {
let agent = AgentFixture::new();
let db = Db::new("snap-interrupted").await;
testkit::seed_interrupted(&db).await;
let wire = project(&db, &agent, &Case::default()).await;
assert_snapshot("interrupted_call", &wire);
let tool_msg = wire.iter().find(|m| m["role"] == "tool").unwrap();
assert!(tool_msg["content"].as_str().unwrap().contains("interrupted"));
}
#[tokio::test]
async fn snapshot_condensed_previous_turn_results() {
let agent = AgentFixture::new();
let db = Db::new("snap-condense").await;
testkit::seed_condensed(&db).await;
let wire = project(&db, &agent, &Case::default()).await;
assert_snapshot("condensed_previous_turn", &wire);
let results: Vec<&str> = wire
.iter()
.filter(|m| m["role"] == "tool")
.map(|m| m["content"].as_str().unwrap())
.collect();
assert_eq!(results[0], "[read_file] read big.txt (120 chars)");
assert_eq!(results[1].len(), TOOL_RESULT_LIMIT * 3, "the current turn keeps its output");
}
#[tokio::test]
async fn snapshot_with_a_compaction_summary() {
let agent = AgentFixture::new();
let db = Db::new("snap-summary").await;
testkit::seed_summary(&db).await;
let wire = project(&db, &agent, &Case::default()).await;
assert_snapshot("compaction_summary", &wire);
assert!(
wire.iter().any(|m| {
m["content"]
.as_str()
.is_some_and(|c| c.contains(crate::compactor::SUMMARY_PREFIX))
}),
"the summary block must carry Skald's own prefix: {wire:#?}"
);
}
#[tokio::test]
async fn snapshot_dtl_all_three_modes() {
let agent = AgentFixture::new();
let db = Db::new("snap-dtl").await;
testkit::seed_activation(&db).await;
for (dtl, name) in [
(DtlMode::None, "dtl_none"),
(DtlMode::AnthropicToolReference, "dtl_anthropic_tool_reference"),
(DtlMode::KimiSystemTools, "dtl_kimi_system_tools"),
] {
let wire = project(&db, &agent, &Case { dtl, ..Case::default() }).await;
assert_snapshot(name, &wire);
// The marker rides the activation's own result, not whichever tool
// result happens to come first in the round.
if dtl == DtlMode::AnthropicToolReference {
let tools: Vec<&Value> = wire.iter().filter(|m| m["role"] == "tool").collect();
assert!(tools[0].get("_tool_references").is_none());
assert_eq!(tools[1]["_tool_references"], json!(["mcp__gmail__send"]));
}
}
}
#[tokio::test]
async fn snapshot_inlined_attachment() {
let agent = AgentFixture::new();
let db = Db::new("snap-media").await;
let home = MediaHome::new();
testkit::seed_media(&db).await;
let wire = project(&db, &agent, &Case {
capabilities: vec!["vision".into()],
fs: Some(home.fs.clone()),
..Case::default()
})
.await;
assert_snapshot("inlined_attachment", &wire);
let current = wire.iter().rev().find(|m| m["role"] == "user").unwrap();
assert_eq!(current["content"][1]["type"], "image_url");
}
@@ -0,0 +1,408 @@
//! `UserLoopRuntime` — the loop stack of one user, built once.
//!
//! Everything that lives as long as the owner's pool lives here: the
//! `LoopManager` (event bus + live-loop registry), the history store, the
//! approval gate, the hooks, the agent catalog and the delegate tool. A turn
//! then contributes only what is genuinely its own — the agent's prompt, its
//! tool set, its model pin — through [`UserLoopRuntime::turn_params`].
//!
//! Why one per user and not one per turn (blueprint D12): the manager's job is
//! the *global* view — which conversations are running, `/stop`, recovery,
//! shutdown. A manager rebuilt for every message can answer none of those, and
//! rebuilding the graph per message also leaks it (the catalog ↔ delegate cycle
//! is broken by a `Weak`, but a per-turn graph would still pile up).
use std::sync::Arc;
use agent_loop::activation::ActivateToolsTool;
use agent_loop::delegate::DelegateTool;
use agent_loop::ids::ConversationId;
use agent_loop::manager::{LiveInput, LoopManager, TurnMeta, TurnParams};
use agent_loop::model::{ModelHint, ModelSelector};
use agent_loop::store::HistoryStore;
use agent_loop::tool::{Extensions, Tool as LoopTool, ToolSet};
use core_api::interface_tool::InterfaceTool;
use core_api::user_fs::SharedFs;
use serde_json::Value;
use sqlx::SqlitePool;
use crate::approval::ApprovalManager;
use crate::clarification::ClarificationManager;
use crate::config::DatetimeConfig;
use crate::llm::LlmManager;
use crate::loop_adapters::activation::{SkaldActivationSource, SkaldToolActivator};
use crate::loop_adapters::async_task::CronExecutor;
use crate::loop_adapters::builtins::{
ExecuteTaskAliasTool, LegacyInterfaceTool, SkaldAskUserTool, SkaldHumanChannel,
UpdateScratchpadTool, WriteTodosTool,
};
use crate::loop_adapters::catalog::SkaldAgentCatalog;
use crate::loop_adapters::gate::ApprovalGate;
use crate::loop_adapters::history::SqliteHistory;
use crate::loop_adapters::hooks::{DtlReanchorHook, SkaldWritePreviewHook};
use crate::loop_adapters::live_input::PendingLiveInput;
use crate::loop_adapters::preview::PreviewContext;
use crate::loop_adapters::projection_cfg::skald_assembler;
use crate::loop_adapters::scope::TurnScope;
use crate::loop_adapters::selector::SkaldSelector;
use crate::loop_adapters::system::AgentSystemContext;
use crate::loop_adapters::toolset::{CallerUserId, SkaldToolSet};
use crate::mcp::McpProvider;
use crate::session::handler::PendingUserInput;
use crate::session::handler::interface_tools::AgentRunConfig;
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
use crate::tools::tool_names as tn;
/// Instance-wide loop limits (from `config.yml`).
#[derive(Clone)]
pub struct LoopConfig {
pub max_rounds: usize,
pub max_parallel_calls: usize,
pub max_history_messages: usize,
pub max_tool_result_chars: Option<usize>,
/// Compaction bounds the context instead of a message window.
pub compaction_enabled: bool,
pub datetime: DatetimeConfig,
pub max_agent_depth: u32,
}
/// Names handled natively; a legacy interface tool of the same name is dropped.
const NATIVE_NAMES: &[&str] = &[tn::ACTIVATE_TOOLS, tn::EXECUTE_TASK];
/// One user's loop stack.
pub struct UserLoopRuntime {
manager: Arc<LoopManager>,
store: Arc<dyn HistoryStore>,
catalog: Arc<SkaldAgentCatalog>,
delegate: Arc<DelegateTool>,
/// Backs `execute_task mode=async`; its `TaskManager` lands at wiring time.
async_exec: Arc<CronExecutor>,
// per-turn assembly material
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
fs: SharedFs,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
llm_manager: Arc<LlmManager>,
clarification: Arc<ClarificationManager>,
tool_discovery: Arc<ToolDiscovery>,
config: LoopConfig,
}
/// What a turn contributes on top of the runtime.
pub struct TurnInputs<'a> {
pub scope: Arc<TurnScope>,
pub config: &'a AgentRunConfig,
/// Messages queued while the turn runs, drained at round boundaries.
pub live_input: Option<Arc<dyn PendingUserInput>>,
}
impl UserLoopRuntime {
#[allow(clippy::too_many_arguments)]
pub fn build(
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
fs: SharedFs,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
llm_manager: Arc<LlmManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
tool_discovery: Arc<ToolDiscovery>,
config: LoopConfig,
) -> anyhow::Result<Arc<Self>> {
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
let gate = ApprovalGate::new(
approval.clone(),
store.clone(),
tools.clone(),
pool.clone(),
shared_pool.clone(),
Some(fs.clone()),
);
let preview_hook = Arc::new(SkaldWritePreviewHook::new(PreviewContext {
pool: pool.clone(),
shared_pool: shared_pool.clone(),
fs: Some(fs.clone()),
}));
// The default selector has no strength requirement; every turn overrides
// it with the agent's own (D14).
let default_selector: Arc<dyn ModelSelector> =
Arc::new(SkaldSelector::new(llm_manager.clone(), None));
let manager = Arc::new(
LoopManager::builder()
.models(default_selector)
.store(store.clone())
.gate_arc(Arc::new(gate))
.hook(preview_hook)
.hook(Arc::new(DtlReanchorHook::new(pool.clone())))
.max_rounds(config.max_rounds)
.max_parallel_calls(config.max_parallel_calls)
.build()?,
);
let catalog = Arc::new(SkaldAgentCatalog::new(
pool.clone(),
shared_pool.clone(),
user_id.clone(),
llm_manager.clone(),
approval,
clarification.clone(),
mcp.clone(),
tools.clone(),
fs.clone(),
config.clone(),
));
// `mode: "async"` runs as a durable cron job; the manager behind it is
// set at wiring time (see `CronExecutor`).
let async_exec = Arc::new(CronExecutor::new());
let delegate = Arc::new(
DelegateTool::new(
manager.clone(),
catalog.clone(),
store.clone(),
config.max_agent_depth,
)
.with_async(async_exec.clone()),
);
// The catalog hands `execute_subtask` to children; it holds this Weak.
catalog.set_delegate(&delegate);
Ok(Arc::new(Self {
manager,
store,
catalog,
delegate,
async_exec,
pool,
shared_pool,
user_id,
fs,
tools,
mcp,
llm_manager,
clarification,
tool_discovery,
config,
}))
}
pub fn manager(&self) -> &Arc<LoopManager> {
&self.manager
}
/// Hands the user's `TaskManager` to the async executor. Called once the
/// cron side exists (it needs the session manager that owns this runtime).
pub fn set_task_manager(&self, tasks: Arc<crate::cron::TaskManager>) {
self.async_exec.set_task_manager(tasks);
}
pub fn store(&self) -> &Arc<dyn HistoryStore> {
&self.store
}
/// The conversation id of a session — the store's encoding.
pub fn conversation(session_id: i64) -> ConversationId {
SqliteHistory::conversation(session_id)
}
/// Everything a turn needs, assembled from the run config and the scope.
pub async fn turn_params(&self, inputs: TurnInputs<'_>) -> anyhow::Result<TurnParams> {
let TurnInputs { scope, config, live_input } = inputs;
let frame_agent = config.agent_id.clone();
// ── System context ──
let system = Arc::new(AgentSystemContext {
agent_id: frame_agent.clone(),
extra_static: config.extra_system.clone(),
extra_dynamic: config.extra_system_dynamic.clone(),
tail_reminder: config.tail_reminder.clone(),
substitutions: config.system_substitutions.clone(),
pool: self.pool.clone(),
shared_pool: self.shared_pool.clone(),
user_id: self.user_id.clone(),
mcp: self.mcp.clone(),
project_root: scope.project_root.clone(),
scratchpad_sid: scope.scratchpad_sid,
datetime: self.config.datetime.clone(),
});
// ── Tool set: the native tools, then the surface's legacy ones ──
let tools = self.build_toolset(&scope, config);
// ── Assembler: the shared projection, scoped to this session's DTL ──
let assembler = Arc::new(skald_assembler(
Arc::new(SkaldActivationSource::new(
self.pool.clone(),
self.mcp.clone(),
scope.config_defs.clone(),
scope.session_id,
None,
)),
Some(self.fs.load()),
self.config.max_history_messages,
self.config.compaction_enabled,
self.config.max_tool_result_chars,
));
// ── Extensions: the tool bridge's context + the turn's own scope ──
let mut extensions = Extensions::new();
extensions.insert(self.pool.clone());
extensions.insert(self.fs.load());
extensions.insert(Arc::new(CallerUserId(self.user_id.clone())));
extensions.insert(scope.clone());
// ── Selector: this agent's strength (D14) ──
let strength = crate::agents::load_meta(&frame_agent).ok().and_then(|m| m.strength);
let selector: Arc<dyn ModelSelector> =
Arc::new(SkaldSelector::new(self.llm_manager.clone(), strength));
// The session's root frame; the store reuses the provisioned row.
let frame = self
.store
.open_frame(
&Self::conversation(scope.session_id),
None,
agent_loop::store::FrameSpec::root(&frame_agent),
)
.await?;
Ok(TurnParams {
frame,
agent: frame_agent,
system,
tools,
model_hint: ModelHint::name(config.client_name.clone()),
selector: Some(selector),
live_input: live_input
.map(|p| Arc::new(PendingLiveInput::new(p)) as Arc<dyn LiveInput>),
extensions,
meta: TurnMeta {
synthetic: false,
interactive: scope.is_interactive,
context_label: scope.context_label.read().ok().and_then(|g| g.clone()),
user_message: None,
},
assembler: Some(assembler),
})
}
/// The root agent's tool set: natives (activation, delegation, clarification,
/// scratchpad, todos) plus the surface's own interface tools.
fn build_toolset(&self, scope: &Arc<TurnScope>, config: &AgentRunConfig) -> Arc<dyn ToolSet> {
let mut native: Vec<Arc<dyn LoopTool>> = Vec::new();
// activate_tools, sharing the turn's grant set so the next round sees
// whatever this round activated.
native.push(Arc::new(
ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
self.pool.clone(),
self.mcp.clone(),
scope.grants.clone(),
scope.session_id,
None,
)))
.with_definition(crate::session::handler::config::activate_tools_tool_def()),
));
// execute_task: sync/async → the delegate; cron → the scheduling handler.
{
let injected = config
.interface_tools
.iter()
.find(|it| it.definition["function"]["name"].as_str() == Some(tn::EXECUTE_TASK))
.cloned();
let (def, handler) = match injected {
Some(it) => (it.definition.clone(), Some(it.handler.clone())),
None => (legacy_execute_task_def(), None),
};
native.push(Arc::new(ExecuteTaskAliasTool::new(
self.delegate.as_ref().clone().with_name(tn::EXECUTE_TASK),
def,
handler,
)));
}
native.push(Arc::new(SkaldAskUserTool::new(
Arc::new(SkaldHumanChannel::new(
self.clarification.clone(),
scope.session_id,
&scope.agent_id,
&scope.source,
scope.is_interactive,
scope.context_label.clone(),
)),
self.store.clone(),
)));
native.push(Arc::new(UpdateScratchpadTool::new(
self.pool.clone(),
scope.scratchpad_sid,
)));
native.push(Arc::new(WriteTodosTool));
let legacy: Vec<InterfaceTool> = config
.interface_tools
.iter()
.filter(|it| {
let name = it.definition["function"]["name"].as_str().unwrap_or("");
!NATIVE_NAMES.contains(&name)
})
.cloned()
.collect();
for it in &legacy {
native.push(Arc::new(LegacyInterfaceTool::new(it.clone())));
}
Arc::new(
SkaldToolSet::new(
scope.base_defs.as_ref().clone(),
scope.config_defs.clone(),
self.mcp.clone(),
scope.grants.clone(),
scope.memory_tools.as_ref().clone(),
scope.image_tools.as_ref().clone(),
legacy,
self.tools.all_tools(),
)
.with_discovery(self.tool_discovery.clone())
.with_native_all(native),
)
}
/// The catalog, for callers that list dispatchable agents.
pub fn catalog(&self) -> &Arc<SkaldAgentCatalog> {
&self.catalog
}
}
/// Fallback definition for `execute_task` when no interface handler was injected
/// (non-interactive sessions): mirrors the injected one.
fn legacy_execute_task_def() -> Value {
serde_json::json!({
"type": "function",
"function": {
"name": tn::EXECUTE_TASK,
"description": "Execute a task with a sub-agent. mode=sync waits for the result; \
mode=async schedules it in the background.",
"parameters": {
"type": "object",
"properties": {
"agent_id": { "type": "string" },
"prompt": { "type": "string" },
"title": { "type": "string" },
"description": { "type": "string" },
"mode": { "type": "string", "enum": ["sync", "async"] },
"client": { "type": "string" }
},
"required": ["agent_id", "prompt"]
}
}
})
}
@@ -0,0 +1,70 @@
//! `TurnScope` — everything about the turn in flight, published once in the
//! kernel's `Extensions`.
//!
//! The adapters that need it (the approval gate, the agent catalog) live as
//! long as the **user**, not the turn: one `LoopManager` per `UserContext`
//! (blueprint D12) means they cannot capture a session id, a source or a
//! permission group at construction. So they read them from here — the seam the
//! library designed for exactly this (`PendingCall.extensions`,
//! `ToolCtx.extensions`, blueprint §4.6).
//!
//! Everything mutable rides a shared cell, so a change during the turn (a
//! `/stop`-time auto-deny flip, a security-group switch, an `activate_tools`
//! grant) is seen by the adapters without rebuilding anything.
use std::collections::HashSet;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, RwLock};
use serde_json::Value;
use tokio::sync::RwLock as AsyncRwLock;
use crate::run_context::RunContext;
use crate::tools::Tool;
/// The turn's own state. Cheap to build (everything is an `Arc` or a small
/// value) because it is built once per turn.
pub struct TurnScope {
// ── identity ──
pub session_id: i64,
pub source: String,
pub is_interactive: bool,
pub agent_id: String,
/// Scratchpad scope: the session's own id, or the parent's for an async
/// sub-task.
pub scratchpad_sid: i64,
/// Project root (agent path) when this is a project session.
pub project_root: Option<String>,
// ── live cells (shared with the session handler) ──
pub context_label: Arc<RwLock<Option<String>>>,
pub run_context: Arc<AsyncRwLock<Option<RunContext>>>,
/// Security group driving the approval rules.
pub group_id: Option<String>,
/// Calls a human approved through a REST resolve after a restart: the gate
/// lets them through once.
pub pre_approved: Arc<Mutex<HashSet<i64>>>,
/// Surfaces that cannot ask a human deny instead of hanging.
pub auto_deny: Arc<AtomicBool>,
/// MCP servers (plus the reserved `config` group) activated for this turn;
/// `activate_tools` mutates it, and the next round sees the new tools.
pub grants: Arc<RwLock<HashSet<String>>>,
// ── tool material a child agent derives its own set from ──
pub base_defs: Arc<Vec<Value>>,
pub config_defs: Arc<Vec<Value>>,
pub memory_tools: Arc<Vec<Arc<dyn Tool>>>,
pub image_tools: Arc<Vec<Arc<dyn Tool>>>,
pub root_only: Arc<Vec<String>>,
}
impl TurnScope {
/// The scope of the turn a call belongs to.
///
/// Absence is a wiring bug, not a runtime condition — every turn publishes
/// one — so callers fail closed (deny / refuse to delegate) rather than
/// guessing a permissive default.
pub fn from(extensions: &agent_loop::tool::Extensions) -> Option<Arc<Self>> {
extensions.get::<TurnScope>()
}
}
@@ -0,0 +1,171 @@
//! `SkaldSelector` — `ModelSelector` over `LlmManager` (blueprint §10, D14).
//!
//! The agent's required **strength is captured at construction, per-turn** —
//! the crate never sees it: `hint` carries only an explicit pin, and the AUTO
//! path delegates to `LlmManager`'s strength tiering + priority ordering.
use std::sync::Arc;
use agent_loop::activation::ToolRendering;
use agent_loop::async_trait;
use agent_loop::model::{ModelHandle, ModelHint, ModelInfo, ModelSelector};
use agent_loop::ids::ModelId;
use serde_json::Value;
use crate::llm::{DtlMode, LlmEntry, LlmManager, LlmStrength};
/// Maps Skald's per-model DTL mode to the crate's wire protocol (D15).
pub fn tool_rendering_of(dtl: DtlMode) -> ToolRendering {
match dtl {
DtlMode::None => ToolRendering::Inline,
DtlMode::AnthropicToolReference => ToolRendering::DeferredToolReference,
DtlMode::KimiSystemTools => ToolRendering::SystemToolBlock,
}
}
/// Builds the crate-side metadata for a resolved entry. `extras` stays empty:
/// the model's `extra_params` are already baked into the client at build time
/// (they would otherwise be merged into every request body a second time).
pub fn model_info_of(entry: &LlmEntry) -> ModelInfo {
ModelInfo {
prompt_cache: entry.prompt_cache,
capabilities: entry.capabilities.clone(),
tool_rendering: tool_rendering_of(entry.dtl),
extras: Value::Null,
}
}
/// The selector handed to the loop manager for one turn: the manager's
/// strength tiering + health + priority, behind the crate's seam.
pub struct SkaldSelector {
manager: Arc<LlmManager>,
strength: Option<LlmStrength>,
}
impl SkaldSelector {
pub fn new(manager: Arc<LlmManager>, strength: Option<LlmStrength>) -> Self {
Self { manager, strength }
}
}
#[async_trait]
impl ModelSelector for SkaldSelector {
async fn select(&self, hint: &ModelHint, exclude: &[ModelId]) -> agent_loop::Result<ModelHandle> {
let (name, entry) = if exclude.is_empty() {
// First selection of the round: pin (hint.name) or AUTO by strength.
self.manager.resolve(hint.name.as_deref(), self.strength).await?
} else {
// Fallback: next healthy model in tier/priority order, skipping the
// ones already tried. The pin is intentionally dropped (it failed).
let excluded: Vec<&str> = exclude.iter().map(String::as_str).collect();
self.manager.select_excluding(&excluded, self.strength).await?
};
Ok(ModelHandle {
id: name,
model: entry.client.clone(),
info: model_info_of(&entry),
})
}
async fn report_success(&self, id: &ModelId) {
self.manager.mark_success(id).await;
}
async fn report_failure(&self, id: &ModelId, err: &str) {
self.manager.mark_failure(id, err).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::SqlitePool;
fn temp_db_path(tag: &str) -> String {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
p.to_string_lossy().into_owned()
}
fn cleanup(path: &str) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{path}{suffix}"));
}
}
async fn manager_with_two_models(tag: &str) -> (Arc<LlmManager>, Arc<SqlitePool>, String) {
// Building reqwest clients (rustls-no-provider) needs the process-wide
// crypto provider main() installs in production. Idempotent.
let _ = rustls::crypto::ring::default_provider().install_default();
let path = temp_db_path(tag);
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
sqlx::query("INSERT INTO llm_providers (id, name, type, api_key) VALUES (1, 'test', 'open_ai', 'sk-test')")
.execute(&*pool)
.await
.unwrap();
// weak: low strength, better priority; strong: high strength.
sqlx::query("INSERT INTO llm_models (provider_id, model_id, name, strength, priority) VALUES
(1, 'weak-id', 'weak-model', 'low', 10),
(1, 'strong-id', 'strong-model', 'high', 20)")
.execute(&*pool)
.await
.unwrap();
let bus = Arc::new(core_api::system_bus::SystemEventBus::new());
let mut registry = crate::provider::ProviderRegistry::new(bus);
registry.register_builtin(crate::llm::providers::openai::OpenAiProvider);
let manager = LlmManager::new(pool.clone(), Arc::new(registry), false).await.unwrap();
(manager, pool, path)
}
#[tokio::test]
async fn pin_resolves_exact_model() {
let (manager, pool, path) = manager_with_two_models("sel-pin").await;
let sel = SkaldSelector::new(manager, None);
let h = sel.select(&ModelHint::name("weak-model"), &[]).await.unwrap();
assert_eq!(h.id, "weak-model");
assert!(sel.select(&ModelHint::name("nope"), &[]).await.is_err());
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn auto_prefers_exact_strength_then_fallback_excludes() {
let (manager, pool, path) = manager_with_two_models("sel-auto").await;
let sel = SkaldSelector::new(manager, Some(LlmStrength::High));
// AUTO with strength High: the exact-tier model wins despite worse priority.
let h = sel.select(&ModelHint::default(), &[]).await.unwrap();
assert_eq!(h.id, "strong-model");
// Fallback excludes it: the remaining one is served.
let h2 = sel.select(&ModelHint::default(), &["strong-model".to_string()]).await.unwrap();
assert_eq!(h2.id, "weak-model");
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn health_reporting_degrades_and_recovers() {
let (manager, pool, path) = manager_with_two_models("sel-health").await;
let sel = SkaldSelector::new(manager, None);
for _ in 0..5 {
sel.report_failure(&"weak-model".to_string(), "boom").await;
}
sel.report_success(&"weak-model".to_string()).await;
// Still resolvable after recovery.
let h = sel.select(&ModelHint::name("weak-model"), &[]).await.unwrap();
assert_eq!(h.id, "weak-model");
pool.close().await;
cleanup(&path);
}
}
@@ -0,0 +1,26 @@
[
{
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"role": "system"
},
{
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below. This is a handoff from a previous context window — treat it as background reference, NOT as active instructions. Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed. Your current task is identified in the '## Active Task' section of the summary — resume exactly from there. Your system prompt and any injected memory files are ALWAYS authoritative — never deprioritize them due to this compaction note. Respond ONLY to the latest user message that appears AFTER this summary. The current session state (files, config, etc.) may reflect work described here — avoid repeating it:\n\nEarlier they discussed ancient things.\n\n[End of context summary — the following messages are the most recent exchanges in full.]",
"role": "system"
},
{
"content": "old reply",
"role": "assistant"
},
{
"content": "recent",
"role": "user"
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,64 @@
[
{
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"role": "system"
},
{
"content": "first",
"role": "user"
},
{
"content": "reading",
"reasoning": "(no reasoning recorded for this step)",
"reasoning_content": "(no reasoning recorded for this step)",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"path\":\"big.txt\"}",
"name": "read_file"
},
"id": "tc_1",
"type": "function"
}
]
},
{
"content": "[read_file] read big.txt (120 chars)",
"role": "tool",
"tool_call_id": "tc_1"
},
{
"content": "second",
"role": "user"
},
{
"content": "reading",
"reasoning": "(no reasoning recorded for this step)",
"reasoning_content": "(no reasoning recorded for this step)",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"path\":\"other.txt\"}",
"name": "read_file"
},
"id": "tc_2",
"type": "function"
}
]
},
{
"content": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"role": "tool",
"tool_call_id": "tc_2"
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,55 @@
[
{
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"role": "system"
},
{
"content": "use gmail",
"role": "user"
},
{
"content": "activating",
"reasoning": "(no reasoning recorded for this step)",
"reasoning_content": "(no reasoning recorded for this step)",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{}",
"name": "read_file"
},
"id": "tc_1",
"type": "function"
},
{
"function": {
"arguments": "{\"groups\":[\"gmail\"]}",
"name": "activate_tools"
},
"id": "tc_2",
"type": "function"
}
]
},
{
"content": "f",
"role": "tool",
"tool_call_id": "tc_1"
},
{
"_tool_references": [
"mcp__gmail__send"
],
"content": "activated",
"role": "tool",
"tool_call_id": "tc_2"
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,67 @@
[
{
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"role": "system"
},
{
"content": "use gmail",
"role": "user"
},
{
"content": "activating",
"reasoning": "(no reasoning recorded for this step)",
"reasoning_content": "(no reasoning recorded for this step)",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{}",
"name": "read_file"
},
"id": "tc_1",
"type": "function"
},
{
"function": {
"arguments": "{\"groups\":[\"gmail\"]}",
"name": "activate_tools"
},
"id": "tc_2",
"type": "function"
}
]
},
{
"content": "f",
"role": "tool",
"tool_call_id": "tc_1"
},
{
"content": "activated",
"role": "tool",
"tool_call_id": "tc_2"
},
{
"role": "system",
"tools": [
{
"function": {
"description": "[gmail] send mail",
"name": "mcp__gmail__send",
"parameters": {
"type": "object"
}
},
"type": "function"
}
]
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,52 @@
[
{
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"role": "system"
},
{
"content": "use gmail",
"role": "user"
},
{
"content": "activating",
"reasoning": "(no reasoning recorded for this step)",
"reasoning_content": "(no reasoning recorded for this step)",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{}",
"name": "read_file"
},
"id": "tc_1",
"type": "function"
},
{
"function": {
"arguments": "{\"groups\":[\"gmail\"]}",
"name": "activate_tools"
},
"id": "tc_2",
"type": "function"
}
]
},
{
"content": "f",
"role": "tool",
"tool_call_id": "tc_1"
},
{
"content": "activated",
"role": "tool",
"tool_call_id": "tc_2"
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,37 @@
[
{
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"role": "system"
},
{
"content": "old shot\n\n[SYSTEM INFO]\n1 attached file:\n* uploads/1/shot.png",
"role": "user"
},
{
"content": "seen",
"role": "assistant"
},
{
"content": [
{
"text": "new shot",
"type": "text"
},
{
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"
},
"type": "image_url"
}
],
"role": "user"
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,39 @@
[
{
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"role": "system"
},
{
"content": "run it",
"role": "user"
},
{
"content": "running",
"reasoning": "(no reasoning recorded for this step)",
"reasoning_content": "(no reasoning recorded for this step)",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"command\":\"sleep 100\"}",
"name": "execute_cmd"
},
"id": "tc_1",
"type": "function"
}
]
},
{
"content": "Error: tool call was interrupted (connection lost before user approval). Please retry the operation.",
"role": "tool",
"tool_call_id": "tc_1"
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,28 @@
[
{
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"role": "system"
},
{
"content": "hello",
"role": "user"
},
{
"content": "hi there",
"reasoning": "thinking",
"reasoning_content": "thinking",
"role": "assistant"
},
{
"content": "one\n\ntwo",
"role": "user"
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,30 @@
[
{
"content": [
{
"cache_control": {
"type": "ephemeral"
},
"text": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"type": "text"
}
],
"role": "system"
},
{
"content": "<scratchpad>\n <!-- Temporary notes shared by all agents in this session. Not persisted across sessions. -->\n <note key=\"plan\">step one</note>\n</scratchpad>",
"role": "system"
},
{
"content": "go",
"role": "user"
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,78 @@
[
{
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
"role": "system"
},
{
"content": "work",
"role": "user"
},
{
"content": "calling",
"reasoning": "(no reasoning recorded for this step)",
"reasoning_content": "(no reasoning recorded for this step)",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"path\":\"a.md\"}",
"name": "read_file"
},
"id": "tc_1",
"type": "function"
},
{
"function": {
"arguments": "{}",
"name": "write_file"
},
"id": "tc_2",
"type": "function"
},
{
"function": {
"arguments": "{}",
"name": "execute_cmd"
},
"id": "tc_3",
"type": "function"
},
{
"function": {
"arguments": "{}",
"name": "glob"
},
"id": "tc_4",
"type": "function"
}
]
},
{
"content": "content",
"role": "tool",
"tool_call_id": "tc_1"
},
{
"content": "Error: disk full",
"role": "tool",
"tool_call_id": "tc_2"
},
{
"content": "no",
"role": "tool",
"tool_call_id": "tc_3"
},
{
"content": "Cancelled by user.",
"role": "tool",
"tool_call_id": "tc_4"
},
{
"content": "MEMORY BLOCK",
"role": "system"
},
{
"content": "REMEMBER THE RULES",
"role": "system"
}
]
@@ -0,0 +1,500 @@
//! `AgentSystemContext` — **every layer of Skald's system prompt**, as a
//! `SystemContextSource` (blueprint §10). It owns the content; the crate's
//! projection decides where each layer lands on the wire:
//!
//! | layer | wire position |
//! |---|---|
//! | AGENT.md + `inject_memory` + skills index + `extra_system` + substitutions | `base` — the cacheable prefix |
//! | session scratchpad | `extra_static` — a system message before the conversation |
//! | Honcho memory / per-turn overrides, then the date/time block | `dynamic_tail` — joined into the trailing system message |
//! | trailing reminder | `tail_reminder` |
use std::collections::HashMap;
use std::sync::Arc;
use agent_loop::context::{SystemContext, SystemContextSource, TurnInfo};
use sqlx::SqlitePool;
use crate::config::DatetimeConfig;
use crate::mcp::McpProvider;
/// Registry of installed skills, relative to Skald's process cwd. Injected
/// into agents that have `inject_skills` enabled (the default).
const SKILLS_INDEX_PATH: &str = "skills/index.md";
/// The static system content of one agent, resolved per turn.
pub struct AgentSystemContext {
pub agent_id: String,
/// Static extra context (interface formatting rules, e.g. Telegram HTML).
pub extra_static: Option<String>,
/// Dynamic extra context (Honcho memory merged with per-turn overrides),
/// emitted as the dynamic tail.
pub extra_dynamic: Option<String>,
pub tail_reminder: Option<String>,
pub substitutions: HashMap<String, String>,
/// Owner pool (`user-memory/` notes).
pub pool: Arc<SqlitePool>,
/// Shared pool (`shared-memory/`, shared folders, user profile).
pub shared_pool: Arc<SqlitePool>,
pub user_id: String,
pub mcp: Arc<dyn McpProvider>,
/// Project root for `__PROJECT_ROOT__` expansion in `inject_memory`.
pub project_root: Option<String>,
/// Scratchpad scope: the session's own id, or the parent's for an async
/// sub-task (the blackboard is shared by every agent of a session).
pub scratchpad_sid: i64,
pub datetime: DatetimeConfig,
}
#[agent_loop::async_trait]
impl SystemContextSource for AgentSystemContext {
async fn system_context(&self, _turn: &TurnInfo) -> agent_loop::Result<SystemContext> {
let mut static_content = crate::agents::load_prompt(&self.agent_id)?;
let meta = crate::agents::load_meta(&self.agent_id)?;
if !meta.inject_memory.is_empty() {
static_content.push_str(
"\n\n---\nThe following memory files have been loaded automatically. \
You can edit them with `edit_file` or `write_file` using the path shown.\n"
);
for mem_path in &meta.inject_memory {
let (content, display) = self.load_inject_memory(mem_path).await;
match content {
Some(c) => static_content.push_str(&format!(
"\n<memory_file path=\"{display}\">\n{c}\n</memory_file>\n"
)),
None => static_content.push_str(&format!(
"\n<memory_file path=\"{display}\">\n(file not created yet)\n</memory_file>\n"
)),
}
}
}
// Skills index — injected unless the agent opts out. Skipped silently
// when no skills are installed.
if meta.inject_skills {
let (abs, display) = self.resolve_memory_path(SKILLS_INDEX_PATH);
if let Ok(c) = tokio::fs::read_to_string(&abs).await {
static_content.push_str(&format!(
"\n\n---\nInstalled skills you can use (read the linked `SKILL.md` before running a skill):\n\
\n<skills_index path=\"{display}\">\n{c}\n</skills_index>\n"
));
}
}
if let Some(extra) = &self.extra_static {
static_content.push_str("\n\n---\n");
static_content.push_str(extra);
}
if static_content.contains("__MCP_LIST__") {
static_content = static_content.replace("__MCP_LIST__", &self.render_mcp_list());
}
if static_content.contains("__SHARED_FOLDERS__") {
static_content = static_content.replace(
"__SHARED_FOLDERS__",
&render_shared_folders_section(&self.shared_pool, &self.user_id).await?,
);
}
if static_content.contains("__USER_PROFILE__") {
static_content = static_content.replace(
"__USER_PROFILE__",
&render_user_profile_section(&self.shared_pool, &self.user_id).await?,
);
}
for (key, value) in &self.substitutions {
let sentinel = format!("__{key}__");
if static_content.contains(sentinel.as_str()) {
static_content = static_content.replace(sentinel.as_str(), value);
}
}
// The scratchpad sits before the conversation: shared by every agent of
// the session, and re-read every turn (it changes, so it is its own
// message rather than part of the cached prefix).
let extra_static = self.scratchpad_block().await?.into_iter().collect();
// The fresh layers, in the order the model reads them.
let mut dynamic_tail: Vec<String> = Vec::new();
dynamic_tail.extend(self.extra_dynamic.clone());
dynamic_tail.extend(self.datetime_block());
Ok(SystemContext {
base: static_content,
extra_static,
dynamic_tail,
tail_reminder: self.tail_reminder.clone(),
})
}
}
/// OS description (type + version), computed once.
fn os_description() -> &'static str {
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
OS.get_or_init(|| os_info::get().to_string())
}
/// System IANA timezone name, computed once.
fn system_timezone() -> Option<&'static str> {
static TZ: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
TZ.get_or_init(|| iana_time_zone::get_timezone().ok()).as_deref()
}
impl AgentSystemContext {
/// The session scratchpad as an XML block, or `None` when empty.
async fn scratchpad_block(&self) -> agent_loop::Result<Option<String>> {
let notes = crate::db::scratchpad::for_session(&self.pool, self.scratchpad_sid).await?;
if notes.is_empty() {
return Ok(None);
}
let mut s = String::from(
"<scratchpad>\n \
<!-- Temporary notes shared by all agents in this session. Not persisted across sessions. -->\n",
);
for (k, v) in &notes {
s.push_str(&format!(" <note key=\"{k}\">{v}</note>\n"));
}
s.push_str("</scratchpad>");
Ok(Some(s))
}
/// The current date/time + OS + cwd block (`None` when disabled).
///
/// Rounding exists for the prompt cache: a timestamp that changes every
/// second would invalidate any cached suffix, so the instance can quantize
/// it (this block is in the dynamic tail, after the cached prefix, but the
/// rounding still helps providers that cache further).
fn datetime_block(&self) -> Option<String> {
if !self.datetime.enabled {
return None;
}
let secs = chrono::Utc::now().timestamp();
let secs = match self.datetime.round_minutes {
Some(m) if m > 0 => {
let bucket = (m as i64) * 60;
(secs / bucket) * bucket
}
_ => secs,
};
let tz = self
.datetime
.timezone
.as_deref()
.and_then(|s| s.parse::<chrono_tz::Tz>().ok())
.or_else(|| system_timezone().and_then(|s| s.parse::<chrono_tz::Tz>().ok()));
let (formatted, tz_name) = match tz {
Some(tz) => {
use chrono::TimeZone as _;
let f = tz
.timestamp_opt(secs, 0)
.single()
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
.unwrap_or_else(|| {
chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string()
});
(f, Some(tz.name().to_string()))
}
None => {
let f = chrono::DateTime::from_timestamp(secs, 0)
.map(|utc| {
utc.with_timezone(&chrono::Local)
.format("%Y-%m-%dT%H:%M:%S%:z")
.to_string()
})
.unwrap_or_else(|| {
chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string()
});
(f, None)
}
};
let date_line = match tz_name {
Some(name) => format!("Current date and time: {formatted} ({name})"),
None => format!("Current date and time: {formatted}"),
};
// The agent's cwd is always its container home.
let cwd = "~";
Some(format!(
"{date_line}\nOperating system: {}\nWorking directory: {cwd}\n\
Filesystem tools and execute_cmd resolve relative paths against your home directory.",
os_description()
))
}
/// Loads an `inject_memory` entry, returning `(content, display_path)`.
/// Virtual memory paths read from SQLite; everything else is a disk read.
async fn load_inject_memory(&self, mem_path: &str) -> (Option<String>, String) {
use crate::tools::fs::{MemScope, classify_memory};
if let Some(m) = classify_memory(mem_path) {
let pool = match m.scope {
MemScope::User => &self.pool,
MemScope::Shared => &self.shared_pool,
};
let content = crate::db::memory_docs::get(pool, &m.rel)
.await.ok().flatten().map(|d| d.content);
return (content, mem_path.to_string());
}
let (abs, display) = self.resolve_memory_path(mem_path);
(tokio::fs::read_to_string(&abs).await.ok(), display)
}
fn resolve_memory_path(&self, mem_path: &str) -> (std::path::PathBuf, String) {
let display = if mem_path.contains("__PROJECT_ROOT__") {
match &self.project_root {
Some(root) => mem_path.replace("__PROJECT_ROOT__", root),
None => {
tracing::warn!(
mem_path,
"inject_memory entry references __PROJECT_ROOT__ but this session has no project root; skipping"
);
return (std::path::PathBuf::from(mem_path), mem_path.to_string());
}
}
} else {
mem_path.to_string()
};
let abs = crate::tools::fs::resolve(&display)
.unwrap_or_else(|_| std::path::PathBuf::from(&display));
(abs, display)
}
/// The **static** catalogue of loadable MCP servers (identical regardless
/// of which are active — cache-prefix stability).
fn render_mcp_list(&self) -> String {
let all_servers: std::collections::BTreeSet<String> = self.mcp.tools()
.into_iter()
.map(|t| t.server_name)
.collect();
if all_servers.is_empty() {
return String::new();
}
let descriptions = self.mcp.server_descriptions();
let mut out = String::from(
"## MCP servers\n\nConnectors you can load with `activate_tools([\"name\"])`. \
Once loaded, a server's tools are callable as `mcp__<name>__<tool>`:\n\n",
);
out.push_str("| Server | Description |\n|--------|-------------|\n");
for name in &all_servers {
let desc = descriptions.get(name)
.and_then(|d| d.as_deref())
.unwrap_or("");
out.push_str(&format!("| `{name}` | {desc} |\n"));
}
out
}
}
// ── Prompt sections resolved from the registry ───────────────────────────────
/// `__SHARED_FOLDERS__` section, resolved from the registry (shared with the
/// `agent-loop` adapter's system-context source).
pub(crate) async fn render_shared_folders_section(
shared_pool: &SqlitePool,
user_id: &str,
) -> anyhow::Result<String> {
let rows = crate::db::shared_folders::agent_view(shared_pool, user_id).await?;
Ok(render_shared_folders_table(&rows))
}
/// `__USER_PROFILE__` block, resolved from the registry (shared with the
/// `agent-loop` adapter's system-context source).
pub(crate) async fn render_user_profile_section(
shared_pool: &SqlitePool,
user_id: &str,
) -> anyhow::Result<String> {
let user = crate::db::users::get(shared_pool, user_id).await?;
let locale = crate::i18n::resolve_locale(
shared_pool,
user.as_ref().and_then(|u| u.locale.as_deref()),
).await;
Ok(render_user_profile_block(
user.as_ref(),
&locale,
chrono::Utc::now().date_naive(),
))
}
/// Renders the shared-folders section body as a Markdown table — one row per
/// folder the user belongs to, naming the folder's other members so the model
/// knows exactly who sees what is written there. An empty membership yields an
/// explicit "not a member" line so the model does not go probing `shared/` paths.
fn render_shared_folders_table(rows: &[crate::db::shared_folders::SharedFolderAccess]) -> String { /// A free-text cell: single line, pipes escaped (they would split the table).
fn cell(s: &str) -> String {
s.trim().replace('|', "\\|").replace('\n', " ")
}
if rows.is_empty() {
return "_You are not a member of any shared folder._\n".to_string();
}
let mut out = String::from("| Path | Access | Shared with | Description |\n|------|--------|-------------|-------------|\n");
for r in rows {
let access = if r.can_write { "read-write" } else { "read-only" };
let shared_with = if r.shared_with.is_empty() { "".to_string() } else { cell(&r.shared_with) };
let desc = if r.description.trim().is_empty() { "".to_string() } else { cell(&r.description) };
out.push_str(&format!("| `shared/{}` | {access} | {shared_with} | {desc} |\n", r.folder_name));
}
out
}
/// Renders the profile block for `__USER_PROFILE__`. Every line is always
/// present — an explicit `unknown` / `not specified` is a signal the agent can
/// act on (e.g. gently ask) — except `Notes`, omitted entirely when empty.
/// `today` is passed in so the age computation stays pure and testable.
fn render_user_profile_block(
user: Option<&crate::db::users::User>,
locale: &str,
today: chrono::NaiveDate,
) -> String {
let name = user
.and_then(|u| non_empty(&u.display_name))
.or_else(|| user.map(|u| u.username.as_str()))
.unwrap_or("unknown");
let birth = match user.and_then(|u| non_empty(&u.birthdate)) {
Some(raw) => match chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
Ok(dob) => match today.years_since(dob) {
Some(age) => format!("{raw} (age {age})"),
None => format!("{raw} (age unknown)"),
},
// Stored value bypassed validation — show it raw rather than drop it.
Err(_) => raw.to_string(),
},
None => "unknown".to_string(),
};
let sex = user.and_then(|u| non_empty(&u.sex)).unwrap_or("not specified");
let mut out = format!(
"Name: {name}\nDate of birth: {birth}\nSex: {sex}\nPreferred language: {}\n",
crate::i18n::language_name(locale),
);
if let Some(notes) = user.and_then(|u| non_empty(&u.notes)) {
out.push_str(&format!("Notes: {notes}\n"));
}
out
}
/// An optional string field as a trimmed `&str`, `None` when empty/blank.
fn non_empty(s: &Option<String>) -> Option<&str> {
s.as_deref().map(str::trim).filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_folders_table_renders_access_and_description() {
use crate::db::shared_folders::SharedFolderAccess;
let rows = vec![
SharedFolderAccess { folder_name: "photos".into(), can_write: false, shared_with: "Bob, Carol".into(), description: "Shared photo archive".into() },
SharedFolderAccess { folder_name: "recipes".into(), can_write: true, shared_with: "".into(), description: "a | b\nc".into() },
];
let out = render_shared_folders_table(&rows);
assert!(out.starts_with("| Path | Access | Shared with | Description |\n|------|--------|-------------|-------------|\n"));
assert!(out.contains("| `shared/photos` | read-only | Bob, Carol | Shared photo archive |\n"));
// Empty shared_with → "—"; free-text cells stay on one line with escaped pipes.
assert!(out.contains("| `shared/recipes` | read-write | — | a \\| b c |\n"));
}
#[test]
fn shared_folders_table_empty_membership_is_explicit() {
assert_eq!(
render_shared_folders_table(&[]),
"_You are not a member of any shared folder._\n"
);
}
fn test_user() -> crate::db::users::User {
crate::db::users::User {
id: "u-1".into(),
username: "luca".into(),
display_name: None,
role_id: "members".into(),
credentials: crate::db::users::Credentials::Cleartext(None),
active: true,
locale: None,
birthdate: None,
sex: None,
notes: None,
created_at: "now".into(),
updated_at: "now".into(),
}
}
#[test]
fn user_profile_renders_all_fields_with_runtime_age() {
let mut u = test_user();
u.display_name = Some("Luca Rossi".into());
u.birthdate = Some("2019-02-10".into());
u.sex = Some("male".into());
u.notes = Some("loves dinosaurs".into());
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
let out = render_user_profile_block(Some(&u), "it", today);
assert_eq!(
out,
"Name: Luca Rossi\n\
Date of birth: 2019-02-10 (age 7)\n\
Sex: male\n\
Preferred language: Italian\n\
Notes: loves dinosaurs\n"
);
}
#[test]
fn user_profile_age_counts_uncelebrated_birthdays() {
let mut u = test_user();
u.birthdate = Some("2019-12-25".into());
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
let out = render_user_profile_block(Some(&u), "en", today);
assert!(out.contains("Date of birth: 2019-12-25 (age 6)\n"), "{out}");
}
#[test]
fn user_profile_empty_fields_are_explicit_and_notes_omitted() {
let u = test_user();
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
let out = render_user_profile_block(Some(&u), "en", today);
assert_eq!(
out,
"Name: luca\n\
Date of birth: unknown\n\
Sex: not specified\n\
Preferred language: English\n"
);
}
#[test]
fn user_profile_tolerates_garbage_and_future_dates() {
let mut u = test_user();
u.birthdate = Some("not-a-date".into());
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
let out = render_user_profile_block(Some(&u), "en", today);
assert!(out.contains("Date of birth: not-a-date\n"), "{out}");
u.birthdate = Some("2099-01-01".into());
let out = render_user_profile_block(Some(&u), "en", today);
assert!(out.contains("Date of birth: 2099-01-01 (age unknown)\n"), "{out}");
}
#[test]
fn user_profile_missing_user_still_renders_language() {
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
let out = render_user_profile_block(None, "fr", today);
assert_eq!(
out,
"Name: unknown\n\
Date of birth: unknown\n\
Sex: not specified\n\
Preferred language: French\n"
);
}
}
@@ -0,0 +1,507 @@
//! Shared scaffolding for the projection tests: a real owner database seeded
//! **through `SqliteHistory`** (the production write path), a real `agents/`
//! directory, a fake MCP provider, and the assembler a Skald turn runs on.
//!
//! One consumer: [`super::projection_snapshots`], the durable oracle — each
//! scenario's expected wire array lives in `snapshots/*.json`. The arrays were
//! frozen while the old `MessageBuilder` was still alive and a parity harness
//! asserted the two produced the same bytes; that harness is gone with the
//! builder, the snapshots outlived it.
//!
//! Everything volatile is neutralized here rather than scrubbed afterwards:
//! the datetime block is disabled, the agent opts out of the skills index, and
//! the fixture's own identifiers never reach the wire.
#![cfg(test)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use agent_loop::context::{AssembleInput, ContextAssembler, SystemContextSource, TurnInfo};
use agent_loop::ids::{ConversationId, FrameId};
use agent_loop::model::ModelInfo;
use agent_loop::store::{CallOutcome, FrameSpec, HistoryStore, NewCall, NewMessage, NewSummary, Role};
use agent_loop::tool::ToolOutput;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use core_api::message_meta::{Attachment, MessageMetadata};
use core_api::user_fs::UserFs;
use crate::config::DatetimeConfig;
use crate::llm::DtlMode;
use crate::loop_adapters::activation::SkaldActivationSource;
use crate::loop_adapters::history::SqliteHistory;
use crate::loop_adapters::projection_cfg::skald_assembler;
use crate::loop_adapters::selector::tool_rendering_of;
use crate::loop_adapters::system::AgentSystemContext;
use crate::mcp::{McpProvider, McpTool};
use crate::tools::{ToolResult, tool_names as tn};
pub const AGENT_PROMPT: &str = "You are the parity fixture agent."; // frozen: the snapshots contain it
pub const EXTRA_STATIC: &str = "FORMAT RULES";
pub const EXTRA_DYNAMIC: &str = "MEMORY BLOCK";
pub const REMINDER: &str = "REMEMBER THE RULES";
pub const HISTORY_LIMIT: usize = 100;
pub const TOOL_RESULT_LIMIT: usize = 40;
// ── fixture plumbing ─────────────────────────────────────────────────────────
pub fn unique(tag: &str) -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{tag}-{}-{nanos}", std::process::id())
}
/// The scenarios share one cwd-relative directory (`agents/`, see
/// [`AgentFixture`]), so they run one at a time: a fixture torn down while a
/// sibling is mid-projection would fail it spuriously.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// An `agents/<id>/` directory, since `crate::agents` resolves agents relative
/// to the process cwd and the projection loads the prompt through it. Removed
/// on drop, so a panicking test does not leave it behind.
pub struct AgentFixture {
pub id: String,
dir: PathBuf,
/// Held for the fixture's lifetime (see [`SERIAL`]). Poisoning is expected:
/// a failing scenario panics while holding it, and the next may proceed.
_lock: std::sync::MutexGuard<'static, ()>,
}
impl AgentFixture {
pub fn new() -> Self {
let _lock = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let id = unique("parity-agent");
let dir = Path::new("agents").join(&id);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("AGENT.md"), AGENT_PROMPT).unwrap();
std::fs::write(
dir.join("meta.json"),
json!({
"name": "Parity fixture",
"description": "projection parity",
"type": "task",
"inject_skills": false,
})
.to_string(),
)
.unwrap();
Self { id, dir, _lock }
}
}
impl Drop for AgentFixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// An owner database with one session and its root frame.
pub struct Db {
pub pool: Arc<SqlitePool>,
pub store: Arc<dyn HistoryStore>,
pub frame: FrameId,
path: PathBuf,
}
impl Db {
pub async fn new(tag: &str) -> Self {
let path = std::env::temp_dir().join(format!("{}.db", unique(tag)));
let pool = Arc::new(crate::db::create_user_pool(&path, None).await.unwrap());
sqlx::query("INSERT INTO chat_sessions (id, title) VALUES (1, 'parity')")
.execute(&*pool)
.await
.unwrap();
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
let frame = store
.open_frame(&ConversationId::new("session:1"), None, FrameSpec::root("parity"))
.await
.unwrap();
Self { pool, store, frame, path }
}
}
impl Drop for Db {
fn drop(&mut self) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", self.path.display()));
}
}
}
struct FakeMcp {
tools: Vec<McpTool>,
}
#[async_trait::async_trait]
impl McpProvider for FakeMcp {
fn tools(&self) -> Vec<McpTool> {
self.tools.clone()
}
fn tools_for(&self, names: &[String]) -> Vec<McpTool> {
self.tools
.iter()
.filter(|t| names.contains(&t.server_name))
.cloned()
.collect()
}
fn server_descriptions(&self) -> HashMap<String, Option<String>> {
HashMap::new()
}
fn server_infos(&self) -> Vec<Value> {
Vec::new()
}
fn tool_display_name(&self, _server: &str, _tool: &str) -> Option<String> {
None
}
async fn call(&self, _s: &str, _t: &str, _a: Value) -> anyhow::Result<ToolResult> {
unimplemented!("the projection never calls a tool")
}
}
pub fn mcp() -> Arc<dyn McpProvider> {
Arc::new(FakeMcp {
tools: vec![McpTool {
server_name: "gmail".into(),
name: "send".into(),
description: "send mail".into(),
input_schema: json!({ "type": "object" }),
title: None,
output_schema: None,
annotations: None,
task_support: None,
}],
})
}
/// The datetime block is disabled: it embeds `now()`, which no snapshot can
/// pin down.
pub fn datetime() -> DatetimeConfig {
DatetimeConfig { enabled: false, round_minutes: None, timezone: None }
}
/// The base tool definitions the projection is handed.
pub fn config_defs() -> Arc<Vec<Value>> {
Arc::new(vec![json!({
"type": "function",
"function": { "name": "config_get", "parameters": { "type": "object" } }
})])
}
/// What the projection is run with, so a difference can only come from the
/// stored state.
pub struct Case {
pub dtl: DtlMode,
pub cache_hints: bool,
pub capabilities: Vec<String>,
pub fs: Option<Arc<UserFs>>,
}
impl Default for Case {
fn default() -> Self {
Self { dtl: DtlMode::None, cache_hints: false, capabilities: Vec::new(), fs: None }
}
}
/// Projects the seeded state into the wire messages a model would receive.
pub async fn project(db: &Db, agent: &AgentFixture, case: &Case) -> Vec<Value> {
let config_defs = config_defs();
let system_source = AgentSystemContext {
agent_id: agent.id.clone(),
extra_static: Some(EXTRA_STATIC.to_string()),
extra_dynamic: Some(EXTRA_DYNAMIC.to_string()),
tail_reminder: Some(REMINDER.to_string()),
substitutions: HashMap::new(),
pool: db.pool.clone(),
shared_pool: db.pool.clone(),
user_id: "u1".into(),
mcp: mcp(),
project_root: None,
scratchpad_sid: 1,
datetime: datetime(),
};
let system = system_source
.system_context(&TurnInfo {
conversation: ConversationId::new("session:1"),
frame: db.frame,
agent: agent.id.clone(),
user_message: None,
})
.await
.unwrap();
let assembler = skald_assembler(
Arc::new(SkaldActivationSource::new(
db.pool.clone(),
mcp(),
config_defs.clone(),
1,
None,
)),
case.fs.clone(),
HISTORY_LIMIT,
// `compaction_enabled: false` mirrors the builder's `compactor: None`.
false,
Some(TOOL_RESULT_LIMIT),
);
assembler
.build(&db.store, &AssembleInput {
frame: db.frame,
system,
model: ModelInfo {
prompt_cache: case.cache_hints,
capabilities: case.capabilities.clone(),
tool_rendering: tool_rendering_of(case.dtl),
extras: Value::Null,
},
round: 0,
})
.await
.unwrap()
}
/// Compares message by message, so a failure names the first divergence instead
/// of dumping two arrays.
pub fn assert_same(expected: &[Value], actual: &[Value], label: &str) {
for (i, (e, a)) in expected.iter().zip(actual.iter()).enumerate() {
assert_eq!(
e,
a,
"{label}: message {i} diverges\n expected: {}\n actual: {}",
serde_json::to_string_pretty(e).unwrap(),
serde_json::to_string_pretty(a).unwrap()
);
}
assert_eq!(
expected.len(),
actual.len(),
"{label}: message COUNT diverges ({} expected vs {} actual); first extra: {:?}",
expected.len(),
actual.len(),
expected
.get(actual.len().min(expected.len()))
.or_else(|| actual.get(expected.len().min(actual.len()))),
);
}
// ── snapshots ────────────────────────────────────────────────────────────────
/// Set to `1` to rewrite the stored arrays from the current projection. Review
/// the diff: a snapshot changing means the bytes a model receives changed.
pub const UPDATE_ENV: &str = "UPDATE_PROJECTION_SNAPSHOTS";
pub fn snapshot_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/loop_adapters/snapshots")
.join(format!("{name}.json"))
}
/// Asserts `actual` against the stored array, or rewrites it under [`UPDATE_ENV`].
pub fn assert_snapshot(name: &str, actual: &[Value]) {
let path = snapshot_path(name);
if std::env::var(UPDATE_ENV).as_deref() == Ok("1") {
write_snapshot(name, actual);
return;
}
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"missing snapshot {}: {e}\nrun with {UPDATE_ENV}=1 to create it",
path.display()
)
});
let expected: Vec<Value> = serde_json::from_str(&raw).unwrap();
assert_same(&expected, actual, name);
}
/// Writes the stored array (pretty, newline-terminated: it is reviewed as a diff).
pub fn write_snapshot(name: &str, value: &[Value]) {
let path = snapshot_path(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut json = serde_json::to_string_pretty(value).unwrap();
json.push('\n');
std::fs::write(&path, json).unwrap();
}
// ── scenarios: the seeded state ──────────────────────────────────────────────
//
// One function per scenario: the state, separate from what is asserted about it.
/// A plain exchange, including the two consecutive user rows that exercise the
/// coalescing rule.
pub async fn seed_plain(db: &Db) {
db.store.append(db.frame, NewMessage::user("hello")).await.unwrap();
db.store
.append(db.frame, NewMessage::assistant("hi there", Some("thinking".into())))
.await
.unwrap();
db.store.append(db.frame, NewMessage::user("one")).await.unwrap();
db.store.append(db.frame, NewMessage::user("two")).await.unwrap();
}
pub async fn seed_scratchpad(db: &Db) {
crate::db::scratchpad::upsert(&db.pool, 1, "plan", "step one").await.unwrap();
db.store.append(db.frame, NewMessage::user("go")).await.unwrap();
}
/// One assistant turn with a call in each terminal state.
pub async fn seed_tool_round(db: &Db) {
db.store.append(db.frame, NewMessage::user("work")).await.unwrap();
let msg = db.store.append(db.frame, NewMessage::assistant("calling", None)).await.unwrap();
let done = db
.store
.append_call(msg, NewCall::new("read_file", json!({ "path": "a.md" })))
.await
.unwrap();
db.store
.resolve_call(done, &CallOutcome::Completed(ToolOutput::Text("content".into())))
.await
.unwrap();
let failed = db.store.append_call(msg, NewCall::new("write_file", json!({}))).await.unwrap();
db.store.resolve_call(failed, &CallOutcome::Failed("disk full".into())).await.unwrap();
let rejected = db.store.append_call(msg, NewCall::new("execute_cmd", json!({}))).await.unwrap();
db.store
.resolve_call(rejected, &CallOutcome::Rejected { reason: "no".into() })
.await
.unwrap();
let cancelled = db.store.append_call(msg, NewCall::new("glob", json!({}))).await.unwrap();
db.store.resolve_call(cancelled, &CallOutcome::Cancelled).await.unwrap();
}
/// A call left `running`, exactly as a crash leaves it.
pub async fn seed_interrupted(db: &Db) {
db.store.append(db.frame, NewMessage::user("run it")).await.unwrap();
let msg = db.store.append(db.frame, NewMessage::assistant("running", None)).await.unwrap();
db.store
.append_call(msg, NewCall::new("execute_cmd", json!({ "command": "sleep 100" })))
.await
.unwrap();
}
/// Two turns with an over-limit result each: only the first is condensed.
pub async fn seed_condensed(db: &Db) {
for (q, path) in [("first", "big.txt"), ("second", "other.txt")] {
db.store.append(db.frame, NewMessage::user(q)).await.unwrap();
let msg = db.store.append(db.frame, NewMessage::assistant("reading", None)).await.unwrap();
let call = db
.store
.append_call(msg, NewCall::new("read_file", json!({ "path": path })))
.await
.unwrap();
db.store
.resolve_call(
call,
&CallOutcome::Completed(ToolOutput::Text("x".repeat(TOOL_RESULT_LIMIT * 3))),
)
.await
.unwrap();
}
}
pub async fn seed_summary(db: &Db) {
let m1 = db.store.append(db.frame, NewMessage::user("ancient")).await.unwrap();
db.store.append(db.frame, NewMessage::assistant("old reply", None)).await.unwrap();
db.store.append(db.frame, NewMessage::user("recent")).await.unwrap();
db.store
.save_summary(db.frame, NewSummary {
text: "Earlier they discussed ancient things.".into(),
covered_up_to: m1,
})
.await
.unwrap();
}
/// An activation round: an unrelated call first, so the DTL marker has a wrong
/// place to land if the anchor rule regresses.
pub async fn seed_activation(db: &Db) {
db.store.append(db.frame, NewMessage::user("use gmail")).await.unwrap();
let anchor = db
.store
.append(db.frame, NewMessage::assistant("activating", None))
.await
.unwrap();
let other = db.store.append_call(anchor, NewCall::new("read_file", json!({}))).await.unwrap();
db.store
.resolve_call(other, &CallOutcome::Completed(ToolOutput::Text("f".into())))
.await
.unwrap();
let act = db
.store
.append_call(anchor, NewCall::new(tn::ACTIVATE_TOOLS, json!({ "groups": ["gmail"] })))
.await
.unwrap();
db.store
.resolve_call(act, &CallOutcome::Completed(ToolOutput::Text("activated".into())))
.await
.unwrap();
crate::db::activated_tools::grant(&db.pool, 1, None, anchor.get(), "mcp", "gmail")
.await
.unwrap();
}
/// A real PNG under the caller's uploads dir, plus the `UserFs` that authorizes
/// it. Removed on drop.
pub struct MediaHome {
root: PathBuf,
pub fs: Arc<UserFs>,
}
impl MediaHome {
pub fn new() -> Self {
let root = std::env::temp_dir().join(unique("parity-home"));
let uploads = root.join("uploads/1");
std::fs::create_dir_all(&uploads).unwrap();
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.extend_from_slice(&[0xAA; 64]);
std::fs::write(uploads.join("shot.png"), png).unwrap();
let fs = Arc::new(UserFs::new(
"u1",
root.clone(),
"skald-u1",
PathBuf::from("/root"),
vec![],
vec![],
None,
));
Self { root, fs }
}
}
impl Drop for MediaHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// The same attachment on an older turn (textual path) and on the current one
/// (inlined when the model can see it).
pub async fn seed_media(db: &Db) {
let meta = MessageMetadata {
attachments: vec![Attachment {
path: "uploads/1/shot.png".into(),
name: "shot.png".into(),
mimetype: Some("image/png".into()),
filesize: None,
}],
..Default::default()
};
let with_attachment = |content: &str| NewMessage {
role: Role::User,
content: content.to_string(),
synthetic: false,
reasoning: None,
metadata: Some(serde_json::to_value(&meta).unwrap()),
};
db.store.append(db.frame, with_attachment("old shot")).await.unwrap();
db.store.append(db.frame, NewMessage::assistant("seen", None)).await.unwrap();
db.store.append(db.frame, with_attachment("new shot")).await.unwrap();
}
@@ -0,0 +1,180 @@
//! `SkaldDigest` — how an over-long tool result is condensed
//! (`agent_loop::projection::ToolResultDigest`).
//!
//! The crate decides *when* a result is too long (its `ResultLimit` gate, which
//! only shrinks turns the agent has already moved past); this decides *what to
//! say instead*, and that needs to know what each tool does — so it lives here,
//! next to the tools, not in the library.
//!
//! The replacement is always one informative line: the model must be able to
//! tell that a call succeeded and on what, without re-reading its output.
use agent_loop::projection::ToolResultDigest;
use serde_json::Value;
use crate::session::handler::preview_truncate;
use crate::tools::tool_names as tn;
pub struct SkaldDigest;
#[agent_loop::async_trait]
impl ToolResultDigest for SkaldDigest {
async fn condense(&self, name: &str, args: &Value, result: &str) -> Option<String> {
Some(summarize_tool_result(name, args, result))
}
}
/// An informative 1-line summary of a tool call result.
pub fn summarize_tool_result(tool_name: &str, arguments: &Value, result: &str) -> String {
let args = arguments;
let char_count = result.len();
let line_count = if result.trim().is_empty() { 0 } else { result.lines().count() };
fn arg_str<'a>(args: &'a Value, key: &str) -> &'a str {
args[key].as_str().unwrap_or("?")
}
match tool_name {
tn::EXECUTE_CMD => {
let cmd = args["command"].as_str().unwrap_or("");
let cmd_display = preview_truncate(cmd, 77);
let exit_code = result
.lines()
.next()
.and_then(|l| l.strip_prefix("exit: "))
.unwrap_or("?");
format!("[execute_cmd] ran `{cmd_display}` → exit {exit_code}, {line_count} lines output")
}
"read_file" | "read_file_chunk" => {
let path = arg_str(args, "path");
format!("[{tool_name}] read {path} ({char_count} chars)")
}
"write_file" => {
let path = arg_str(args, "path");
format!("[write_file] wrote to {path}")
}
"edit_file" | "patch_file" => {
let path = arg_str(args, "path");
format!("[{tool_name}] edited {path}")
}
"list_dir" | "glob" => {
let path = args["path"].as_str()
.or_else(|| args["pattern"].as_str())
.unwrap_or("?");
format!("[{tool_name}] {path} ({char_count} chars)")
}
"list_items" => {
let kind = arg_str(args, "type");
format!("[list_items] {kind} ({char_count} chars)")
}
"toggle_item" => {
let kind = arg_str(args, "kind");
let id = arg_str(args, "id");
let enabled = args["enabled"].as_bool().unwrap_or(false);
format!("[toggle_item] {kind} '{id}' → {}", if enabled { "enabled" } else { "disabled" })
}
tn::READ_NOTIFICATION => {
let count = serde_json::from_str::<Vec<Value>>(result)
.map(|v| v.len())
.unwrap_or(0);
format!("[read_notification] {count} notification(s)")
}
tn::EXECUTE_TASK | tn::EXECUTE_SUBTASK => {
let agent = arg_str(args, "agent_id");
format!("[{tool_name}] → {agent} ({char_count} chars result)")
}
tn::ACTIVATE_TOOLS => {
let groups = args["groups"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
.unwrap_or_else(|| "?".to_string());
format!("[activate_tools] loaded: {groups}")
}
_ if tool_name.starts_with("mcp__") => {
format!("[{tool_name}] ({char_count} chars result)")
}
_ => {
let first_arg = args.as_object()
.and_then(|m| m.iter().next())
.map(|(k, v)| {
let sv = preview_truncate(v.as_str().unwrap_or_default(), 40);
format!(" {k}={sv}")
})
.unwrap_or_default();
format!("[{tool_name}]{first_arg} ({char_count} chars result)")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn execute_cmd_reports_the_command_exit_code_and_size() {
let s = summarize_tool_result(
tn::EXECUTE_CMD,
&json!({ "command": "ls -la /tmp" }),
"exit: 0\nfile a\nfile b",
);
assert_eq!(s, "[execute_cmd] ran `ls -la /tmp` → exit 0, 3 lines output");
}
#[test]
fn file_tools_report_the_path() {
assert_eq!(
summarize_tool_result("read_file", &json!({ "path": "notes.md" }), "0123456789"),
"[read_file] read notes.md (10 chars)"
);
assert_eq!(
summarize_tool_result("write_file", &json!({ "path": "a.txt" }), "ok"),
"[write_file] wrote to a.txt"
);
// A missing argument degrades, never panics.
assert_eq!(
summarize_tool_result("edit_file", &json!({}), "ok"),
"[edit_file] edited ?"
);
}
#[test]
fn sub_agent_and_activation_calls_name_their_target() {
assert_eq!(
summarize_tool_result(tn::EXECUTE_TASK, &json!({ "agent_id": "researcher" }), "abc"),
"[execute_task] → researcher (3 chars result)"
);
assert_eq!(
summarize_tool_result(tn::ACTIVATE_TOOLS, &json!({ "groups": ["gmail", "config"] }), ""),
"[activate_tools] loaded: gmail, config"
);
}
#[test]
fn unknown_tools_fall_back_to_the_first_argument() {
assert_eq!(
summarize_tool_result("mcp__gmail__send", &json!({ "to": "x@y.z" }), "sent"),
"[mcp__gmail__send] (4 chars result)"
);
assert_eq!(
summarize_tool_result("weird_tool", &json!({ "q": "hello" }), "res"),
"[weird_tool] q=hello (3 chars result)"
);
assert_eq!(
summarize_tool_result("weird_tool", &json!({}), "res"),
"[weird_tool] (3 chars result)"
);
}
}
@@ -0,0 +1,442 @@
//! `SkaldToolSet` — the crate's `ToolSet` over Skald's tool surface (port of
//! `AgentRunConfig::all_tool_defs`, blueprint §10), plus the bridges that let
//! core-api tools and MCP tools run inside the crate's kernel (the "double
//! Tool trait" seam of phase 1: bridged, not re-exported).
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use agent_loop::activation::ToolRendering;
use agent_loop::async_trait;
use agent_loop::model::ModelInfo;
use agent_loop::tool::{
MediaRef, RestartHint, Tool as LoopTool, ToolCtx, ToolExecution, ToolFailure,
ToolOutput, ToolSet, Visibility,
};
use core_api::interface_tool::InterfaceTool;
use core_api::tool::{ExecutionOutcome as CoreOutcome, ToolExecutionState as CoreState};
use core_api::user_fs::UserFs;
use serde_json::Value;
use sqlx::SqlitePool;
use crate::mcp::McpProvider;
use crate::tools::tool_names::CONFIG_GROUP;
// ── Extension keys ───────────────────────────────────────────────────────────
/// The calling user's id — tools that address per-user external stores key on
/// it. Inserted by the host at TurnParams construction.
#[derive(Debug, Clone)]
pub struct CallerUserId(pub String);
/// Reads the `core_api::tool::ToolContext` pieces out of a `ToolCtx`:
/// owner pool + fs from the type-map, session id from the conversation.
fn core_tool_context(ctx: &ToolCtx) -> Result<core_api::tool::ToolContext, ToolFailure> {
let pool = ctx.extensions.get::<SqlitePool>().ok_or_else(|| {
ToolFailure::Failed("tool bridge: no SqlitePool in extensions".into())
})?;
let fs = ctx.extensions.get::<UserFs>().ok_or_else(|| {
ToolFailure::Failed("tool bridge: no UserFs in extensions".into())
})?;
let user_id = ctx
.extensions
.get::<CallerUserId>()
.map(|u| u.0.clone())
.unwrap_or_default();
let session_id = ctx
.conversation
.as_str()
.strip_prefix("session:")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or_default();
Ok(core_api::tool::ToolContext { session_id, user_id, pool, fs })
}
/// Maps a core-api `ToolResult` to the crate's `ToolOutput`.
fn map_output(r: core_api::tool::ToolResult) -> ToolOutput {
match r {
core_api::tool::ToolResult::Text(s) => ToolOutput::Text(s),
core_api::tool::ToolResult::Json(v) => ToolOutput::Json(v),
core_api::tool::ToolResult::Media { text, media } => ToolOutput::Media {
text,
refs: media
.iter()
.map(|m| MediaRef { host_path: m.host_path.clone(), mime: m.mime.clone() })
.collect(),
},
}
}
// ── BridgeExecution ──────────────────────────────────────────────────────────
/// Wraps a core-api `ToolExecution` as the crate's `ToolExecution` (the two
/// state machines are structurally identical).
struct BridgeExecution<'a> {
inner: Box<dyn core_api::tool::ToolExecution + 'a>,
}
impl ToolExecution for BridgeExecution<'_> {
fn state(&self) -> agent_loop::tool::ToolExecutionState {
match self.inner.state() {
CoreState::Pending | CoreState::AwaitingApproval | CoreState::Running => {
agent_loop::tool::ToolExecutionState::Running
}
CoreState::Completed => agent_loop::tool::ToolExecutionState::Completed,
CoreState::Failed => agent_loop::tool::ToolExecutionState::Failed,
CoreState::Cancelled | CoreState::Rejected => agent_loop::tool::ToolExecutionState::Cancelled,
}
}
fn wait<'b>(&'b self) -> std::pin::Pin<Box<dyn std::future::Future<Output = agent_loop::tool::ExecutionOutcome> + Send + 'b>> {
Box::pin(async move {
match self.inner.wait().await {
CoreOutcome::Completed(r) => agent_loop::tool::ExecutionOutcome::Completed(map_output(r)),
CoreOutcome::Failed(e) => agent_loop::tool::ExecutionOutcome::Failed(e),
CoreOutcome::Cancelled => agent_loop::tool::ExecutionOutcome::Cancelled,
}
})
}
fn stop<'b>(&'b self) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'b>> {
self.inner.stop()
}
}
// ── CoreToolBridge ───────────────────────────────────────────────────────────
/// Runs a core-api tool (`crate::tools::Tool`) inside the crate's kernel:
/// context from the type-map, execution bridged (kill/teardown preserved —
/// `execute_cmd`'s reaper keeps working through `stop`).
pub struct CoreToolBridge {
inner: Arc<dyn crate::tools::Tool>,
}
impl CoreToolBridge {
pub fn new(inner: Arc<dyn crate::tools::Tool>) -> Self { Self { inner } }
}
#[async_trait]
impl LoopTool for CoreToolBridge {
fn name(&self) -> &str { self.inner.name() }
fn definition(&self) -> Value { self.inner.openai_definition() }
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
// Same path as `start`, driven to completion without a cancel token.
let exec = self.start(args, ctx);
match exec.wait().await {
agent_loop::tool::ExecutionOutcome::Completed(out) => Ok(out),
agent_loop::tool::ExecutionOutcome::Failed(e) => Err(ToolFailure::Failed(e)),
agent_loop::tool::ExecutionOutcome::Cancelled |
agent_loop::tool::ExecutionOutcome::Suspended => {
Err(ToolFailure::Failed("tool execution interrupted".into()))
}
}
}
fn start<'a>(&'a self, args: Value, ctx: &'a ToolCtx) -> Box<dyn ToolExecution + 'a> {
match core_tool_context(ctx) {
Ok(tool_ctx) => Box::new(BridgeExecution { inner: self.inner.run_with(&tool_ctx, args) }),
Err(e) => Box::new(agent_loop::tool::SimpleExecution::new(Box::pin(async move { Err(e) }))),
}
}
fn restart_hint(&self) -> RestartHint {
// D7: shell commands are not idempotent — never re-run them on restart.
if self.inner.name() == "execute_cmd" {
RestartHint::MarkInterrupted
} else {
RestartHint::ReExecute
}
}
fn visibility(&self) -> Visibility {
if self.inner.root_agent_only() {
Visibility::RootOnly
} else if self.inner.sub_agents_only() {
Visibility::SubAgentsOnly
} else if self.inner.interactive_only() {
Visibility::InteractiveOnly
} else {
Visibility::Always
}
}
}
// ── McpToolBridge ────────────────────────────────────────────────────────────
/// Runs one MCP tool (`mcp__server__tool`) inside the crate's kernel.
pub struct McpToolBridge {
mcp: Arc<dyn McpProvider>,
server: String,
tool: String,
definition: Value,
}
impl McpToolBridge {
pub fn new(mcp: Arc<dyn McpProvider>, server: impl Into<String>, tool: impl Into<String>, definition: Value) -> Self {
Self { mcp, server: server.into(), tool: tool.into(), definition }
}
}
#[async_trait]
impl LoopTool for McpToolBridge {
fn name(&self) -> &str { self.definition["function"]["name"].as_str().unwrap_or("") }
fn definition(&self) -> Value { self.definition.clone() }
async fn call(&self, args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
match self.mcp.call(&self.server, &self.tool, args).await {
Ok(r) => Ok(map_output(r)),
Err(e) => Err(ToolFailure::Failed(e.to_string())),
}
}
}
// ── SkaldToolSet ─────────────────────────────────────────────────────────────
/// The per-turn tool set: base built-ins + MCP grants + the lazy `config`
/// group + memory/image/interface tools, rendered per the model's
/// `ToolRendering` (D15). `defs` is re-read at every round/attempt — grants
/// activated at round N are visible at round N+1 for free.
pub struct SkaldToolSet {
base_defs: Vec<Value>,
config_defs: Arc<Vec<Value>>,
mcp: Arc<dyn McpProvider>,
grants: Arc<RwLock<HashSet<String>>>,
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
/// Crate-native tools (ActivateToolsTool, aliases) — returned as-is.
interface_tools: Vec<InterfaceTool>,
/// Core tools available for execution by name (the find() side).
core_tools: Vec<Arc<dyn crate::tools::Tool>>,
/// Extra crate-native tools for find() (bridge-free).
native_tools: Vec<Arc<dyn LoopTool>>,
/// Records tools offered to the LLM each round (Security-groups UI).
discovery: Option<Arc<crate::tool_discovery::ToolDiscovery>>,
}
impl SkaldToolSet {
#[allow(clippy::too_many_arguments)]
pub fn new(
base_defs: Vec<Value>,
config_defs: Arc<Vec<Value>>,
mcp: Arc<dyn McpProvider>,
grants: Arc<RwLock<HashSet<String>>>,
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
interface_tools: Vec<InterfaceTool>,
core_tools: Vec<Arc<dyn crate::tools::Tool>>,
) -> Self {
Self {
base_defs,
config_defs,
mcp,
grants,
memory_tools,
image_tools,
interface_tools,
core_tools,
native_tools: Vec::new(),
discovery: None,
}
}
pub fn with_discovery(mut self, discovery: Arc<crate::tool_discovery::ToolDiscovery>) -> Self {
self.discovery = Some(discovery);
self
}
pub fn with_native(mut self, tool: Arc<dyn LoopTool>) -> Self {
self.native_tools.push(tool);
self
}
pub fn with_native_all(mut self, tools: Vec<Arc<dyn LoopTool>>) -> Self {
self.native_tools.extend(tools);
self
}
}
/// Tags an OpenAI tool definition as deferred (Anthropic tool search).
fn deferred(mut def: Value) -> Value {
def["defer_loading"] = Value::Bool(true);
def
}
impl ToolSet for SkaldToolSet {
fn defs(&self, model: &ModelInfo) -> Vec<Value> {
let mut defs = self.base_defs.clone();
match model.tool_rendering {
// Declare EVERY accessible MCP tool + the config group as
// `defer_loading:true` — a stable, cache-safe set.
ToolRendering::DeferredToolReference => {
defs.extend(self.mcp.tools().iter().map(|t| deferred(t.to_openai_definition())));
defs.extend(self.config_defs.iter().cloned().map(deferred));
}
// Activated tools are injected as `system`+`tools` messages by the
// assembler — NOT in the top-level array.
ToolRendering::SystemToolBlock => {}
ToolRendering::Inline => {
let granted: HashSet<String> = self.grants.read().map(|g| g.clone()).unwrap_or_default();
let servers: Vec<String> = granted
.iter()
.filter(|n| n.as_str() != CONFIG_GROUP)
.cloned()
.collect();
if !servers.is_empty() {
defs.extend(self.mcp.tools_for(&servers).iter().map(|t| t.to_openai_definition()));
}
if granted.contains(CONFIG_GROUP) {
defs.extend(self.config_defs.iter().cloned());
}
}
}
defs.extend(self.memory_tools.iter().map(|t| t.openai_definition()));
defs.extend(self.image_tools.iter().map(|t| t.openai_definition()));
defs.extend(self.interface_tools.iter().map(|t| t.definition.clone()));
defs.extend(self.native_tools.iter().map(|t| t.definition()));
// Dedup by name (first wins): the host's base/interface defs already
// carry the built-ins (scratchpad/todos/ask_user/activate_tools), and
// the native aliases provide the same names for find() — the wire must
// never carry duplicates (OpenAI-compat APIs 400 on them).
let mut seen = std::collections::HashSet::new();
defs.retain(|d| seen.insert(d["function"]["name"].as_str().unwrap_or("").to_string()));
if let Some(discovery) = &self.discovery {
discovery.observe(&defs);
}
defs
}
fn find(&self, name: &str) -> Option<Arc<dyn LoopTool>> {
if let Some(t) = self.native_tools.iter().find(|t| t.name() == name) {
return Some(t.clone());
}
if let Some(t) = self.core_tools.iter().find(|t| t.name() == name) {
return Some(Arc::new(CoreToolBridge::new(t.clone())));
}
if let Some(t) = self.memory_tools.iter().find(|t| t.name() == name) {
return Some(Arc::new(CoreToolBridge::new(t.clone())));
}
if let Some(t) = self.image_tools.iter().find(|t| t.name() == name) {
return Some(Arc::new(CoreToolBridge::new(t.clone())));
}
// MCP names are `mcp__<server>__<tool>`.
if let Some((server, tool)) = crate::mcp::parse_mcp_tool_name(name) {
let def = self
.mcp
.tools_for(&[server.to_string()])
.into_iter()
.find(|t| t.name == tool)
.map(|t| t.to_openai_definition());
if let Some(def) = def {
return Some(Arc::new(McpToolBridge::new(self.mcp.clone(), server, tool, def)));
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use mcp_client::McpTool;
use crate::tools::ToolResult;
fn fake_mcp(server: &str, tool_names: &[&str]) -> Arc<dyn McpProvider> {
struct Fake(Vec<McpTool>);
#[async_trait::async_trait]
impl McpProvider for Fake {
fn tools(&self) -> Vec<McpTool> { self.0.clone() }
fn tools_for(&self, names: &[String]) -> Vec<McpTool> {
self.0.iter().filter(|t| names.contains(&t.server_name)).cloned().collect()
}
fn server_descriptions(&self) -> HashMap<String, Option<String>> { HashMap::new() }
fn server_infos(&self) -> Vec<Value> { Vec::new() }
fn tool_display_name(&self, _s: &str, _t: &str) -> Option<String> { None }
async fn call(&self, _s: &str, _t: &str, _a: Value) -> anyhow::Result<ToolResult> {
unimplemented!()
}
}
Arc::new(Fake(
tool_names
.iter()
.map(|t| McpTool {
server_name: server.to_string(),
name: t.to_string(),
description: String::new(),
input_schema: serde_json::json!({"type":"object"}),
title: None,
output_schema: None,
annotations: None,
task_support: None,
})
.collect(),
))
}
fn set(grants: &[&str]) -> Arc<RwLock<HashSet<String>>> {
Arc::new(RwLock::new(grants.iter().map(|s| s.to_string()).collect()))
}
fn toolset(grants: Arc<RwLock<HashSet<String>>>) -> SkaldToolSet {
SkaldToolSet::new(
vec![serde_json::json!({"type":"function","function":{"name":"read_file","parameters":{}}})],
Arc::new(vec![serde_json::json!({"type":"function","function":{"name":"cron_list","parameters":{}}})]),
fake_mcp("gmail", &["send"]),
grants,
vec![],
vec![],
vec![],
vec![],
)
}
#[test]
fn inline_renders_only_granted_groups() {
let ts = toolset(set(&[]));
let defs = ts.defs(&ModelInfo::default());
let names: Vec<&str> = defs.iter().filter_map(|d| d["function"]["name"].as_str()).collect();
assert_eq!(names, ["read_file"]);
let ts = toolset(set(&["gmail", CONFIG_GROUP]));
let defs = ts.defs(&ModelInfo::default());
let names: Vec<&str> = defs.iter().filter_map(|d| d["function"]["name"].as_str()).collect();
assert!(names.contains(&"mcp__gmail__send"), "{names:?}");
assert!(names.contains(&"cron_list"));
}
#[test]
fn deferred_declares_everything_tagged() {
let ts = toolset(set(&[]));
let info = ModelInfo { tool_rendering: ToolRendering::DeferredToolReference, ..Default::default() };
let defs = ts.defs(&info);
let gmail = defs.iter().find(|d| d["function"]["name"].as_str() == Some("mcp__gmail__send")).unwrap();
assert_eq!(gmail["defer_loading"], serde_json::json!(true));
let base = defs.iter().find(|d| d["function"]["name"].as_str() == Some("read_file")).unwrap();
assert!(base.get("defer_loading").is_none());
}
#[test]
fn system_tool_block_keeps_array_stable() {
let ts = toolset(set(&["gmail"]));
let info = ModelInfo { tool_rendering: ToolRendering::SystemToolBlock, ..Default::default() };
let defs = ts.defs(&info);
let names: Vec<&str> = defs.iter().filter_map(|d| d["function"]["name"].as_str()).collect();
assert_eq!(names, ["read_file"], "activated tools must NOT be in the array in Kimi mode");
}
#[test]
fn find_bridges_mcp_names() {
let ts = toolset(set(&["gmail"]));
let t = ts.find("mcp__gmail__send").expect("mcp tool not bridged");
assert_eq!(t.definition()["function"]["name"], serde_json::json!("mcp__gmail__send"));
assert!(ts.find("mcp__gmail__nope").is_none());
assert!(ts.find("unknown_tool").is_none());
}
}
@@ -0,0 +1,336 @@
//! The `LoopEvent → ServerEvent` translator (blueprint §10): ONE subscriber of
//! the loop manager's bus, forwarding to the session's WS channel with the
//! host enrichments the frontend expects (display meta, diff previews, file
//! changes). Byte-parity with the pre-kernel event sequence is the contract.
use std::sync::Arc;
use agent_loop::events::{DeltaKind, Event, LoopEvent};
use agent_loop::ids::ConversationId;
use agent_loop::store::{CallOutcome, HistoryStore};
use core_api::message_meta::MessageMetadata;
use serde_json::Value;
use tokio::sync::mpsc;
use crate::events::{ServerEvent, TokenDeltaKind};
use crate::mcp::McpProvider;
use crate::tools::{ToolRegistry, is_file_write_tool};
/// Forwards ONE conversation's loop events to that session's WS `tx`.
///
/// The bus is per **user** (one `LoopManager` per owner), so every session of
/// that user sees every other session's events: the `conv` filter is what keeps
/// them apart, not an accident of wiring.
pub struct EventTranslator {
tx: mpsc::Sender<ServerEvent>,
conv: ConversationId,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
store: Arc<dyn HistoryStore>,
shared: Arc<std::sync::Mutex<TranslateShared>>,
}
/// Turn state the wiring reads back after join (ChatEvent publication).
#[derive(Default)]
pub struct TranslateShared {
/// The user message id that opened the turn.
pub user_message_id: Option<i64>,
/// Accumulated tool calls of the turn (done/failed only — mirrors the old
/// `all_tool_calls` accumulate rules).
pub tool_calls: Vec<core_api::bus::ToolCallEvent>,
}
impl EventTranslator {
pub fn new(
tx: mpsc::Sender<ServerEvent>,
conv: ConversationId,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
store: Arc<dyn HistoryStore>,
) -> (Self, Arc<std::sync::Mutex<TranslateShared>>) {
let shared = Arc::new(std::sync::Mutex::new(TranslateShared::default()));
(Self { tx, conv, tools, mcp, store, shared: shared.clone() }, shared)
}
/// Subscribe and forward until `stop` is cancelled — then **drain what is
/// already buffered** before exiting.
///
/// The caller cancels `stop` right after the turn joins, at which point the
/// kernel's last events (`Done`, the final `ToolDone`) are in the channel
/// but may not have been forwarded yet. Exiting on the token alone would
/// drop them, and the frontend treats `Done` as the turn's truth — the
/// pending bubble would hang forever. Hence: `recv` wins the select, and the
/// stop branch drains before breaking.
pub fn spawn(
self,
mut rx: tokio::sync::broadcast::Receiver<Event<LoopEvent>>,
stop: tokio_util::sync::CancellationToken,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
let ev = tokio::select! {
biased;
ev = rx.recv() => ev,
_ = stop.cancelled() => {
// Drain the tail, then done.
while let Ok(ev) = rx.try_recv() {
self.forward(ev).await;
}
break;
}
};
match ev {
Ok(ev) => self.forward(ev).await,
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "event translator lagged; some events were dropped");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
})
}
async fn emit(&self, ev: ServerEvent) {
self.tx.send(ev).await.ok();
}
pub async fn forward(&self, ev: Event<LoopEvent>) {
// Another session of the same user: not ours to report.
if ev.conversation != self.conv {
return;
}
let is_root = ev.parent_frame.is_none();
match ev.inner {
LoopEvent::TurnStarted | LoopEvent::RoundStarted { .. } | LoopEvent::AsyncResultReady { .. } => {}
LoopEvent::UserMessage { message_id, content, synthetic, metadata } => {
// The turn-opening user message (root, non-synthetic) is
// recorded for the wiring's ChatEvent publication.
if is_root && !synthetic {
let mut g = self.shared.lock().unwrap();
if g.user_message_id.is_none() {
g.user_message_id = Some(message_id.get());
}
}
if synthetic {
return;
}
let meta: Option<MessageMetadata> = metadata
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
let attachments = meta.as_ref().map(|m| m.attachments.clone()).unwrap_or_default();
// A custom slash command persists its expanded template (for
// LLM replay) but the bubble shows the typed command.
let echo = meta
.and_then(|m| m.command.map(|c| c.display))
.unwrap_or(content);
self.emit(ServerEvent::UserMessage { message_id: message_id.get(), content: echo, attachments }).await;
}
LoopEvent::TokenDelta { kind, text } => {
let kind = match kind {
DeltaKind::Content => TokenDeltaKind::Content,
DeltaKind::Reasoning => TokenDeltaKind::Reasoning,
};
self.emit(ServerEvent::TokenDelta { kind, delta: text }).await;
}
LoopEvent::Thinking { message_id, content, usage, reasoning } => {
self.emit(ServerEvent::Thinking {
message_id: message_id.get(),
content,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
reasoning_content: reasoning,
}).await;
}
LoopEvent::Done { message_id, content, usage, reasoning } => {
if !is_root {
return; // a child's completion rides AgentFinished
}
self.emit(ServerEvent::Done {
message_id: message_id.get(),
stack_id: ev.frame.get(),
content,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
reasoning_content: reasoning,
}).await;
}
LoopEvent::Truncated { output_tokens } => {
if is_root {
self.emit(ServerEvent::Truncated { output_tokens }).await;
}
}
LoopEvent::ToolCallStarted { id, message_id, name, args } => {
let (display_name, icon) = self.ui_meta(&name, &args);
let label_short = self.tools.describe_call(&name, &args, core_api::tool::ToolDescriptionLength::Short);
let label_full = self.tools.describe_call(&name, &args, core_api::tool::ToolDescriptionLength::Full);
let path = self.tools.target_path(&name, &args);
self.emit(ServerEvent::ToolStart {
tool_call_id: id.get(),
message_id: message_id.get(),
name,
arguments: args,
display_name,
icon,
label_short,
label_full,
path,
}).await;
}
LoopEvent::ToolCallFinished { id, outcome } => match outcome {
CallOutcome::Completed(out) => {
let stored = self.store.get_call(id).await.ok().flatten();
if let Some(c) = stored.as_ref() {
self.shared.lock().unwrap().tool_calls.push(core_api::bus::ToolCallEvent {
name: c.name.clone(),
arguments: Some(serde_json::to_string(&c.arguments).unwrap_or_default()),
result: Some(out.to_wire()),
status: "done".to_string(),
});
}
let (preview_old, preview_new) = stored
.as_ref()
.map(|c| (
c.extras["preview_old"].as_str().map(str::to_string),
c.extras["preview_new"].as_str().map(str::to_string),
))
.unwrap_or((None, None));
self.emit(ServerEvent::ToolDone {
tool_call_id: id.get(),
result: out.to_wire(),
result_type: out.kind().to_string(),
preview_old,
preview_new,
}).await;
// A successful file-write asks clients holding the file to reload.
if let Some(c) = stored
&& is_file_write_tool(&c.name)
&& let Some(p) = c.arguments["path"].as_str()
{
self.emit(ServerEvent::FileChanged { path: crate::approval::normalize_path(p) }).await;
}
}
CallOutcome::Failed(error) => {
let stored = self.store.get_call(id).await.ok().flatten();
if let Some(c) = stored.as_ref() {
self.shared.lock().unwrap().tool_calls.push(core_api::bus::ToolCallEvent {
name: c.name.clone(),
arguments: Some(serde_json::to_string(&c.arguments).unwrap_or_default()),
result: Some(error.clone()),
status: "failed".to_string(),
});
}
self.emit(ServerEvent::ToolError { tool_call_id: id.get(), error }).await;
}
CallOutcome::Cancelled => {
self.emit(ServerEvent::ToolCancelled { tool_call_id: id.get() }).await;
}
CallOutcome::Rejected { reason } => {
self.emit(ServerEvent::ToolRejected { tool_call_id: id.get(), reason }).await;
}
},
LoopEvent::ApprovalRequired { id, name, args, request_id } => {
self.emit(ServerEvent::ApprovalRequired {
request_id,
tool_call_id: id.get(),
tool_name: name,
arguments: args,
}).await;
}
LoopEvent::AgentSpawned { frame, agent, depth, prompt_preview, parent_call, parent_agent } => {
self.emit(ServerEvent::AgentStart {
stack_id: frame.get(),
parent_tool_call_id: parent_call.get(),
agent_id: agent,
parent_agent_id: parent_agent,
depth: depth as i64,
prompt_preview,
}).await;
}
LoopEvent::AgentFinished { frame, agent, result_preview, parent_agent } => {
self.emit(ServerEvent::AgentDone {
stack_id: frame.get(),
agent_id: agent,
parent_agent_id: parent_agent,
result_preview,
}).await;
}
LoopEvent::ModelFallback { from, to, reason } => {
self.emit(ServerEvent::ModelFallback { from, to, reason: first_line(&reason) }).await;
}
LoopEvent::LlmFailed { tried, last_error } => {
self.emit(ServerEvent::LlmFailed { tried, last_error }).await;
}
LoopEvent::Compacted { .. } => {}
LoopEvent::Error(message) => {
self.emit(ServerEvent::Error { message }).await;
}
LoopEvent::Cancelled => {
if is_root {
self.emit(ServerEvent::Error { message: "Cancelled by user.".to_string() }).await;
}
}
LoopEvent::Host(v) => self.forward_host(v).await,
}
}
/// Host-escaped events (blueprint §4.9): `pending_write` from the
/// approval gate, `agent_question` from the human channel.
async fn forward_host(&self, v: Value) {
match v["type"].as_str() {
Some("pending_write") => {
self.emit(ServerEvent::PendingWrite {
request_id: v["request_id"].as_i64().unwrap_or_default(),
tool_call_id: v["tool_call_id"].as_i64().unwrap_or_default(),
path: v["path"].as_str().unwrap_or_default().to_string(),
old_content: v["old_content"].as_str().map(str::to_string),
new_content: v["new_content"].as_str().unwrap_or_default().to_string(),
}).await;
}
Some("agent_question") => {
self.emit(ServerEvent::AgentQuestion {
request_id: v["request_id"].as_i64().unwrap_or_default(),
tool_call_id: v["tool_call_id"].as_i64().unwrap_or_default(),
title: v["title"].as_str().unwrap_or_default().to_string(),
question: v["question"].as_str().unwrap_or_default().to_string(),
suggested_answers: v["suggested_answers"]
.as_array()
.map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect())
.unwrap_or_default(),
}).await;
}
_ => {}
}
}
/// `(display_name, icon)` for a tool card, with the MCP friendly-name
/// override (mirrors `tool_ui_meta`).
fn ui_meta(&self, name: &str, args: &Value) -> (String, String) {
let mut meta = self.tools.display_meta(name, args);
if let Some((server, tool)) = crate::mcp::parse_mcp_tool_name(name)
&& let Some(friendly) = self.mcp.tool_display_name(server, tool)
{
meta.display_name = friendly;
}
(meta.display_name, meta.icon)
}
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or(s).to_string()
}
@@ -1,375 +0,0 @@
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::info;
use crate::db::{activated_tools, chat_history, chat_llm_tools, chat_sessions_stack, scratchpad};
use crate::events::ServerEvent;
use super::{ChatSessionHandler, MAX_AGENT_DEPTH, TurnOutcome};
use super::emitter::TurnEmitter;
use super::interface_tools::{AgentRunConfig, InterfaceTool, ToolFuture};
use super::config::activate_tools_tool_def;
impl ChatSessionHandler {
/// Dispatches a sub-agent as a child stack frame within the current session.
/// Used by `execute_task` (mode=sync) and `execute_subtask` interceptions in `llm_loop`.
/// Args must contain `agent_id` and `prompt`; optionally `client`.
pub(super) async fn dispatch_sub_agent(
&self,
parent_stack_id: i64,
parent_config: &AgentRunConfig,
parent_tool_call_id: i64,
args: &Value,
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
) -> anyhow::Result<String> {
let pool = &self.db;
let em = TurnEmitter::new(tx);
let target_id = args["agent_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("dispatch_sub_agent: missing required argument `agent_id`"))?;
let prompt = args["prompt"].as_str()
.ok_or_else(|| anyhow::anyhow!("dispatch_sub_agent: missing required argument `prompt`"))?;
if target_id == parent_config.agent_id {
anyhow::bail!("dispatch_sub_agent: an agent cannot call itself (`{target_id}`)");
}
// Only `task` agents are dispatchable: this rejects `chat` (e.g. `main`,
// `project-coordinator`) and `system` (e.g. `tic`) agents, and surfaces a
// not-found error for unknown ids — all in one gate.
let target_meta = crate::agents::load_task_meta(target_id)
.map_err(|e| anyhow::anyhow!("dispatch_sub_agent: {e}"))?;
let parent_frame = chat_sessions_stack::find_by_id(pool, parent_stack_id).await?
.ok_or_else(|| anyhow::anyhow!("dispatch_sub_agent: parent stack frame not found"))?;
let new_depth = parent_frame.depth + 1;
if new_depth > MAX_AGENT_DEPTH {
anyhow::bail!(
"dispatch_sub_agent: maximum agent depth ({}) exceeded — refusing to recurse further",
MAX_AGENT_DEPTH
);
}
let explicit_client = args["client"].as_str().or(target_meta.client.as_deref());
let (resolved_client, _) = self.llm_manager.resolve(
explicit_client,
target_meta.strength,
).await.map_err(|e| anyhow::anyhow!("dispatch_sub_agent: {e}"))?;
let child = chat_sessions_stack::create(
pool,
self.session_id,
target_id,
Some(prompt),
new_depth,
Some(parent_tool_call_id),
).await?;
// Single source of the sub-agent's config (base tools + augmentation + grants
// + activate_tools), shared with restart recovery so the two can't drift (B3).
let child_config = self.build_sub_agent_config(
parent_config, target_id, resolved_client.clone(), child.id, new_depth,
).await?;
chat_history::append(pool, child.id, &chat_history::Role::Agent, prompt, false, None).await?;
let prompt_preview = super::preview_truncate(prompt, 500);
em.agent_start(
child.id,
parent_tool_call_id,
target_id.to_string(),
parent_config.agent_id.clone(),
new_depth,
prompt_preview,
).await;
info!(
session_id = self.session_id,
parent_stack = parent_stack_id,
child_stack = child.id,
target_agent = target_id,
client = %resolved_client,
"dispatch_sub_agent: running child inline"
);
// Run the child synchronously in the SAME task, holding the same
// `processing` lock and sharing the same cancellation token. The returned
// string becomes the parent tool call's result, which `run_agent_turn`
// persists and emits as `ToolDone` — so completion lives in one place.
// Boxed: `resume_pending_tools` now dispatches sub-agents via `execute_tool_call`,
// which re-enters here — box this edge so the recursive async future stays sized.
let _ = Box::pin(self.resume_pending_tools(child.id, &child_config, token, tx)).await;
// Sub-agents never inject live user input.
let outcome = self.run_agent_turn(child.id, &child_config, token, tx, None).await;
if let Err(e) = activated_tools::delete_for_stack(pool, child.id).await {
tracing::warn!(stack_id = child.id, error = %e, "dispatch_sub_agent: failed to delete stack activations");
}
let parent_agent_id = parent_config.agent_id.clone();
let child_agent_id = target_id.to_string();
let preview = |s: &str| super::preview_truncate(s, 500);
let result = match outcome {
Ok(TurnOutcome::Final { content, .. }) => {
em.agent_done(child.id, child_agent_id, parent_agent_id, preview(&content)).await;
Ok(content)
}
Ok(TurnOutcome::Cancelled) => {
// The parent shares this token: if the cancel came from the user,
// its next round check returns Cancelled too. We still record a
// tool result so the history stays well-formed.
em.agent_done(child.id, child_agent_id, parent_agent_id, "⚠️ Cancelled.".to_string()).await;
Ok(format!("Sub-agent `{target_id}` was cancelled."))
}
Ok(TurnOutcome::Exhausted) => {
em.agent_done(child.id, child_agent_id, parent_agent_id, "⚠️ Exhausted tool-call rounds.".to_string()).await;
Ok(format!(
"Sub-agent `{target_id}` exceeded {} tool-call rounds without producing a final answer.",
self.max_tool_rounds
))
}
Err(e) => {
let msg = e.to_string();
em.agent_done(child.id, child_agent_id, parent_agent_id, format!("⚠️ Error: {msg}")).await;
Err(e)
}
};
let _ = chat_sessions_stack::terminate(pool, child.id).await;
result
}
/// Builds the [`AgentRunConfig`] for a sub-agent stack frame: base tools derived
/// from `parent_config`, plus the sub-agent augmentation (sub-agents-only tools,
/// `ask_user_clarification`, `execute_subtask` while `depth` still permits
/// recursion), the approval-visibility filter, the frame's persisted MCP grants,
/// and a stack-scoped `activate_tools`.
///
/// The **single** source of a sub-agent's config, shared by live dispatch
/// (`dispatch_sub_agent`) and post-restart recovery (`build_recovery_frame_config`),
/// so a resumed child runs with the same prompt/tools it had live — never the root
/// agent's (bug B3). `depth` is passed explicitly (not `parent.depth + 1`) so
/// recovery can build a config for a frame at any depth straight from the root.
pub(super) async fn build_sub_agent_config(
&self,
parent_config: &AgentRunConfig,
agent_id: &str,
client_name: String,
stack_id: i64,
depth: i64,
) -> anyhow::Result<AgentRunConfig> {
let persisted_grants = activated_tools::list_refs_stack(&self.db, stack_id)
.await
.unwrap_or_default();
let active_mcp_grants: Arc<RwLock<HashSet<String>>> =
Arc::new(RwLock::new(persisted_grants.into_iter().collect()));
let mut child_config = parent_config.for_sub_agent(agent_id.to_string(), client_name);
child_config.depth = depth;
child_config.active_mcp_grants = Arc::clone(&active_mcp_grants);
child_config.base_tool_defs.extend(self.tools.openai_definitions_sub_agents_only());
child_config.base_tool_defs.push(super::ask_user_clarification_tool_def());
// Expose `execute_subtask` only while the child can still recurse — at the
// depth limit `dispatch_sub_agent` would reject it.
if depth < MAX_AGENT_DEPTH {
child_config.base_tool_defs.push(super::execute_subtask_tool_def());
}
{
let group_id = self.tool_group_id().await;
let gid = group_id.as_deref().unwrap_or("default");
// Registry table — read from the registry pool, not the owner pool
// (see the same filter in `config.rs::build_agent_config`).
let group_rules = match crate::db::approval_rules::list_for_group(
&self.shared_pool, Some(gid),
).await {
Ok(rules) => rules,
Err(e) => {
tracing::warn!(group = gid, error = %e, "sub-agent approval-rules visibility filter: list_for_group failed; leaving all tools visible");
Vec::new()
}
};
child_config.base_tool_defs.retain(|def| {
let name = def["function"]["name"].as_str().unwrap_or("");
self.approval.is_tool_visible(&group_rules, name)
});
}
{
let activate_tool = crate::tools::activate_tools::ActivateTools {
stack_id: Some(stack_id),
mcp: Arc::clone(&self.mcp),
active_mcp_grants: Arc::clone(&active_mcp_grants),
};
let activate_tool = Arc::new(activate_tool);
child_config.interface_tools.push(InterfaceTool {
definition: activate_tools_tool_def(),
handler: Arc::new(move |args| -> ToolFuture {
use crate::tools::Tool as _;
let tool = Arc::clone(&activate_tool);
Box::pin(async move {
tokio::task::spawn_blocking(move || tool.execute(args))
.await
.map_err(|e| anyhow::anyhow!("activate_tools task panicked: {e}"))?
})
}),
});
}
Ok(child_config)
}
/// Config to re-run a sub-agent frame during app-restart recovery: resolves the
/// frame's **own** agent (prompt/meta/client) and builds its sub-agent config, so
/// `resume_turn`'s cascade resumes a child as itself, not as the root agent (bug
/// B3). The root frame is not passed here — the caller keeps the session's root
/// config for it. Base tools derive from `root_config`; the per-dispatch `client`
/// override isn't persisted, so the frame's agent meta drives model resolution.
pub(super) async fn build_recovery_frame_config(
&self,
root_config: &AgentRunConfig,
frame: &chat_sessions_stack::SessionStack,
) -> anyhow::Result<AgentRunConfig> {
let meta = crate::agents::load_task_meta(&frame.agent_id)
.map_err(|e| anyhow::anyhow!("resume: cannot load sub-agent `{}`: {e}", frame.agent_id))?;
let (client, _) = self.llm_manager.resolve(
meta.client.as_deref(), meta.strength,
).await?;
self.build_sub_agent_config(root_config, &frame.agent_id, client.to_string(), frame.id, frame.depth).await
}
/// Handles the `update_scratchpad` built-in.
///
/// The scratchpad is a session-scoped shared blackboard (`scratchpad_sid()` is
/// the session_id, identical for every frame). When a homogeneous batch of
/// sub-agents runs concurrently (`handle_sub_agent_batch`), two siblings writing
/// the *same* key race to last-writer-wins — this is inherent to a shared
/// blackboard and accepted by design, not a correctness bug. Sub-agents that must
/// not clobber each other should write distinct keys.
pub(super) async fn dispatch_update_scratchpad(
&self,
args: &Value,
) -> anyhow::Result<String> {
let key = args["key"].as_str().unwrap_or("").to_string();
let value = args["value"].as_str().unwrap_or("").to_string();
scratchpad::upsert(&self.db, self.scratchpad_sid(), &key, &value).await
.map(|_| format!("Scratchpad updated: {key}"))
}
/// Handles the `write_todos` built-in.
///
/// Stateless: the list is not persisted anywhere — it lives only in this
/// agent's tool-result history (per-stack, so it is never seen by sub-agents
/// or the caller). We just validate/normalise the items and echo back a
/// formatted checklist the model re-reads from its own tool result.
pub(super) async fn dispatch_write_todos(
&self,
args: &Value,
) -> anyhow::Result<String> {
let items = args["todos"].as_array().ok_or_else(|| {
anyhow::anyhow!("`write_todos` requires a `todos` array. Re-send the full list, e.g. [{{\"content\":\"...\",\"status\":\"pending\"}}].")
})?;
if items.is_empty() {
return Err(anyhow::anyhow!("`todos` is empty — send at least one item, or omit the call entirely."));
}
let mut lines = Vec::with_capacity(items.len());
let (mut done, mut active, mut pending) = (0usize, 0usize, 0usize);
for item in items {
let content = item["content"].as_str().unwrap_or("").trim();
if content.is_empty() {
continue;
}
// Normalise unknown statuses to `pending`.
let marker = match item["status"].as_str() {
Some("completed") => { done += 1; "x" }
Some("in_progress") => { active += 1; "~" }
_ => { pending += 1; " " }
};
lines.push(format!("[{marker}] {content}"));
}
if lines.is_empty() {
return Err(anyhow::anyhow!("No valid todo items (every `content` was empty)."));
}
Ok(format!(
"Todo list ({total}): {done} done, {active} in progress, {pending} pending\n{body}",
total = lines.len(),
body = lines.join("\n"),
))
}
/// Handles the `ask_user_clarification` built-in.
///
/// Interactive sessions (web, telegram): sends `AgentQuestion` over the WS channel
/// and waits for the user to answer inline in the chat.
///
/// Background sessions (cron, tic): registers in `ClarificationManager` so the
/// Agent Inbox page can surface and resolve the request.
///
/// `tool_call_id` is used to mark the DB row as `pending` before blocking,
/// so page refreshes and app restarts can distinguish "waiting for input" from
/// "was executing" and re-ask the question correctly.
pub(super) async fn dispatch_ask_user_clarification(
&self,
tool_call_id: i64,
args: &Value,
tx: &mpsc::Sender<ServerEvent>,
) -> anyhow::Result<String> {
let title = args["title"].as_str().unwrap_or("Clarification needed").to_string();
let question = args["question"].as_str().unwrap_or("?").to_string();
let suggested: Vec<String> = args["suggested_answers"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
// Mark as pending before suspending so restart/refresh can re-ask the question.
chat_llm_tools::set_approval_pending(&self.db, tool_call_id).await?;
let context_label = self.context_label.read().ok().and_then(|g| g.clone());
// Always register in ClarificationManager so the question appears in the
// Agent Inbox for ALL sessions (both interactive web/telegram and background cron/tic).
let (request_id, rx) = self.clarification.register(
self.session_id,
&self.agent_id,
&self.source,
context_label.as_deref(),
&title,
&question,
suggested.clone(),
).await;
tracing::debug!(session_id = self.session_id, request_id, is_interactive = self.is_interactive, source = %self.source, "dispatch_ask_user_clarification: routing");
if self.is_interactive {
// For interactive sessions, also send the question over WS so it appears
// inline in the chat. The user can answer from either the chat or the Inbox.
info!(session_id = self.session_id, request_id, %question, source = %self.source, "agent asking user for clarification (interactive) — sending AgentQuestion");
let send_result = tx.send(ServerEvent::AgentQuestion {
request_id,
tool_call_id,
title,
question,
suggested_answers: suggested,
}).await;
if send_result.is_err() {
tracing::warn!(session_id = self.session_id, request_id, "AgentQuestion send failed — tx receiver dropped");
} else {
info!(session_id = self.session_id, request_id, "AgentQuestion sent to bridge");
}
} else {
info!(session_id = self.session_id, request_id, %question, source = %self.source, "background session waiting for clarification");
}
// Wait for the answer (from WS via resolve_question → clarification.resolve,
// or directly from the Inbox REST endpoint).
rx.await.map_err(|_| anyhow::Error::new(super::AgentFlowSignal::QuestionChannelClosed))
}
}
@@ -1,128 +0,0 @@
use serde_json::Value;
use tracing::debug;
use super::ChatSessionHandler;
use super::emitter::TurnEmitter;
use crate::tools::{is_file_write_tool, tool_names as tn};
impl ChatSessionHandler {
/// Emits the appropriate frontend approval event for the given tool call.
///
/// | Tool kind | Event emitted |
/// |------------------|-------------------------------------------------------|
/// | file-write tools | `PendingWrite` with before/after diff (IO concurrent) |
/// | `execute_cmd` | `PendingWrite` with command preview |
/// | `restart` | `PendingWrite` with restart description |
/// | everything else | `ApprovalRequired` |
///
/// Called from both `llm_loop` and `resume_pending_tools` to avoid duplication.
pub(super) async fn emit_approval_event(
&self,
em: &TurnEmitter<'_>,
request_id: i64,
tool_call_id: i64,
tool_name: &str,
arguments: &Value,
) {
if is_file_write_tool(tool_name) {
let path = arguments["path"].as_str().unwrap_or("").to_string();
// Read current file and compute new content concurrently — both are disk I/O.
let (old_content, new_content) = tokio::join!(
self.read_current_content(&path),
self.compute_new_content(tool_name, arguments),
);
if let Some(new_content) = new_content {
em.pending_write(request_id, tool_call_id, path, old_content, new_content).await;
} else {
// File doesn't exist yet or diff can't be computed — fall back to generic.
debug!(tool = tool_name, "emit_approval_event: no diff available, using ApprovalRequired");
em.approval_required(request_id, tool_call_id, tool_name.to_string(), arguments.clone()).await;
}
} else if tool_name == tn::EXECUTE_CMD {
let cmd = arguments["command"].as_str().unwrap_or("");
em.pending_write(request_id, tool_call_id, "$ execute_cmd".to_string(), None, format!("$ {cmd}")).await;
} else {
em.approval_required(request_id, tool_call_id, tool_name.to_string(), arguments.clone()).await;
}
}
/// Reads the current content of a file for the diff in a `PendingWrite` event.
///
/// Routes **exactly like the fs-tools** (blueprint §6), so the diff the user
/// approves reflects the real target — not the server's cwd:
/// - `user-memory/…` / `shared-memory/…` → the `memory_docs` note on the right
/// pool (owner vs `system.db`), never disk;
/// - every other agent path → the caller's per-user host workspace via `self.fs`,
/// containment-checked by `resolve_host_path`.
///
/// A resolve failure or a missing note/file yields `None` (rendered as "new file").
/// The old cwd-relative `fs::resolve` was wrong for every agent path: it showed a
/// bogus "new file" on overwrites and, worse, the diff of a same-named cwd file.
pub(super) async fn read_current_content(&self, path: &str) -> Option<String> {
use crate::tools::fs::{classify_memory, resolve_host_path, MemScope};
if let Some(m) = classify_memory(path) {
let pool = match m.scope {
MemScope::User => &self.db,
MemScope::Shared => &self.shared_pool,
};
return crate::db::memory_docs::get(pool, &m.rel)
.await.ok().flatten().map(|d| d.content);
}
let abs = resolve_host_path(&self.fs.load(), path).ok()?;
tokio::fs::read_to_string(&abs).await.ok()
}
/// Computes what a file would look like after the tool runs, without writing it.
/// Returns `None` if the result cannot be determined (e.g. edit_file on a missing file).
pub(super) async fn compute_new_content(&self, name: &str, args: &Value) -> Option<String> {
match name {
"write_file" => args["content"].as_str().map(|s| s.to_string()),
"edit_file" => {
let path = args["path"].as_str()?;
let old_text = args["old"].as_str()?;
let new_text = args["new"].as_str()?;
let current = self.read_current_content(path).await?;
if current.contains(old_text) {
Some(current.replacen(old_text, new_text, 1))
} else {
None
}
}
"insert_at_line" => {
let path = args["path"].as_str()?;
let line_num = args["line"].as_u64()? as usize;
let new_text = args["content"].as_str()?;
let placement = args["placement"].as_str().unwrap_or("after");
if line_num == 0 { return None; }
let current = self.read_current_content(path).await?;
let mut lines: Vec<&str> = current.split('\n').collect();
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
let insert_idx = if placement == "before" { idx } else { idx + 1 };
let new_lines: Vec<&str> = new_text.split('\n').collect();
for (i, l) in new_lines.iter().enumerate() {
lines.insert(insert_idx + i, l);
}
Some(lines.join("\n"))
}
"replace_lines" => {
let path = args["path"].as_str()?;
let from_line = args["from_line"].as_u64()? as usize;
let to_line = args["to_line"].as_u64()? as usize;
let new_text = args["new"].as_str()?;
if from_line == 0 || to_line < from_line { return None; }
let current = self.read_current_content(path).await?;
let mut lines: Vec<&str> = current.lines().collect();
let total = lines.len();
if from_line > total { return None; }
let to_clamped = to_line.min(total);
let new_lines: Vec<&str> = new_text.lines().collect();
lines.splice((from_line - 1)..to_clamped, new_lines);
let has_trailing = current.ends_with('\n');
let mut result = lines.join("\n");
if has_trailing { result.push('\n'); }
Some(result)
}
_ => None,
}
}
}
@@ -8,7 +8,7 @@ use super::{ChatSessionHandler, update_scratchpad_tool_def, write_todos_tool_def
use super::interface_tools::{AgentRunConfig, InterfaceTool, ToolFuture};
/// Returns an `activate_tools` OpenAI tool definition.
pub(super) fn activate_tools_tool_def() -> Value {
pub(crate) fn activate_tools_tool_def() -> Value {
serde_json::json!({
"type": "function",
"function": {
@@ -38,7 +38,7 @@ pub(super) fn activate_tools_tool_def() -> Value {
impl ChatSessionHandler {
/// Resolves the LLM client and assembles `AgentRunConfig` for a top-level turn
/// (depth = 0). Extracted to avoid duplicating the same ~15 lines in both
/// `handle_message` and `resume_turn`.
/// `handle_message` and the recovery paths.
pub(super) async fn build_agent_config(
&self,
client_name: Option<String>,

Some files were not shown because too many files have changed in this diff Show More