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).
This commit is contained in:
2026-07-26 17:09:01 +01:00
parent 3fca7867fa
commit 24ee5b89d7
74 changed files with 7661 additions and 5982 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
+1
View File
@@ -49,6 +49,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"base64",
"futures",
"futures-util",
"reqwest 0.13.4",
+1
View File
@@ -9,6 +9,7 @@ license = "MIT"
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"
+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}");
}
}
+46 -209
View File
@@ -1,27 +1,23 @@
//! The system context (layered) and the `ContextAssembler` — from system +
//! history to wire messages.
//!
//! **Well-formedness contract** (every assembler MUST honor it):
//!
//! 1. Order: static system → compaction summary (if any) → messages after
//! `covered_up_to` → dynamic tail → tail reminder.
//! 2. Every assistant `tool_call` has a tool-result: `Done`→result,
//! `Failed`→error, `Cancelled`/`Rejected`→note, **`Running`/`AwaitingHuman`
//! surviving a crash → synthetic "interrupted" result**.
//! 3. No `failed` messages (orphans) — already filtered by the store.
//! 4. DTL injection (§4.10 of the blueprint): when `model.tool_rendering` is
//! not `Inline` and an `ActivationSource` is present, each activation is
//! projected at its anchor (marker vs system+tools block, append-only).
//! 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, json};
use serde_json::Value;
use crate::activation::{ActivationSource, ToolRendering};
use crate::activation::ActivationSource;
use crate::ids::{ConversationId, FrameId};
use crate::model::ModelInfo;
use crate::store::{HistoryStore, Role, StoredMessage};
use crate::projection::{
MediaSource, Projection, ProjectionHooks, ResultLimit, ToolResultDigest,
};
use crate::store::HistoryStore;
// ── SystemContext ────────────────────────────────────────────────────────────
@@ -115,36 +111,55 @@ pub trait ContextAssembler: Send + Sync {
// ── LinearAssembler ──────────────────────────────────────────────────────────
/// The shipped assembler: system + summary + history, with an optional
/// message window and tool-result truncation. Honors the DTL injection
/// contract when given an `ActivationSource`.
/// 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 {
/// Keep at most this many history messages (cut at a User/Agent boundary,
/// never mid assistant+tool group).
pub max_messages: Option<usize>,
/// Truncate each tool result to this many chars.
pub max_tool_result_chars: Option<usize>,
/// DTL activations (only consulted when `tool_rendering != Inline`).
pub activation: Option<Arc<dyn ActivationSource>>,
pub projection: Projection,
pub hooks: ProjectionHooks,
}
impl LinearAssembler {
pub fn new() -> Self {
Self { max_messages: None, max_tool_result_chars: None, activation: None }
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.max_messages = Some(n);
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.max_tool_result_chars = Some(n);
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.activation = Some(src);
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
}
}
@@ -153,9 +168,8 @@ impl Default for LinearAssembler {
fn default() -> Self { Self::new() }
}
/// The summary block is prefixed so the model understands what it is (Skald
/// keeps its own SUMMARY_PREFIX in its assembler).
pub const SUMMARY_PREFIX: &str = "[CONTEXT SUMMARY — earlier messages were compacted into this summary]";
/// Re-exported for hosts that only need the default summary header.
pub use crate::projection::SUMMARY_PREFIX;
#[async_trait]
impl ContextAssembler for LinearAssembler {
@@ -164,183 +178,6 @@ impl ContextAssembler for LinearAssembler {
store: &Arc<dyn HistoryStore>,
input: &AssembleInput,
) -> crate::Result<Vec<Value>> {
let mut out: Vec<Value> = Vec::new();
// 1. static system
if !input.system.base.is_empty() {
out.push(json!({ "role": "system", "content": input.system.base }));
}
for s in &input.system.extra_static {
out.push(json!({ "role": "system", "content": s }));
}
// 2. summary + surviving history
let summary = store.latest_summary(input.frame).await?;
if let Some(s) = &summary {
out.push(json!({
"role": "system",
"content": format!("{SUMMARY_PREFIX}\n\n{}", s.text),
}));
}
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) = self.max_messages {
history = window(history, max);
}
// 3. DTL activations (consulted only in non-Inline modes)
let activations = match (&self.activation, input.model.tool_rendering) {
(Some(src), ToolRendering::Inline) => {
let _ = src;
Vec::new()
}
(Some(src), _) => src.activations(input.frame).await.unwrap_or_default(),
(None, _) => Vec::new(),
};
for msg in &history {
project_message(&mut out, msg, self.max_tool_result_chars);
inject_activations(&mut out, msg, &activations, &input.model.tool_rendering);
}
// 4. dynamic tail + reminder
for s in &input.system.dynamic_tail {
out.push(json!({ "role": "system", "content": s }));
}
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, at a User/Agent boundary so an
/// assistant+tool group is never split.
fn window(history: Vec<StoredMessage>, max: usize) -> Vec<StoredMessage> {
if history.len() <= max {
return history;
}
let start = history.len() - max;
let cut = history[start..]
.iter()
.position(|m| matches!(m.role, Role::User | Role::Agent))
.map(|p| start + p)
.unwrap_or(start);
history[cut..].to_vec()
}
/// Project one stored message (and its tool results) to wire messages.
fn project_message(out: &mut Vec<Value>, msg: &StoredMessage, result_limit: Option<usize>) {
match msg.role {
Role::System => {
out.push(json!({ "role": "system", "content": msg.content }));
}
Role::User | Role::Agent => {
out.push(json!({ "role": "user", "content": msg.content }));
}
Role::Assistant => {
let mut wire = json!({ "role": "assistant", "content": msg.content });
if let Some(r) = &msg.reasoning {
// Echoed under both names: DeepSeek expects reasoning_content,
// others reasoning (the clients normalize on read).
wire["reasoning_content"] = json!(r);
}
if !msg.calls.is_empty() {
let calls: Vec<Value> = msg
.calls
.iter()
.map(|c| {
json!({
"id": c.provider_id,
"type": "function",
"function": {
"name": c.name,
"arguments": serde_json::to_string(&c.arguments)
.unwrap_or_else(|_| "{}".into()),
},
})
})
.collect();
wire["tool_calls"] = Value::Array(calls);
}
out.push(wire);
for call in &msg.calls {
let mut content = match call.state {
crate::store::CallState::Running | crate::store::CallState::AwaitingHuman => {
"[interrupted: this tool call did not complete — the session restarted \
before a result was recorded]"
.to_string()
}
crate::store::CallState::Failed => {
format!("Error: {}", call.result.as_deref().unwrap_or("unknown error"))
}
_ => call.result.clone().unwrap_or_default(),
};
if let Some(limit) = result_limit
&& content.chars().count() > limit
{
content = format!(
"{}… [truncated]",
content.chars().take(limit).collect::<String>()
);
}
out.push(json!({
"role": "tool",
"tool_call_id": call.provider_id,
"content": content,
}));
}
}
}
}
/// DTL injection at an activation anchor (blueprint §4.10):
/// - `DeferredToolReference`: `_tool_references` marker on the FIRST tool
/// result of the anchored assistant message (the client converts it).
/// - `SystemToolBlock`: a `{role:"system", tools:[defs]}` message appended
/// right after the anchored message's tool-result group.
fn inject_activations(
out: &mut Vec<Value>,
msg: &StoredMessage,
activations: &[crate::activation::Activation],
mode: &ToolRendering,
) {
let acts: Vec<&crate::activation::Activation> =
activations.iter().filter(|a| a.anchor == msg.id).collect();
if acts.is_empty() {
return;
}
match mode {
ToolRendering::Inline => {}
ToolRendering::DeferredToolReference => {
let names: Vec<Value> = acts
.iter()
.flat_map(|a| &a.defs)
.filter_map(|d| d["function"]["name"].as_str())
.map(|n| json!(n))
.collect();
if names.is_empty() {
return;
}
// Attach to the first tool result just emitted for this message.
if let Some(tool_msg) = out
.iter_mut()
.rev()
.take(msg.calls.len())
.find(|m| m["role"].as_str() == Some("tool"))
{
tool_msg["_tool_references"] = Value::Array(names);
}
}
ToolRendering::SystemToolBlock => {
let defs: Vec<Value> = acts.iter().flat_map(|a| a.defs.clone()).collect();
if !defs.is_empty() {
out.push(json!({ "role": "system", "tools": defs }));
}
}
crate::projection::project(store, input, &self.projection, &self.hooks).await
}
}
+358 -20
View File
@@ -4,9 +4,10 @@
//! parent awaits; a homogeneous batch of sync delegates fans out through the
//! kernel's generic concurrency (`concurrency_safe`).
//!
//! The crate ships the SYNC flow. Async delegation rides the host's
//! `AsyncExecutor` (phase-3 concern: Skald wires its durable cron executor
//! there); calling it here fails with a clear error.
//! 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;
@@ -15,11 +16,11 @@ use serde_json::{Value, json};
use crate::async_trait;
use crate::context::SystemContextSource;
use crate::events::{EventSink, LoopEvent};
use crate::ids::FrameId;
use crate::ids::{ConversationId, FrameId, TaskId, ToolCallId};
use crate::manager::{LoopManager, LoopParams, TurnMeta};
use crate::model::{ModelHint, ModelSelector};
use crate::store::{FrameSpec, HistoryStore, NewMessage};
use crate::tool::{SharedToolSet, Tool, ToolCtx, ToolFailure, ToolOutput, ToolSet};
use crate::store::{CallOutcome, FrameSpec, HistoryStore, NewCall, NewMessage};
use crate::tool::{Extensions, SharedToolSet, Tool, ToolCtx, ToolFailure, ToolOutput, ToolSet};
// ── AgentCatalog ─────────────────────────────────────────────────────────────
@@ -84,7 +85,16 @@ 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.
async fn get(&self, id: &str, child_frame: FrameId) -> crate::Result<AgentProfile>;
///
/// `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) {}
@@ -99,6 +109,18 @@ pub struct FilteredToolSet {
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
@@ -125,6 +147,255 @@ impl ToolSet for FilteredToolSet {
}
}
// ── 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 —
@@ -137,6 +408,8 @@ pub struct DelegateTool {
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 {
@@ -146,7 +419,23 @@ impl DelegateTool {
store: Arc<dyn HistoryStore>,
max_depth: u32,
) -> Self {
Self { manager, catalog, store, max_depth, name: "delegate".to_string(), definition_override: None }
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
@@ -182,6 +471,57 @@ impl DelegateTool {
})
}
/// 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!(
@@ -218,7 +558,7 @@ impl DelegateTool {
// 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).await {
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;
@@ -258,11 +598,7 @@ impl DelegateTool {
.extensions
.get::<SharedToolSet>()
.ok_or_else(|| ToolFailure::Failed("delegate: no ToolSet in extensions".into()))?;
Arc::new(FilteredToolSet {
inner: parent_tools.0.clone(),
remove: profile.tools.remove.clone(),
add: profile.tools.add.clone(),
})
Arc::new(FilteredToolSet::derive(parent_tools.0.clone(), &profile.tools))
}
};
@@ -361,11 +697,8 @@ impl Tool for DelegateTool {
.ok_or_else(|| ToolFailure::Failed("delegate: missing required argument `prompt`".into()))?;
match args["mode"].as_str() {
Some("async") => Err(ToolFailure::Failed(
"delegate: async mode rides the host's AsyncExecutor, which is not wired on this path"
.to_string(),
)),
_ => self.run_sync(agent_id, prompt, ctx).await,
Some("async") => self.run_async(agent_id, prompt, &args, ctx).await,
_ => self.run_sync(agent_id, prompt, ctx).await,
}
}
}
@@ -399,7 +732,12 @@ impl Default for StaticCatalog {
#[async_trait]
impl AgentCatalog for StaticCatalog {
async fn get(&self, id: &str, _child_frame: FrameId) -> crate::Result<AgentProfile> {
async fn get(
&self,
id: &str,
_child_frame: FrameId,
_ctx: &ToolCtx,
) -> crate::Result<AgentProfile> {
self.profiles
.iter()
.find(|p| p.id == id)
+21 -11
View File
@@ -86,12 +86,7 @@ pub(crate) async fn run(
// 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 = || {
let mut ext = params.extensions.clone();
ext.insert(Arc::new(events.clone()));
ext.insert(Arc::new(crate::tool::SharedToolSet(params.tools.clone())));
ext
};
let tool_extensions = || tool_extensions(&params, &events);
events.emit(frame, parent, LoopEvent::TurnStarted);
@@ -289,6 +284,20 @@ pub(crate) async fn run(
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,
@@ -510,7 +519,7 @@ async fn record_call(
})
}
enum PreExecution {
pub(crate) enum PreExecution {
Run(Arc<dyn crate::tool::Tool>),
Resolved(CallOutcome),
TurnCancelled,
@@ -519,8 +528,9 @@ enum PreExecution {
Suspended,
}
/// Gate + hooks.pre + tool lookup — shared by sequential and fan-out paths.
async fn pre_execution(
/// 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,
@@ -572,8 +582,8 @@ async fn pre_execution(
}
}
/// Phase-3 shared by both paths: hooks.post → resolve → emit.
async fn record_outcome(
/// 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,
+16 -2
View File
@@ -12,6 +12,7 @@
//! Design document: `blueprint/project-loop.md` (Skald workspace).
pub mod activation;
pub mod compaction;
pub mod context;
pub mod delegate;
pub mod events;
@@ -23,6 +24,8 @@ 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;
@@ -43,13 +46,17 @@ 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, DelegateTool, FilteredToolSet,
StaticCatalog, ToolSelection,
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};
@@ -67,6 +74,13 @@ pub mod prelude {
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,
+130 -1
View File
@@ -58,6 +58,10 @@ pub struct TurnParams {
/// 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`.
@@ -139,6 +143,29 @@ struct RunningEntry {
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 {
@@ -212,7 +239,7 @@ impl LoopManager {
system: params.system,
tools: params.tools,
model_hint: params.model_hint,
selector: None,
selector: params.selector,
token: None,
live_input: params.live_input,
extensions: params.extensions,
@@ -287,6 +314,108 @@ impl LoopManager {
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
+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));
}
}
+9
View File
@@ -221,6 +221,11 @@ pub struct StoredCall {
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,
@@ -280,6 +285,10 @@ pub trait HistoryStore: Send + Sync {
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<()>;
+21
View File
@@ -154,6 +154,8 @@ impl HistoryStore for InMemoryStore {
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(),
@@ -195,6 +197,25 @@ impl HistoryStore for InMemoryStore {
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| {
+189 -2
View File
@@ -143,6 +143,7 @@ async fn params(
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(),
@@ -502,18 +503,26 @@ async fn second_loop_on_same_conversation_rejected() {
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) -> agent_loop::Result<AgentProfile> {
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: None,
model: self.model.clone(),
selector: None,
assembler: None,
})
@@ -540,6 +549,7 @@ async fn sync_delegate_runs_child_loop_and_returns_result() {
);
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));
@@ -551,6 +561,7 @@ async fn sync_delegate_runs_child_loop_and_returns_result() {
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(),
@@ -599,6 +610,7 @@ async fn delegate_batch_fans_out_concurrently() {
);
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));
@@ -610,6 +622,7 @@ async fn delegate_batch_fans_out_concurrently() {
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(),
@@ -633,4 +646,178 @@ async fn delegate_batch_fans_out_concurrently() {
);
}
// ── 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 -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>,
}
+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());
+83 -425
View File
@@ -1,61 +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::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 /
@@ -82,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 ────────────────────────────────────────────────────────────────
@@ -211,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,
@@ -221,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,
@@ -240,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.
@@ -249,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,
@@ -265,309 +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 request = agent_loop::model::ModelRequest {
messages: messages_payload,
tools: Vec::new(),
model: llm.model.clone(),
max_tokens: None,
temperature: Some(0.3),
request_id: uuid::Uuid::new_v4().to_string(),
conversation: agent_loop::ids::ConversationId::new(format!("session:{session_id}")),
frame: agent_loop::ids::FrameId(stack_id),
extras: serde_json::Value::Null,
log: Some(json!({ "session_id": session_id, "stack_id": stack_id })),
};
let resp = llm.client.complete(&request, None).await
.map_err(|e| {
warn!(stack_id, error = %e, "compactor: LLM call failed");
e
})?;
let summary_text = match resp {
agent_loop::model::ModelResponse::Message { content, .. } => content,
agent_loop::model::ModelResponse::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,
}
@@ -19,7 +19,7 @@ use crate::tools::tool_names::CONFIG_GROUP;
/// 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. Port of `MessageBuilder::resolve_activation_defs`.
/// injection: which tool definitions an activation resolves to.
pub struct SkaldActivationSource {
pool: Arc<SqlitePool>,
mcp: Arc<dyn McpProvider>,
@@ -1,528 +0,0 @@
//! `SkaldAssembler` — Skald's history projection behind the crate's
//! `ContextAssembler` (port of `MessageBuilder::build`'s message-array half,
//! blueprint §10). Byte-parity with the current builder is the contract:
//! same layers, same tool-result texts, same DTL injections, same media rules.
//!
//! During phase 2 the old `MessageBuilder` still serves the legacy paths
//! (resume/recovery); the two are deleted together in phase 5.
use std::sync::Arc;
use agent_loop::activation::{ActivationSource, ToolRendering};
use agent_loop::context::{AssembleInput, ContextAssembler};
use agent_loop::store::{CallState, HistoryStore, Role};
use core_api::message_meta::{MessageMetadata, attachments_block};
use core_api::tool::MediaRef;
use core_api::user_fs::UserFs;
use serde_json::{Value, json};
use crate::compactor::SUMMARY_PREFIX;
use crate::config::DatetimeConfig;
use crate::loop_adapters::activation::SkaldActivationSource;
use crate::session::handler::media;
use crate::tools::tool_names as tn;
/// Stand-in for a tool-call turn's `reasoning_content` when none was recorded
/// (DeepSeek's thinking mode 400s on replay without it).
const REASONING_ROUNDTRIP_PLACEHOLDER: &str = "(no reasoning recorded for this step)";
/// 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()
}
/// Skald's `ContextAssembler`: static system → scratchpad → summary → history
/// (with DTL + media) → dynamic tail (+datetime) → tail reminder.
pub struct SkaldAssembler {
/// Owner pool — scratchpad reads (keyed on `scratchpad_sid`).
pub pool: Arc<sqlx::SqlitePool>,
/// Scratchpad scope (session_id, or the parent's for async sub-tasks).
pub scratchpad_sid: i64,
pub datetime_config: DatetimeConfig,
pub max_history_messages: usize,
pub max_tool_result_chars: Option<usize>,
/// The history window applies only when compaction is disabled.
pub compactor_enabled: bool,
/// The caller's fs view — media containment for inlining. `None` skips
/// media inlining entirely.
pub fs: Option<Arc<UserFs>>,
/// DTL activations (consulted only in non-Inline modes).
pub activation: Option<SkaldActivationSource>,
}
#[agent_loop::async_trait]
impl ContextAssembler for SkaldAssembler {
async fn build(
&self,
store: &Arc<dyn HistoryStore>,
input: &AssembleInput,
) -> agent_loop::Result<Vec<Value>> {
let mut out: Vec<Value> = Vec::new();
// ── 1. Static system message ──────────────────────────────────────────
let static_msg = 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 })
};
out.push(static_msg);
// ── 2. Scratchpad system message (before conversation) ────────────────
let scratch = crate::db::scratchpad::for_session(&self.pool, self.scratchpad_sid).await?;
if !scratch.is_empty() {
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 &scratch {
s.push_str(&format!(" <note key=\"{k}\">{v}</note>\n"));
}
s.push_str("</scratchpad>");
out.push(json!({ "role": "system", "content": s }));
}
// ── 3. Compaction summary + surviving history ─────────────────────────
let summary = store.latest_summary(input.frame).await?;
if let Some(s) = &summary {
out.push(json!({
"role": "system",
"content": format!(
"{SUMMARY_PREFIX}\n\n{}\n\n\
[End of context summary the following messages are the most recent exchanges in full.]",
s.text
)
}));
}
let mut history = match &summary {
Some(s) => store.load_since(input.frame, s.covered_up_to).await?,
None => store.load(input.frame).await?,
};
if !self.compactor_enabled && history.len() > self.max_history_messages {
history.drain(..history.len() - self.max_history_messages);
if matches!(history.first().map(|m| m.role), Some(Role::Assistant)) {
history.drain(..1);
}
}
let current_turn_boundary = history
.iter()
.rposition(|e| matches!(e.role, Role::User | Role::Agent));
// Inline-media turn group: trailing assistant rows are the in-flight
// turn's own rounds; the current turn's user messages sit just before
// them. Older-turn media degrades to the textual path block.
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;
}
// DTL: tools activated at each assistant message (empty in Inline mode).
let activation_defs: std::collections::HashMap<i64, Vec<Value>> =
match (&self.activation, input.model.tool_rendering) {
(Some(src), ToolRendering::Inline) => {
let _ = src;
Default::default()
}
(Some(src), _) => src
.activations(input.frame)
.await
.unwrap_or_default()
.into_iter()
.map(|a| (a.anchor.get(), a.defs))
.collect(),
(None, _) => Default::default(),
};
// ── 4. Conversation history ───────────────────────────────────────────
for (idx, entry) in history.iter().enumerate() {
let is_previous_turn = current_turn_boundary.is_some_and(|b| idx < b);
match entry.role {
Role::System => {}
Role::User | Role::Agent => {
let metadata: Option<MessageMetadata> = entry
.metadata
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
let (text, media_parts) = match &metadata {
Some(meta)
if !meta.attachments.is_empty()
&& idx >= media_turn_start
&& self.fs.is_some() =>
{
let fs = self.fs.as_deref().expect("guarded by is_some()");
let partition = media::partition(&meta.attachments, &input.model.capabilities, fs).await;
(
format!("{}{}", entry.content, attachments_block(&partition.rest)),
partition.parts,
)
}
Some(meta) if !meta.attachments.is_empty() => (
format!("{}{}", entry.content, attachments_block(&meta.attachments)),
Vec::new(),
),
_ => (entry.content.clone(), Vec::new()),
};
push_user_chunk(&mut out, text, media_parts);
}
Role::Assistant => {
if entry.calls.is_empty() {
let mut msg = json!({ "role": "assistant", "content": entry.content });
if let Some(rc) = entry.reasoning.as_deref().filter(|s| !s.is_empty()) {
msg["reasoning_content"] = rc.into();
msg["reasoning"] = rc.into();
}
out.push(msg);
} else {
let tc_array: Vec<Value> = entry.calls
.iter()
.map(|tc| json!({
"id": tc.provider_id,
"type": "function",
"function": {
"name": tc.name,
"arguments": serde_json::to_string(&tc.arguments)
.unwrap_or_else(|_| "{}".into()),
}
}))
.collect();
let mut msg = json!({
"role": "assistant",
"content": entry.content,
"tool_calls": tc_array,
});
// DeepSeek thinking mode: a tool-calling assistant turn must
// carry a NON-EMPTY reasoning_content on replay.
let rc = entry.reasoning.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(REASONING_ROUNDTRIP_PLACEHOLDER);
msg["reasoning_content"] = rc.into();
msg["reasoning"] = rc.into();
out.push(msg);
for tc in &entry.calls {
let result_content = match tc.state {
CallState::Done => tc.result.clone().unwrap_or_default(),
CallState::Failed => format!(
"Error: {}",
tc.result.as_deref().unwrap_or("unknown error")
),
CallState::Rejected => tc.result.clone()
.unwrap_or_else(|| "User rejected this tool call.".to_string()),
CallState::Cancelled => tc.result.clone()
.unwrap_or_else(|| "Tool call was cancelled by the user.".to_string()),
// 'pending'/'running' left behind by a crash or a lost
// connection: the call really was interrupted mid-flight.
_ => "Error: tool call was interrupted (connection lost before user approval). Please retry the operation.".to_string(),
};
let result_content = self.maybe_hide_tool_result(
result_content,
is_previous_turn,
&tc.name,
&tc.arguments,
);
let mut tool_msg = json!({
"role": "tool",
"tool_call_id": tc.provider_id,
"content": result_content,
});
// Anthropic DTL: an `activate_tools` result becomes a set of
// `tool_reference`s.
if matches!(input.model.tool_rendering, ToolRendering::DeferredToolReference)
&& tc.name == tn::ACTIVATE_TOOLS
&& let Some(adefs) = activation_defs.get(&entry.id.get())
{
let names: Vec<Value> = adefs.iter()
.filter_map(|d| d["function"]["name"].as_str())
.map(|n| Value::String(n.to_string()))
.collect();
if !names.is_empty() {
tool_msg["_tool_references"] = Value::Array(names);
}
}
out.push(tool_msg);
}
// Tool-produced media of the current turn: inline as a
// synthetic `user` message right after the tool-result group.
if idx >= media_turn_start
&& let Some(fs) = self.fs.as_deref()
{
let mut refs: Vec<MediaRef> = Vec::new();
for tc in &entry.calls {
if let Some(mj) = tc.extras["media"].as_str()
&& let Ok(mut v) = serde_json::from_str::<Vec<MediaRef>>(mj)
{
refs.append(&mut v);
}
}
if !refs.is_empty() {
let parts = media::inline_paths(&refs, &input.model.capabilities, fs).await;
if !parts.is_empty() {
out.push(json!({ "role": "user", "content": parts }));
}
}
}
// Kimi K3 DTL: the tools activated at this assistant message,
// as a `system` message carrying a `tools` field, right after
// its tool-result group (append-only → cache-safe).
if matches!(input.model.tool_rendering, ToolRendering::SystemToolBlock)
&& let Some(adefs) = activation_defs.get(&entry.id.get())
&& !adefs.is_empty()
{
out.push(json!({ "role": "system", "tools": adefs }));
}
}
}
}
}
// ── 5. Dynamic tail (extra dynamic + datetime) ────────────────────────
{
let datetime_line = self.datetime_line();
let extra_dynamic = input.system.dynamic_tail.first().map(String::as_str);
let tail = match (extra_dynamic, datetime_line.as_deref()) {
(Some(dyn_ctx), Some(dt)) => Some(format!("{dyn_ctx}\n\n---\n{dt}")),
(Some(dyn_ctx), None) => Some(dyn_ctx.to_string()),
(None, Some(dt)) => Some(dt.to_string()),
(None, None) => None,
};
if let Some(content) = tail {
out.push(json!({ "role": "system", "content": content }));
}
}
// ── 6. Tail reminder ──────────────────────────────────────────────────
if let Some(reminder) = &input.system.tail_reminder {
out.push(json!({ "role": "system", "content": reminder }));
}
Ok(out)
}
}
impl SkaldAssembler {
/// The current date/time + OS + cwd block (empty when disabled).
fn datetime_line(&self) -> Option<String> {
if !self.datetime_config.enabled {
return None;
}
let now_utc = chrono::Utc::now();
let secs = now_utc.timestamp();
let secs = match self.datetime_config.round_minutes {
Some(m) if m > 0 => {
let bucket = (m as i64) * 60;
(secs / bucket) * bucket
}
_ => secs,
};
let tz = self.datetime_config.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}"),
};
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()
))
}
/// Replaces an over-limit previous-turn result with an informative 1-liner.
fn maybe_hide_tool_result(
&self,
result: String,
is_previous_turn: bool,
tool_name: &str,
arguments: &Value,
) -> String {
if !is_previous_turn {
return result;
}
let Some(limit) = self.max_tool_result_chars else {
return result;
};
if result.len() <= limit {
return result;
}
summarize_tool_result(tool_name, arguments, &result)
}
}
// ── Free helpers (ported verbatim from message_builder.rs) ─────────────────────
/// Appends one user/agent chunk, coalescing with a preceding `user` message.
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 }));
}
}
/// Creates an informative 1-line summary of a tool call result.
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 serde_json::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 = crate::session::handler::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<serde_json::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 = crate::session::handler::preview_truncate(v.as_str().unwrap_or_default(), 40);
format!(" {k}={sv}")
})
.unwrap_or_default();
format!("[{tool_name}]{first_arg} ({char_count} chars result)")
}
}
}
@@ -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
}
}
+20 -14
View File
@@ -195,22 +195,27 @@ impl Tool for SkaldAskUserTool {
// ── ExecuteTaskAliasTool ─────────────────────────────────────────────────────
/// The legacy `execute_task`: `mode=sync` (or unspecified) delegates to the
/// crate's `DelegateTool`; `mode=async` rides the legacy interface-tool
/// handler (ChatHub's task injection) until phase 3 wires `CronExecutor`.
/// 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,
async_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
delegate: DelegateTool,
definition: Value,
cron_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
}
impl ExecuteTaskAliasTool {
pub fn new(
delegate: DelegateTool,
definition: Value,
async_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
delegate: DelegateTool,
definition: Value,
cron_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
) -> Self {
Self { delegate, definition, async_handler }
Self { delegate, definition, cron_handler }
}
}
@@ -221,14 +226,15 @@ impl Tool for ExecuteTaskAliasTool {
fn definition(&self) -> Value { self.definition.clone() }
fn concurrency_safe(&self, args: &Value) -> bool {
args["mode"].as_str() != Some("async")
// 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("async") {
let Some(handler) = &self.async_handler else {
if args["mode"].as_str() == Some("cron") {
let Some(handler) = &self.cron_handler else {
return Err(ToolFailure::Failed(
"execute_task: async mode is not available in this session".into(),
"execute_task: cron mode is not available in this session".into(),
));
};
return handler(args)
+89 -102
View File
@@ -3,26 +3,32 @@
//! 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};
use std::sync::{Arc, RwLock, Weak};
use agent_loop::context::ContextAssembler;
use agent_loop::delegate::{AgentCatalog, AgentKind, AgentProfile, AgentSummary, DelegateTool, ToolSelection};
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;
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::config::DatetimeConfig;
use crate::llm::LlmManager;
use crate::loop_adapters::activation::SkaldToolActivator;
use crate::loop_adapters::assembler::SkaldAssembler;
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;
@@ -30,36 +36,24 @@ use crate::mcp::McpProvider;
use crate::tools::ToolRegistry;
use crate::tools::tool_names as tn;
/// Everything the catalog needs from the parent turn, captured at wiring time.
/// The catalog's own dependencies — all of them user-scoped.
pub struct SkaldAgentCatalog {
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
session_id: i64,
source: String,
is_interactive: bool,
context_label: Arc<RwLock<Option<String>>>,
llm_manager: Arc<LlmManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
mcp: Arc<dyn McpProvider>,
registry: Arc<ToolRegistry>,
/// Parent turn's derived def lists (the child's base derives from these).
base_defs: Vec<serde_json::Value>,
config_defs: Arc<Vec<serde_json::Value>>,
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
core_tools: Vec<Arc<dyn crate::tools::Tool>>,
root_only: Vec<String>,
/// The delegate tool, injected post-construction (catalog ↔ delegate cycle).
delegate: RwLock<Option<Arc<DelegateTool>>>,
/// Per-turn assembler knobs shared with children.
datetime_config: DatetimeConfig,
max_history_messages: usize,
max_tool_result_chars: Option<usize>,
compactor_enabled: bool,
fs: Option<Arc<core_api::user_fs::UserFs>>,
project_root: Option<String>,
/// 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 {
@@ -68,69 +62,51 @@ impl SkaldAgentCatalog {
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
session_id: i64,
source: String,
is_interactive: bool,
context_label: Arc<RwLock<Option<String>>>,
llm_manager: Arc<LlmManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
mcp: Arc<dyn McpProvider>,
registry: Arc<ToolRegistry>,
base_defs: Vec<serde_json::Value>,
config_defs: Arc<Vec<serde_json::Value>>,
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
root_only: Vec<String>,
datetime_config: DatetimeConfig,
max_history_messages: usize,
max_tool_result_chars: Option<usize>,
compactor_enabled: bool,
fs: Option<Arc<core_api::user_fs::UserFs>>,
project_root: Option<String>,
fs: SharedFs,
config: LoopConfig,
) -> Self {
let core_tools = registry.all_tools();
Self {
pool,
shared_pool,
user_id,
session_id,
source,
is_interactive,
context_label,
llm_manager,
approval,
clarification,
mcp,
registry,
base_defs,
config_defs,
memory_tools,
image_tools,
core_tools,
root_only,
delegate: RwLock::new(None),
datetime_config,
max_history_messages,
max_tool_result_chars,
compactor_enabled,
fs,
project_root,
config,
delegate: RwLock::new(Weak::new()),
}
}
/// Post-construction wiring of the delegate (the catalog ↔ delegate cycle).
pub fn set_delegate(&self, delegate: DelegateTool) {
*self.delegate.write().unwrap() = Some(Arc::new(delegate));
/// 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) -> agent_loop::Result<AgentProfile> {
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}"))?;
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.
@@ -139,27 +115,31 @@ impl AgentCatalog for SkaldAgentCatalog {
// 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: self.project_root.clone(),
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> = self
let mut child_defs: Vec<serde_json::Value> = scope
.base_defs
.iter()
.filter(|d| {
let name = d["function"]["name"].as_str().unwrap_or("");
!self.root_only.iter().any(|n| n == name)
!scope.root_only.iter().any(|n| n == name)
&& name != tn::ASK_USER_CLARIFICATION
&& name != tn::EXECUTE_SUBTASK
&& name != tn::EXECUTE_TASK
@@ -178,7 +158,8 @@ impl AgentCatalog for SkaldAgentCatalog {
}
// Native child tools: clarification, sub-delegation (depth permitting),
// and the frame-scoped activate_tools with a FRESH grant set.
// 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
@@ -191,60 +172,66 @@ impl AgentCatalog for SkaldAgentCatalog {
{
let channel = Arc::new(SkaldHumanChannel::new(
self.clarification.clone(),
self.session_id,
scope.session_id,
id,
&self.source,
self.is_interactive,
self.context_label.clone(),
&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.
let delegate = self.delegate.read().unwrap().clone();
if let Some(d) = delegate {
native.push(Arc::new(d.as_ref().clone().with_name(tn::EXECUTE_SUBTASK)));
// `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(),
self.session_id,
scope.session_id,
Some(child_frame.get()),
)))));
let toolset: Arc<dyn agent_loop::tool::ToolSet> = Arc::new(
SkaldToolSet::new(
child_defs,
self.config_defs.clone(),
scope.config_defs.clone(),
self.mcp.clone(),
child_grants,
self.memory_tools.clone(),
self.image_tools.clone(),
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(SkaldAssembler {
pool: self.pool.clone(),
scratchpad_sid: self.session_id,
datetime_config: self.datetime_config.clone(),
max_history_messages: self.max_history_messages,
max_tool_result_chars: self.max_tool_result_chars,
compactor_enabled: self.compactor_enabled,
fs: self.fs.clone(),
activation: Some(crate::loop_adapters::activation::SkaldActivationSource::new(
self.pool.clone(),
self.mcp.clone(),
self.config_defs.clone(),
self.session_id,
Some(child_frame.get()),
)),
});
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(),
+101 -89
View File
@@ -10,74 +10,45 @@
//! to `GateDecision::Suspend` (the call stays `AwaitingHuman`, the turn
//! ends) — the old `GateOutcome::ChannelClosed`.
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
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 tokio::sync::RwLock;
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};
/// Everything the gate needs that the current loop keeps on the handler.
/// Shared by reference so phase-2 wiring shares the same cells.
/// 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>,
session_id: i64,
source: String,
group_id: Option<String>,
run_context: Arc<RwLock<Option<RunContext>>>,
pre_approved: Arc<Mutex<HashSet<i64>>>,
auto_deny: Arc<AtomicBool>,
context_label: Arc<std::sync::RwLock<Option<String>>>,
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>,
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
fs: Option<SharedFs>,
}
impl ApprovalGate {
#[allow(clippy::too_many_arguments)]
pub fn new(
approval: Arc<ApprovalManager>,
store: Arc<dyn HistoryStore>,
tools: Arc<ToolRegistry>,
session_id: i64,
source: impl Into<String>,
group_id: Option<String>,
run_context: Arc<RwLock<Option<RunContext>>>,
pre_approved: Arc<Mutex<HashSet<i64>>>,
auto_deny: Arc<AtomicBool>,
context_label: Arc<std::sync::RwLock<Option<String>>>,
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
fs: Option<SharedFs>,
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,
session_id,
source: source.into(),
group_id,
run_context,
pre_approved,
auto_deny,
context_label,
pool,
shared_pool,
fs,
}
Self { approval, store, tools, pool, shared_pool, fs }
}
/// Reads the current content of a file for the `PendingWrite` diff, routed
@@ -203,8 +174,17 @@ impl ApprovalGate {
#[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 self.pre_approved.lock().unwrap().remove(&call.id.get()) {
if scope.pre_approved.lock().unwrap().remove(&call.id.get()) {
return GateDecision::Allow;
}
@@ -214,13 +194,13 @@ impl Gate for ApprovalGate {
let mut gate = self
.approval
.check(
self.session_id,
scope.session_id,
category,
&call.agent,
&self.source,
&scope.source,
&call.name,
&call.args,
self.group_id.as_deref(),
scope.group_id.as_deref(),
)
.await;
@@ -228,7 +208,7 @@ impl Gate for ApprovalGate {
// (never overrides a Deny).
if matches!(gate, GateResult::Require) {
let path = call.args["path"].as_str().unwrap_or("");
let guard = self.run_context.read().await.clone();
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) {
@@ -249,7 +229,7 @@ impl Gate for ApprovalGate {
reason: "Tool call denied by approval policy.".to_string(),
},
GateResult::Require => {
if self.auto_deny.load(Ordering::Relaxed) {
if scope.auto_deny.load(Ordering::Relaxed) {
return GateDecision::Reject {
reason: "Tool call auto-denied: this session does not support approval requests."
.to_string(),
@@ -263,16 +243,16 @@ impl Gate for ApprovalGate {
};
}
let label = self.context_label.read().ok().and_then(|g| g.clone());
let label = scope.context_label.read().ok().and_then(|g| g.clone());
let (request_id, approve_rx) = self
.approval
.register(
self.session_id,
scope.session_id,
call.id.get(),
&call.name,
call.args.clone(),
&call.agent,
&self.source,
&scope.source,
label.as_deref(),
category,
)
@@ -293,6 +273,11 @@ impl Gate for ApprovalGate {
#[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};
@@ -318,6 +303,44 @@ mod tests {
}
}
/// 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,
@@ -350,28 +373,13 @@ mod tests {
approval.clone(),
store,
tools,
1,
"web",
None,
Arc::new(tokio::sync::RwLock::new(None)),
Arc::new(Mutex::new(HashSet::new())),
Arc::new(AtomicBool::new(false)),
Arc::new(std::sync::RwLock::new(None)),
pool.clone(),
pool.clone(),
None,
);
let (bus, _) = tokio::sync::broadcast::channel(16);
let events = EventSink::new(ConversationId::new("session:1"), bus);
let call = PendingCall {
id: ToolCallId(call_id),
name: "some_tool".into(),
args: json!({}),
frame: FrameId(frame.id),
parent_frame: None,
agent: "assistant".into(),
extensions: Extensions::new(),
};
let call = pending(call_id, frame.id, scope("web", false));
Fixture { gate, events, pool, call, path, approval }
}
@@ -416,28 +424,14 @@ mod tests {
approval,
Arc::new(SqliteHistory::new(pool.clone())),
Arc::new(ToolRegistry::new()),
1,
"cron", // background source: auto-deny
None,
Arc::new(tokio::sync::RwLock::new(None)),
Arc::new(Mutex::new(HashSet::new())),
Arc::new(AtomicBool::new(true)),
Arc::new(std::sync::RwLock::new(None)),
pool.clone(),
pool.clone(),
None,
);
let (bus, _) = tokio::sync::broadcast::channel(16);
let events = EventSink::new(ConversationId::new("session:1"), bus);
let call = PendingCall {
id: ToolCallId(call_id),
name: "some_tool".into(),
args: json!({}),
frame: FrameId(frame.id),
parent_frame: None,
agent: "assistant".into(),
extensions: Extensions::new(),
};
// 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;
@@ -447,6 +441,24 @@ mod tests {
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;
+26 -1
View File
@@ -35,8 +35,13 @@ pub struct SqliteHistory {
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).
fn session_id(conv: &ConversationId) -> anyhow::Result<i64> {
pub fn session_id(conv: &ConversationId) -> anyhow::Result<i64> {
conv.as_str()
.strip_prefix("session:")
.and_then(|s| s.parse::<i64>().ok())
@@ -105,6 +110,10 @@ impl SqliteHistory {
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,
@@ -239,6 +248,22 @@ impl HistoryStore for SqliteHistory {
.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)
+50 -4
View File
@@ -1,14 +1,21 @@
//! Skald's `LoopHooks`: the file-write diff preview bracket (pre: capture the
//! old content; post: capture the new one and persist via `set_call_extras`).
//! Port of the `execute_tool_call` preview bracketing (blueprint §10).
//! 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::Mutex;
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;
@@ -59,3 +66,42 @@ impl LoopHooks for SkaldWritePreviewHook {
.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,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;
}
}
+22 -4
View File
@@ -1,6 +1,6 @@
//! Skald-side adapters implementing the `agent-loop` trait surface over the
//! existing infrastructure (blueprint §14 phase 1). **Unused by the current
//! loop** — they compile and are unit-tested here, and get wired in phase 2.
//! 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`
@@ -14,17 +14,35 @@
//! `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 assembler;
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,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,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"
}
]
+333 -19
View File
@@ -1,9 +1,13 @@
//! `AgentSystemContext` — Skald's agent prompt as a `SystemContextSource`
//! (the static half of the old `MessageBuilder::build`, blueprint §10):
//! AGENT.md + `inject_memory` files + skills index + `extra_system` +
//! `__MCP_LIST__` / `__SHARED_FOLDERS__` / `__USER_PROFILE__` / custom
//! substitutions. The dynamic tail (Honcho memory, per-turn overrides) rides
//! as `dynamic_tail`; the datetime line and scratchpad stay assembler-side.
//! `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;
@@ -11,6 +15,7 @@ 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
@@ -35,6 +40,10 @@ pub struct AgentSystemContext {
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]
@@ -84,21 +93,13 @@ impl SystemContextSource for AgentSystemContext {
if static_content.contains("__SHARED_FOLDERS__") {
static_content = static_content.replace(
"__SHARED_FOLDERS__",
&crate::session::handler::message_builder::render_shared_folders_section(
&self.shared_pool,
&self.user_id,
)
.await?,
&render_shared_folders_section(&self.shared_pool, &self.user_id).await?,
);
}
if static_content.contains("__USER_PROFILE__") {
static_content = static_content.replace(
"__USER_PROFILE__",
&crate::session::handler::message_builder::render_user_profile_section(
&self.shared_pool,
&self.user_id,
)
.await?,
&render_user_profile_section(&self.shared_pool, &self.user_id).await?,
);
}
@@ -109,16 +110,121 @@ impl SystemContextSource for AgentSystemContext {
}
}
// 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: Vec::new(),
dynamic_tail: self.extra_dynamic.clone().into_iter().collect(),
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) {
@@ -184,3 +290,211 @@ impl AgentSystemContext {
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)"
);
}
}
@@ -1,11 +1,12 @@
//! 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 old `TurnEmitter` sequence is the contract.
//! 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;
@@ -15,12 +16,17 @@ use crate::events::{ServerEvent, TokenDeltaKind};
use crate::mcp::McpProvider;
use crate::tools::{ToolRegistry, is_file_write_tool};
/// Forwards one conversation's loop events to the session's WS `tx`.
/// 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>,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
store: Arc<dyn HistoryStore>,
tx: mpsc::Sender<ServerEvent>,
conv: ConversationId,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
store: Arc<dyn HistoryStore>,
shared: Arc<std::sync::Mutex<TranslateShared>>,
}
@@ -37,29 +43,48 @@ pub struct TranslateShared {
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, tools, mcp, store, shared: shared.clone() }, shared)
(Self { tx, conv, tools, mcp, store, shared: shared.clone() }, shared)
}
/// Subscribe and forward until `stop` is cancelled (the turn's end).
pub fn spawn(self, mut rx: tokio::sync::broadcast::Receiver<Event<LoopEvent>>, stop: tokio_util::sync::CancellationToken) -> tokio::task::JoinHandle<()> {
/// 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 {
tokio::select! {
_ = stop.cancelled() => break,
ev = rx.recv() => {
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,
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,
}
}
})
@@ -70,6 +95,10 @@ impl EventTranslator {
}
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 { .. } => {}
@@ -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,
}
}
}
@@ -38,7 +38,7 @@ pub(crate) 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>,
@@ -1,177 +0,0 @@
//! Per-tool-call dispatch router.
//!
//! Extracted from `run_agent_turn`: `execute_tool_call` routes an approved call to
//! the right executor (special non-cancellable paths + the unified cancellable
//! `ToolExecution` path). The session working directory is always the user's home
//! (`~`); tool calls receive their arguments unchanged, and the agent references
//! project files via the absolute agent path `projects/{owner}/{slug}/…`.
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::events::ServerEvent;
use crate::tools::{drive_execution, is_file_write_tool, tool_names as tn, ExecutionOutcome, ToolResult};
use super::ChatSessionHandler;
use super::interface_tools::AgentRunConfig;
/// 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 the WS payload — the detail
/// page then shows no diff for it.
const MAX_PREVIEW_BYTES: usize = 256 * 1024;
/// A file-write tool's before/after snapshot, captured by `execute_tool_call` around
/// the write so the diff renders inline and survives a reload (Phase 2). `None` sides
/// mean unreadable / new file / over the cap.
pub(super) struct WritePreview {
pub old: Option<String>,
pub new: Option<String>,
}
/// Drops a captured snapshot over the size cap (a truncated snapshot would render a
/// misleading diff, so omit it entirely).
fn cap_preview(s: Option<String>) -> Option<String> {
s.filter(|c| c.len() <= MAX_PREVIEW_BYTES)
}
/// Whether a tool call is a synchronous sub-agent dispatch, i.e. one intercepted
/// by `execute_tool_call` and routed to `dispatch_sub_agent` rather than the
/// registry. Covers `execute_task` (mode=sync), `execute_subtask`, and the legacy
/// `run_subtask` alias (only reachable via a `pending` call left across a restart).
/// Shared by the router below and the parallel-batch detection in `run_agent_turn`.
pub(super) fn is_sync_sub_agent(tool_name: &str, args: &Value) -> bool {
(tool_name == tn::EXECUTE_TASK && args["mode"].as_str() == Some("sync") && args.get("agent_id").is_some())
|| tool_name == tn::EXECUTE_SUBTASK
|| tool_name == "run_subtask"
}
/// Result of routing a single tool call to its executor.
pub(super) enum DispatchResult {
/// Normal completion / failure / cancellation — the caller records it. `preview`
/// carries a file-write's before/after snapshot (else `None`) for the diff card.
Outcome {
outcome: ExecutionOutcome,
preview: Option<WritePreview>,
},
/// The turn must end now and the tool row must stay `pending`: the
/// `ask_user_clarification` WS channel closed while awaiting an answer. The
/// caller returns `TurnOutcome::Cancelled` **without** recording the tool, so
/// `resume_pending_tools` re-asks it on reconnect.
AbortPending,
}
impl ChatSessionHandler {
/// Routes one already-approved tool call to the right executor. Covers the
/// special, non-cancellable paths (sub-agent, scratchpad, todos, clarification,
/// the `task_completed` stub) and the unified cancellable `ToolExecution` path
/// (registry / memory / image / interface / MCP). `restart` is handled by the
/// caller before this is reached (it calls `_exit` and never returns).
#[allow(clippy::too_many_arguments)]
pub(super) async fn execute_tool_call(
&self,
stack_id: i64,
config: &AgentRunConfig,
tool_call_id: i64,
tool_name: &str,
args: &Value,
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
) -> DispatchResult {
let outcome: ExecutionOutcome = if is_sync_sub_agent(tool_name, args) {
plain_outcome(self.dispatch_sub_agent(stack_id, config, tool_call_id, args, token, tx).await)
} else if tool_name == tn::UPDATE_SCRATCHPAD {
plain_outcome(self.dispatch_update_scratchpad(args).await)
} else if tool_name == tn::WRITE_TODOS {
plain_outcome(self.dispatch_write_todos(args).await)
} else if tool_name == tn::ASK_USER_CLARIFICATION {
match self.dispatch_ask_user_clarification(tool_call_id, args, tx).await {
Ok(answer) => ExecutionOutcome::Completed(ToolResult::Text(answer)),
Err(err) => {
// WS disconnected while waiting for a clarification answer.
// Tool stays 'pending' in DB — resume_pending_tools re-dispatches on reconnect.
if matches!(err.downcast_ref::<super::AgentFlowSignal>(), Some(super::AgentFlowSignal::QuestionChannelClosed)) {
warn!(session_id = self.session_id, tool_call_id, "clarification channel closed — aborting turn (tool stays pending)");
return DispatchResult::AbortPending;
}
ExecutionOutcome::Failed(err.to_string())
}
}
} else if tool_name == "task_completed" {
// Defensive stub: if the LLM somehow calls this itself, return a hint.
// Real delivery is via inject_async_result (synthetic message from the system).
let task_id = args["task_id"].as_i64().unwrap_or(0);
ExecutionOutcome::Completed(ToolResult::Text(format!(r#"{{"status":"not_ready","task_id":{task_id},"message":"This tool is invoked by the system, not by you. Do not call it again — the result will arrive automatically as a new message in this conversation."}}"#)))
} else {
// Unified cancellable path. The execution owns its in-flight state and
// its own stop(); on /stop the work future is dropped (aborting I/O /
// killing the child) and the tool is recorded as Cancelled, not Failed.
//
// For a file-write tool, bracket the execution with a before/after
// snapshot so its diff renders inline and survives a reload (Phase 2).
// The reads route memory-vs-disk exactly like the write itself
// (`read_current_content`); `new` is captured only on success.
let write_path = if is_file_write_tool(tool_name) {
args["path"].as_str().map(str::to_string)
} else {
None
};
let preview_old = match &write_path {
Some(p) => cap_preview(self.read_current_content(p).await),
None => None,
};
let outcome = match self.build_execution(tool_name, args.clone(), config) {
Some(exec) => drive_execution(exec.as_ref(), token).await,
None => ExecutionOutcome::Failed(format!("Unknown tool: {tool_name}")),
};
let preview = match &write_path {
Some(p) => {
let new = if matches!(outcome, ExecutionOutcome::Completed(_)) {
cap_preview(self.read_current_content(p).await)
} else {
None
};
Some(WritePreview { old: preview_old, new })
}
None => None,
};
return DispatchResult::Outcome { outcome, preview };
};
DispatchResult::Outcome { outcome, preview: None }
}
}
/// Maps a plain dispatch `Result<String>` to an [`ExecutionOutcome`]. Used by the
/// non-cancellable special paths (sub-agent, scratchpad, todos), which can only
/// complete or fail — never `Cancelled`.
fn plain_outcome(result: anyhow::Result<String>) -> ExecutionOutcome {
match result {
Ok(s) => ExecutionOutcome::Completed(ToolResult::Text(s)),
Err(e) => ExecutionOutcome::Failed(e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::is_sync_sub_agent;
use serde_json::json;
#[test]
fn recognises_sync_sub_agent_calls() {
assert!(is_sync_sub_agent("execute_task", &json!({"mode": "sync", "agent_id": "x"})));
assert!(is_sync_sub_agent("execute_subtask", &json!({})));
assert!(is_sync_sub_agent("run_subtask", &json!({}))); // legacy alias
}
#[test]
fn rejects_everything_else() {
// execute_task without mode=sync + agent_id is NOT a sync sub-agent.
assert!(!is_sync_sub_agent("execute_task", &json!({"mode": "async", "agent_id": "x"})));
assert!(!is_sync_sub_agent("execute_task", &json!({"mode": "sync"}))); // no agent_id
assert!(!is_sync_sub_agent("execute_task", &json!({})));
// Regular tools never qualify (they must keep the sequential path).
assert!(!is_sync_sub_agent("read_file", &json!({"path": "/x"})));
assert!(!is_sync_sub_agent("execute_cmd", &json!({"cmd": "ls"})));
}
}
@@ -1,170 +0,0 @@
//! Typed, fire-and-forget event seam for a running agent turn.
//!
//! Every event a turn produces used to be sent inline as
//! `tx.send(ServerEvent::X { .. }).await.ok()`, scattered across `llm_loop`,
//! `resume`, `agent_dispatch`, and `approval`. `TurnEmitter` wraps the per-turn
//! `mpsc::Sender<ServerEvent>` (which `ChatHub` bridges onto the global broadcast
//! bus) and exposes one semantic method per event, so the loop speaks in domain
//! terms (`emitter.tool_done(..)`) instead of constructing wire enums by hand.
//!
//! It is a zero-cost borrow wrapper: construct one at the top of a function that
//! emits and pass `&TurnEmitter` to any helper. This is also the single seam a
//! future event-bus / UI-vs-domain split would hook into.
use serde_json::Value;
use tokio::sync::mpsc;
use core_api::message_meta::Attachment;
use crate::events::ServerEvent;
/// Borrows the per-turn event sender and emits typed [`ServerEvent`]s.
pub(super) struct TurnEmitter<'a> {
tx: &'a mpsc::Sender<ServerEvent>,
}
impl<'a> TurnEmitter<'a> {
pub(super) fn new(tx: &'a mpsc::Sender<ServerEvent>) -> Self {
Self { tx }
}
/// Send an event, dropping it silently if the receiver is gone (the same
/// `.await.ok()` semantics every call site used before).
async fn emit(&self, event: ServerEvent) {
self.tx.send(event).await.ok();
}
// ── User / assistant turn events ────────────────────────────────────────
/// A user message row was persisted (telnet-style echo).
pub(super) async fn user_message(&self, message_id: i64, content: String, attachments: Vec<Attachment>) {
self.emit(ServerEvent::UserMessage { message_id, content, attachments }).await;
}
/// The assistant produced text alongside tool calls (reasoning before acting).
pub(super) async fn thinking(&self, message_id: i64, content: String, input_tokens: Option<u32>, output_tokens: Option<u32>, reasoning_content: Option<String>) {
self.emit(ServerEvent::Thinking { message_id, content, input_tokens, output_tokens, reasoning_content }).await;
}
/// Clone of the underlying sender, for spawning side-channel tasks that
/// emit alongside the turn (e.g. the token-delta forwarder).
pub(super) fn sender(&self) -> mpsc::Sender<ServerEvent> {
self.tx.clone()
}
/// The assistant response is complete.
pub(super) async fn done(&self, message_id: i64, stack_id: i64, content: String, input_tokens: Option<u32>, output_tokens: Option<u32>, reasoning_content: Option<String>) {
self.emit(ServerEvent::Done { message_id, stack_id, content, input_tokens, output_tokens, reasoning_content }).await;
}
/// The LLM was cut off by the token limit.
pub(super) async fn truncated(&self, output_tokens: Option<u32>) {
self.emit(ServerEvent::Truncated { output_tokens }).await;
}
/// A fatal error occurred processing the request.
pub(super) async fn error(&self, message: String) {
self.emit(ServerEvent::Error { message }).await;
}
// ── Tool-call lifecycle ─────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) async fn tool_start(
&self,
tool_call_id: i64,
message_id: i64,
name: String,
arguments: Value,
display_name: String,
icon: String,
label_short: String,
label_full: String,
path: Option<String>,
) {
self.emit(ServerEvent::ToolStart {
tool_call_id, message_id, name, arguments, display_name, icon, label_short, label_full, path,
}).await;
}
pub(super) async fn tool_done(
&self,
tool_call_id: i64,
result: String,
result_type: String,
preview_old: Option<String>,
preview_new: Option<String>,
) {
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type, preview_old, preview_new }).await;
}
pub(super) async fn tool_error(&self, tool_call_id: i64, error: String) {
self.emit(ServerEvent::ToolError { tool_call_id, error }).await;
}
pub(super) async fn tool_cancelled(&self, tool_call_id: i64) {
self.emit(ServerEvent::ToolCancelled { tool_call_id }).await;
}
pub(super) async fn tool_rejected(&self, tool_call_id: i64, reason: String) {
self.emit(ServerEvent::ToolRejected { tool_call_id, reason }).await;
}
/// A file-write tool completed; ask clients holding the file to reload.
pub(super) async fn file_changed(&self, path: String) {
self.emit(ServerEvent::FileChanged { path }).await;
}
// ── Approval / clarification prompts ────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) async fn pending_write(
&self,
request_id: i64,
tool_call_id: i64,
path: String,
old_content: Option<String>,
new_content: String,
) {
self.emit(ServerEvent::PendingWrite { request_id, tool_call_id, path, old_content, new_content }).await;
}
pub(super) async fn approval_required(&self, request_id: i64, tool_call_id: i64, tool_name: String, arguments: Value) {
self.emit(ServerEvent::ApprovalRequired { request_id, tool_call_id, tool_name, arguments }).await;
}
// Note: `AgentQuestion` is emitted directly in `dispatch_ask_user_clarification`
// because that one site inspects the send Result for diagnostic logging — it is
// deliberately not wrapped here.
// ── Sub-agent stack frames ──────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) async fn agent_start(
&self,
stack_id: i64,
parent_tool_call_id: i64,
agent_id: String,
parent_agent_id: String,
depth: i64,
prompt_preview: String,
) {
self.emit(ServerEvent::AgentStart {
stack_id, parent_tool_call_id, agent_id, parent_agent_id, depth, prompt_preview,
}).await;
}
pub(super) async fn agent_done(&self, stack_id: i64, agent_id: String, parent_agent_id: String, result_preview: String) {
self.emit(ServerEvent::AgentDone { stack_id, agent_id, parent_agent_id, result_preview }).await;
}
// ── LLM model fallback ──────────────────────────────────────────────────
pub(super) async fn model_fallback(&self, from: String, to: String, reason: String) {
self.emit(ServerEvent::ModelFallback { from, to, reason }).await;
}
pub(super) async fn llm_failed(&self, tried: Vec<String>, last_error: String) {
self.emit(ServerEvent::LlmFailed { tried, last_error }).await;
}
}
@@ -1,138 +0,0 @@
//! Shared approval gate for a single tool call.
//!
//! The decision + human-approval flow (approval-engine check, RunContext
//! fast-path, auto-deny, register + await) was duplicated in `run_agent_turn` and
//! `resume_pending_tools`, and had already drifted (only the live loop applied the
//! RunContext fast-path and the auto-deny short-circuit). `run_approval_gate` is the
//! single implementation both call, so the two paths gate identically.
use std::sync::atomic::Ordering;
use serde_json::Value;
use tracing::{info, warn};
use crate::approval::GateResult;
use crate::db::chat_llm_tools;
use crate::run_context::RunContext;
use crate::tools::{is_file_read_tool, is_file_write_tool};
use super::{ApprovalDecision, ChatSessionHandler};
use super::emitter::TurnEmitter;
/// Result of the approval gate for a single tool call.
pub(super) enum GateOutcome {
/// The tool may execute.
Proceed,
/// Denied by policy, auto-denied, or rejected by a human. The DB row has been
/// marked `rejected` and the `ToolRejected` event emitted — the caller just
/// skips the call.
Rejected,
/// The approval channel closed (WS disconnected) while awaiting a decision.
/// The caller must end the turn / resume.
ChannelClosed,
}
impl ChatSessionHandler {
/// Runs a tool call through the approval engine and, when human approval is
/// required, registers the request, emits the approval event, and awaits the
/// decision. Shared by `run_agent_turn` and `resume_pending_tools`.
pub(super) async fn run_approval_gate(
&self,
tool_call_id: i64,
tool_name: &str,
args: &Value,
agent_id: &str,
em: &TurnEmitter<'_>,
) -> anyhow::Result<GateOutcome> {
let pool = &self.db;
// Post-restart manual resolve: this exact tool_call was already approved by the
// user via a resolve endpoint, which then triggered this resume. There is no
// live oneshot to unblock, so skip re-gating (and re-prompting) and dispatch it.
if self.pre_approved.lock().unwrap().remove(&tool_call_id) {
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, "approval: pre-approved (post-restart resolve) — skipping gate");
return Ok(GateOutcome::Proceed);
}
let category = self.tools.category_of(tool_name);
let group_id = self.tool_group_id().await;
// The approval engine decides first: an explicit Deny/Allow rule always wins.
let mut gate = self.approval.check(
self.session_id, category,
agent_id, &self.source, tool_name, args,
group_id.as_deref(),
).await;
// RunContext fast-path: relax `Require` to `Allow` for pre-authorized
// filesystem paths. It never overrides a `Deny` (same semantics as session
// bypass), so e.g. the `secrets/` deny rule holds even inside an auto-read
// working directory.
if matches!(gate, GateResult::Require) {
let path = args["path"].as_str().unwrap_or("");
let guard = self.run_context.read().await;
let dflt = RunContext::default();
let rc = guard.as_ref().unwrap_or(&dflt);
let pre_allowed = if is_file_read_tool(tool_name) {
rc.is_read_allowed(path)
} else if is_file_write_tool(tool_name) {
rc.is_write_allowed(path)
} else {
false
};
if pre_allowed { gate = GateResult::Allow; }
}
match gate {
GateResult::Allow => Ok(GateOutcome::Proceed),
GateResult::Deny => {
let msg = "Tool call denied by approval policy.".to_string();
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, "approval: denied");
chat_llm_tools::reject(pool, tool_call_id, &msg).await?;
em.tool_rejected(tool_call_id, msg).await;
Ok(GateOutcome::Rejected)
}
GateResult::Require => {
if self.auto_deny_approvals.load(Ordering::Relaxed) {
let msg = "Tool call auto-denied: this session does not support approval requests.".to_string();
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, "auto_deny_approvals: denied");
chat_llm_tools::reject(pool, tool_call_id, &msg).await?;
em.tool_rejected(tool_call_id, msg).await;
return Ok(GateOutcome::Rejected);
}
// Mark as pending before suspending so restart/refresh shows the
// approval form (not "Interrupted") and auto-resume re-gates.
chat_llm_tools::set_approval_pending(pool, tool_call_id).await?;
let ctx_label = self.context_label.read().ok().and_then(|g| g.clone());
let (request_id, approve_rx) = self.approval.register(
self.session_id, tool_call_id, tool_name,
args.clone(), agent_id, &self.source,
ctx_label.as_deref(), category,
).await;
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, request_id, "approval: waiting for human");
self.emit_approval_event(em, request_id, tool_call_id, tool_name, args).await;
match approve_rx.await {
Ok(ApprovalDecision::Approved) => {
info!(session_id = self.session_id, request_id, tool = %tool_name, "approval: approved");
Ok(GateOutcome::Proceed)
}
Ok(ApprovalDecision::Rejected { note }) => {
info!(session_id = self.session_id, request_id, tool = %tool_name, %note, "approval: rejected");
let msg = ApprovalDecision::rejection_message(&note);
chat_llm_tools::reject(pool, tool_call_id, &msg).await?;
em.tool_rejected(tool_call_id, msg).await;
Ok(GateOutcome::Rejected)
}
Err(_) => {
// WS closed while waiting — session is orphaned.
warn!(session_id = self.session_id, request_id, "approval channel closed (WS disconnected), aborting");
Ok(GateOutcome::ChannelClosed)
}
}
}
}
}
}
@@ -12,7 +12,7 @@ pub use core_api::interface_tool::{InterfaceTool, ToolFuture};
/// All configuration for a single agent run (root or sub-agent).
///
/// Passed by reference to `run_agent_turn` and `dispatch_call_agent`.
/// Passed by reference to the turn builder (`UserLoopRuntime::turn_params`).
/// Callers build this once in `handle_message`; sub-agents receive a derived
/// config with an empty `interface_tools` (except `activate_tools`) and fresh
/// `active_mcp_grants`.
@@ -138,11 +138,11 @@ impl AgentRunConfig {
root_only(&mut defs);
// Strip the per-level augmentations that the config builders re-derive, so
// they are never inherited: `ask_user_clarification` is added by
// `build_agent_config` (root) and re-added by `dispatch_sub_agent`;
// `execute_subtask` is added by `dispatch_sub_agent`. Leaving them in the
// `build_agent_config` (root) and re-added by the agent catalog;
// `execute_subtask` is added by the catalog too. Leaving them in the
// inherited set would duplicate them (depth ≥ 1 for `ask_user_clarification`,
// depth ≥ 2 for `execute_subtask`) and the OpenAI-compat APIs reject
// non-unique tool names with HTTP 400. With this strip, `dispatch_sub_agent`
// non-unique tool names with HTTP 400. With this strip, the catalog
// is the single owner of sub-agent augmentation and duplication is
// structurally impossible — no dedup pass needed anywhere.
{
@@ -1,315 +1,128 @@
//! Kernel-driven root turn (phase 2, blueprint §14): `handle_message` builds
//! the turn's `TurnParams` from its fields and drives the `agent-loop` kernel
//! instead of `run_agent_turn`. The translator (`EventTranslator`) is the ONE
//! bus subscriber producing the session's `ServerEvent`s.
//! The session's turns, driven by the `agent-loop` kernel (blueprint §14).
//!
//! Sub-agents run on the same kernel via `DelegateTool` (sync); async
//! `execute_task` still rides the legacy interface handler until phase 3.
//! Recovery/resume stays on the old path until phase 3 as well.
//! Everything shared lives on the user's `UserLoopRuntime` (manager, store,
//! gate, catalog, delegate); this only assembles the turn's own state —
//! [`TurnScope`] plus the run config — and reads the outcome back. The
//! translator (`EventTranslator`) is the ONE bus subscriber producing the
//! session's `ServerEvent`s.
//!
//! Three entry points, one path:
//!
//! - [`run_kernel_turn`](ChatSessionHandler::run_kernel_turn) — a user message.
//! It repairs first: a call left dangling by a crash is resolved before the
//! new turn appends anything.
//! - [`recover_turn`](ChatSessionHandler::recover_turn) — no new message:
//! continue a turn that was interrupted (a client reconnecting, a background
//! job, a decision taken out of band).
//! - [`resolve_pending_call`](ChatSessionHandler::resolve_pending_call) — a
//! human answered an approval nothing is waiting on anymore.
//!
//! Sub-agents run on the same kernel via `DelegateTool`, sync and async alike.
use std::collections::HashMap;
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, NewMessage};
use agent_loop::tool::{Extensions, Tool as LoopTool, ToolSet};
use core_api::interface_tool::InterfaceTool;
use agent_loop::recovery::{HumanDecision, RecoveryPolicy, RecoveryReport};
use agent_loop::store::{NewMessage, Role};
use core_api::message_meta::MessageMetadata;
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::info;
use crate::chat_event_bus::ToolCallEvent;
use crate::events::ServerEvent;
use crate::loop_adapters::activation::{SkaldActivationSource, SkaldToolActivator};
use crate::loop_adapters::assembler::SkaldAssembler;
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::SkaldWritePreviewHook;
use crate::loop_adapters::live_input::PendingLiveInput;
use crate::loop_adapters::preview::PreviewContext;
use crate::loop_adapters::selector::SkaldSelector;
use crate::loop_adapters::system::AgentSystemContext;
use crate::loop_adapters::toolset::{CallerUserId, SkaldToolSet};
use crate::loop_adapters::runtime::{TurnInputs, UserLoopRuntime};
use crate::loop_adapters::scope::TurnScope;
use crate::loop_adapters::translate::EventTranslator;
use crate::tools::tool_names as tn;
use super::interface_tools::AgentRunConfig;
use super::{ChatSessionHandler, MAX_AGENT_DEPTH, PendingUserInput, TurnOutcome};
use super::interface_tools::{AgentRunConfig, InterfaceTool};
use super::{ChatSessionHandler, PendingUserInput, TurnOutcome};
/// Special-cased names handled natively (never legacy-wrapped).
const NATIVE_NAMES: &[&str] = &[tn::ACTIVATE_TOOLS, tn::EXECUTE_TASK];
/// What Skald does with a conversation a crash left mid-flight.
///
/// `ReExecute` + `ReAsk` is the historical behavior: an interrupted call runs
/// again and an approval card reappears — except where the tool itself says
/// otherwise (`execute_cmd` declares `MarkInterrupted`, D7: a command may
/// already have had its effect).
fn policy() -> RecoveryPolicy {
RecoveryPolicy {
interrupted_text: "Error: this tool call was interrupted by a restart and was NOT \
re-run automatically (its effects may be partial). Re-run it if \
the task still needs it."
.to_string(),
..RecoveryPolicy::default()
}
}
impl ChatSessionHandler {
/// Runs the root turn on the `agent-loop` kernel. Same observable contract
/// as `run_agent_turn` on the root: events over `tx`, `TurnOutcome` back.
/// Runs the root turn on the `agent-loop` kernel: events over `tx`, the
/// turn's outcome back.
pub(super) async fn run_kernel_turn(
&self,
stack_id: i64,
config: &AgentRunConfig,
user_content: &str,
is_synthetic: bool,
metadata: Option<&MessageMetadata>,
pending_input: Option<&Arc<dyn PendingUserInput>>,
tx: &mpsc::Sender<ServerEvent>,
config: &AgentRunConfig,
user_content: &str,
is_synthetic: bool,
metadata: Option<&MessageMetadata>,
pending_input: Option<&Arc<dyn PendingUserInput>>,
tx: &mpsc::Sender<ServerEvent>,
) -> anyhow::Result<TurnOutcome> {
let pool = self.db.clone();
let shared_pool = self.shared_pool.clone();
let conv = ConversationId::new(format!("session:{}", self.session_id));
let rt = self.loop_runtime.clone();
let conv = UserLoopRuntime::conversation(self.session_id);
// ── Store ──
let store = Arc::new(SqliteHistory::new(pool.clone()));
// ── The turn's own state, read by the long-lived gate and catalog ──
let scope = Arc::new(self.turn_scope(config).await);
// ── Selector (root strength from the agent meta, D14) ──
let strength = crate::agents::load_meta(&config.agent_id)
.ok()
.and_then(|m| m.strength);
let selector: Arc<dyn ModelSelector> =
Arc::new(SkaldSelector::new(self.llm_manager.clone(), strength));
// ── Gate ──
let group_id = self.tool_group_id().await;
let gate = ApprovalGate::new(
self.approval.clone(),
store.clone(),
self.tools.clone(),
self.session_id,
&self.source,
group_id,
self.run_context.clone(),
self.pre_approved.clone(),
self.auto_deny_approvals.clone(),
self.context_label.clone(),
pool.clone(),
shared_pool.clone(),
Some(self.fs.clone()),
);
// ── Hooks ──
let preview_hook = Arc::new(SkaldWritePreviewHook::new(PreviewContext {
pool: pool.clone(),
shared_pool: shared_pool.clone(),
fs: Some(self.fs.clone()),
}));
// ── Manager ──
let manager = Arc::new(
LoopManager::builder()
.models(selector)
.store(store.clone())
.gate_arc(Arc::new(gate))
.hook(preview_hook)
.max_rounds(self.max_tool_rounds)
.max_parallel_calls(self.max_parallel_subagents)
.build()?,
);
// ── Catalog + delegate ──
let config_defs = Arc::new(config.config_tool_defs.clone());
let catalog = Arc::new(SkaldAgentCatalog::new(
pool.clone(),
shared_pool.clone(),
self.user_id.clone(),
self.session_id,
self.source.clone(),
self.is_interactive,
self.context_label.clone(),
self.llm_manager.clone(),
self.approval.clone(),
self.clarification.clone(),
self.mcp.clone(),
self.tools.clone(),
config.base_tool_defs.clone(),
config_defs.clone(),
config.memory_tools.clone(),
config.image_tools.clone(),
config.root_only_tool_names.clone(),
self.datetime_config.clone(),
self.max_history_messages,
self.max_tool_result_chars,
self.compactor.is_some(),
Some(self.fs.load()),
self.run_context.read().await.as_ref().and_then(|rc| rc.project_root.clone()),
));
let delegate = DelegateTool::new(manager.clone(), catalog.clone(), store.clone(), MAX_AGENT_DEPTH as u32);
catalog.set_delegate(delegate.clone());
// ── Tool set ──
let mut native: Vec<Arc<dyn LoopTool>> = Vec::new();
// activate_tools (root scope — shares the config's grant set so the
// next round sees the new tools, exactly like today).
native.push(Arc::new(
ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
pool.clone(),
self.mcp.clone(),
config.active_mcp_grants.clone(),
self.session_id,
None,
)))
.with_definition(super::config::activate_tools_tool_def()),
));
// execute_task: sync → DelegateTool; async → the legacy interface handler.
{
let et = native_interface(config, tn::EXECUTE_TASK);
let (def, handler) = match et {
Some(it) => (it.definition.clone(), Some(it.handler.clone())),
None => (legacy_execute_task_def(), None),
};
native.push(Arc::new(ExecuteTaskAliasTool::new(
delegate.clone().with_name(tn::EXECUTE_TASK),
def,
handler,
)));
}
native.push(Arc::new(SkaldAskUserTool::new(
Arc::new(SkaldHumanChannel::new(
self.clarification.clone(),
self.session_id,
&config.agent_id,
&self.source,
self.is_interactive,
self.context_label.clone(),
)),
store.clone(),
)));
native.push(Arc::new(UpdateScratchpadTool::new(pool.clone(), self.scratchpad_sid())));
native.push(Arc::new(WriteTodosTool));
// Legacy interface tools (per-surface, minus the native ones).
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())));
}
let mut toolset = SkaldToolSet::new(
config.base_tool_defs.clone(),
config_defs.clone(),
self.mcp.clone(),
config.active_mcp_grants.clone(),
config.memory_tools.clone(),
config.image_tools.clone(),
legacy,
self.tools.all_tools(),
)
.with_discovery(self.tool_discovery.clone());
for t in native {
toolset = toolset.with_native(t);
}
let tools: Arc<dyn ToolSet> = Arc::new(toolset);
// ── System context ──
let system = Arc::new(AgentSystemContext {
agent_id: config.agent_id.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: pool.clone(),
shared_pool: shared_pool.clone(),
user_id: self.user_id.clone(),
mcp: self.mcp.clone(),
project_root: self.run_context.read().await.as_ref().and_then(|rc| rc.project_root.clone()),
});
// ── Assembler ──
let assembler = Arc::new(SkaldAssembler {
pool: pool.clone(),
scratchpad_sid: self.scratchpad_sid(),
datetime_config: self.datetime_config.clone(),
max_history_messages: self.max_history_messages,
max_tool_result_chars: self.max_tool_result_chars,
compactor_enabled: self.compactor.is_some(),
fs: Some(self.fs.load()),
activation: Some(SkaldActivationSource::new(
pool.clone(),
self.mcp.clone(),
config_defs.clone(),
self.session_id,
None,
)),
});
// ── Extensions (tool bridge context) ──
let mut extensions = Extensions::new();
extensions.insert(pool.clone());
extensions.insert(self.fs.load());
extensions.insert(Arc::new(CallerUserId(self.user_id.clone())));
// ── Live input ──
let live_input: Option<Arc<dyn LiveInput>> =
pending_input.map(|p| Arc::new(PendingLiveInput::new(p.clone())) as Arc<dyn LiveInput>);
// ── Translator ──
// ── The one bus subscriber for this session's events ──
let (translator, shared) = EventTranslator::new(
tx.clone(),
conv.clone(),
self.tools.clone(),
self.mcp.clone(),
store.clone(),
rt.store().clone(),
);
let stop = CancellationToken::new();
let translator_task = translator.spawn(manager.events(), stop.clone());
let translator_task = translator.spawn(rt.manager().events(), stop.clone());
// ── Frame + turn ──
let frame = store
.open_frame(&conv, None, agent_loop::store::FrameSpec::root(&config.agent_id))
// ── Drive ──
let mut params = rt
.turn_params(TurnInputs { scope, config, live_input: pending_input.cloned() })
.await?;
// The frame opened at session provisioning is the one the old path
// used — assert the mapping (defensive; remove once bedded in).
debug_assert_eq!(frame.get(), stack_id);
params.meta.synthetic = is_synthetic;
// A previous turn may have died with a call still in flight. Repair it
// before appending anything: the model must never be shown a call with
// no result, and the resumed result belongs to the OLD turn, so it has
// to land before the new message. This does not re-drive that turn —
// the user has moved on.
let repaired = self.recovery().repair(&conv, &params).await?;
if repaired != agent_loop::recovery::RecoveryReport::default() {
info!(session_id = self.session_id, ?repaired, "repaired an interrupted turn");
}
let msg = NewMessage {
role: agent_loop::store::Role::User,
content: user_content.to_string(),
role: Role::User,
content: user_content.to_string(),
synthetic: is_synthetic,
reasoning: None,
metadata: metadata.and_then(|m| serde_json::to_value(m).ok()),
};
let params = TurnParams {
frame,
agent: config.agent_id.clone(),
system,
tools,
model_hint: ModelHint::name(config.client_name.clone()),
live_input,
extensions,
meta: TurnMeta {
synthetic: is_synthetic,
interactive: self.is_interactive,
..TurnMeta::default()
},
assembler: Some(assembler),
metadata: metadata.and_then(|m| serde_json::to_value(m).ok()),
};
// Register for /stop, then drive.
*self.kernel_live.lock().unwrap() = Some((manager.clone(), conv.clone()));
let handle = manager.start_turn(conv.clone(), msg, params).await
.map_err(|e| anyhow::anyhow!("kernel turn failed to start: {e}"))?;
let outcome = handle.join().await;
*self.kernel_live.lock().unwrap() = None;
let outcome = rt
.manager()
.start_turn(conv, msg, params)
.await
.map_err(|e| anyhow::anyhow!("kernel turn failed to start: {e}"))?
.join()
.await;
// Let the translator drain what the kernel emitted, then stop it.
stop.cancel();
let _ = translator_task.await;
let shared_state = std::mem::take(&mut *shared.lock().unwrap());
match outcome? {
agent_loop::kernel::TurnOutcome::Final { content, message_id, usage, reasoning } => {
agent_loop::kernel::TurnOutcome::Final { content, message_id, usage, .. } => {
let tool_calls: Vec<ToolCallEvent> = shared_state.tool_calls;
info!(
session_id = self.session_id,
@@ -321,8 +134,6 @@ impl ChatSessionHandler {
message_id: message_id.get(),
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
truncated: usage.truncated,
reasoning_content: reasoning,
tool_calls,
})
}
@@ -331,46 +142,138 @@ impl ChatSessionHandler {
}
}
/// `/stop` for the kernel-driven turn: cancels the live loop (the legacy
/// `current_cancel` path keeps covering resume/recovery).
pub(super) fn cancel_kernel_turn(&self) {
let live = self.kernel_live.lock().unwrap().clone();
if let Some((manager, conv)) = live {
manager.cancel(&conv);
/// Continues a turn nobody is driving: a client reconnecting to a session
/// that was mid-tool when the process died, a background job's parent, or a
/// conversation woken by an async result.
///
/// No new user message — the history already says what to do. Sub-agent
/// frames cascade back to the root, each running as **its own** agent.
pub async fn recover_turn(
&self,
interface_tools: Vec<InterfaceTool>,
tx: mpsc::Sender<ServerEvent>,
) -> anyhow::Result<()> {
let _guard = self.processing.lock().await;
let report = self.drive_recovery(interface_tools, tx, None).await?;
info!(session_id = self.session_id, ?report, "recover_turn done");
Ok(())
}
/// Applies a human's decision to a call that has no loop waiting on it — an
/// approval card answered after a restart, or from the Inbox — then
/// continues the conversation.
///
/// Approval **skips the gate** (the human just decided) but not the
/// context: the tool runs with this session's `ToolContext`, so a write
/// lands in the caller's workspace and a command in their container, never
/// on the host (blueprint §6).
pub async fn resolve_pending_call(
&self,
call: i64,
decision: HumanDecision,
interface_tools: Vec<InterfaceTool>,
tx: mpsc::Sender<ServerEvent>,
) -> anyhow::Result<()> {
let _guard = self.processing.lock().await;
let report = self.drive_recovery(interface_tools, tx, Some((call, decision))).await?;
info!(session_id = self.session_id, call, ?report, "resolve_pending_call done");
Ok(())
}
/// The shared body of the two entry points above: build the root turn's
/// parameters, subscribe the translator, run recovery (optionally applying
/// a human decision first), drain the events.
async fn drive_recovery(
&self,
interface_tools: Vec<InterfaceTool>,
tx: mpsc::Sender<ServerEvent>,
decision: Option<(i64, HumanDecision)>,
) -> anyhow::Result<RecoveryReport> {
let rt = self.loop_runtime.clone();
let conv = UserLoopRuntime::conversation(self.session_id);
let mut config = self
.build_agent_config(None, None, None, interface_tools, HashMap::new())
.await?;
// The tail reminder belongs to a fresh user message, not to finishing
// work that was already under way.
config.tail_reminder = None;
let scope = Arc::new(self.turn_scope(&config).await);
let (translator, _shared) = EventTranslator::new(
tx.clone(),
conv.clone(),
self.tools.clone(),
self.mcp.clone(),
rt.store().clone(),
);
let stop = CancellationToken::new();
let translator_task = translator.spawn(rt.manager().events(), stop.clone());
let params = rt
.turn_params(TurnInputs { scope, config: &config, live_input: None })
.await?;
let result = match decision {
Some((call, decision)) => {
rt.manager()
.resolve_pending(
agent_loop::ids::ToolCallId(call),
decision,
rt.catalog().clone(),
&params,
)
.await
}
None => self.recovery().run(&conv, &params).await,
};
stop.cancel();
let _ = translator_task.await;
result
}
/// Recovery bound to this user's manager, with Skald's policy.
fn recovery(&self) -> agent_loop::recovery::Recovery {
let rt = &self.loop_runtime;
rt.manager().recovery(rt.catalog().clone(), policy())
}
/// The turn's scope: identity, the live cells the gate watches, and the tool
/// material a sub-agent derives its own set from.
async fn turn_scope(&self, config: &AgentRunConfig) -> TurnScope {
TurnScope {
session_id: self.session_id,
source: self.source.clone(),
is_interactive: self.is_interactive,
agent_id: config.agent_id.clone(),
scratchpad_sid: self.scratchpad_sid(),
project_root: self
.run_context
.read()
.await
.as_ref()
.and_then(|rc| rc.project_root.clone()),
context_label: self.context_label.clone(),
run_context: self.run_context.clone(),
group_id: self.tool_group_id().await,
pre_approved: self.pre_approved.clone(),
auto_deny: self.auto_deny_approvals.clone(),
grants: config.active_mcp_grants.clone(),
base_defs: Arc::new(config.base_tool_defs.clone()),
config_defs: Arc::new(config.config_tool_defs.clone()),
memory_tools: Arc::new(config.memory_tools.clone()),
image_tools: Arc::new(config.image_tools.clone()),
root_only: Arc::new(config.root_only_tool_names.clone()),
}
}
}
/// Finds an interface tool by name in the run config.
fn native_interface(config: &AgentRunConfig, name: &str) -> Option<InterfaceTool> {
config
.interface_tools
.iter()
.find(|it| it.definition["function"]["name"].as_str() == Some(name))
.cloned()
}
/// 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"]
}
}
})
/// `/stop` for the kernel-driven turn: the manager cancels the live loop of
/// this conversation (the legacy `current_cancel` path still covers
/// resume/recovery).
pub(super) fn cancel_kernel_turn(&self) {
self.loop_runtime
.manager()
.cancel(&UserLoopRuntime::conversation(self.session_id));
}
}
@@ -1,283 +0,0 @@
//! One LLM call per round, with automatic model fallback.
//!
//! Extracted from `run_agent_turn`: on a retriable error (5xx / network) it retries
//! up to `MAX_LLM_ATTEMPTS` models in priority order, rebuilding the message list
//! when the replacement model has a different `prompt_cache` setting, and emits
//! `ModelFallback` / `LlmFailed` along the way. The call itself goes through the
//! `agent_loop::model::Model` trait (blueprint D13) — clients and protocols live
//! in the `agent-loop` crate.
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{error, warn};
use agent_loop::ids::{ConversationId, FrameId};
use agent_loop::model::{ModelRequest, ModelResponse, StreamDelta};
use crate::db::llm_request_payloads;
use crate::events::{ServerEvent, TokenDeltaKind};
use crate::llm::{LlmEntry, LlmStrength};
use super::ChatSessionHandler;
use super::emitter::TurnEmitter;
use super::interface_tools::AgentRunConfig;
/// Outcome of one round's LLM call.
pub(super) enum RoundLlm {
/// The model responded (message or tool calls). Boxed: `ModelResponse`
/// dwarfs the other variants.
Turn(Box<ModelResponse>),
/// The turn was cancelled (`/stop`) while the request was in flight.
Cancelled,
/// All fallback attempts were exhausted, or an error is non-retriable.
Failed(anyhow::Error),
}
/// Maximum number of models tried in one round before giving up.
const MAX_LLM_ATTEMPTS: usize = 3;
impl ChatSessionHandler {
/// Calls the current model and, on a retriable failure, falls back to the next
/// model in priority order. Mutates `cur_name` / `cur_llm` / `messages` in place
/// so the caller keeps using the model that actually produced the turn.
#[allow(clippy::too_many_arguments)]
pub(super) async fn call_llm_round(
&self,
stack_id: i64,
config: &AgentRunConfig,
active_grants: &HashSet<String>,
req_strength: Option<LlmStrength>,
cur_name: &mut String,
cur_llm: &mut Arc<LlmEntry>,
messages: &mut Vec<Value>,
token: &CancellationToken,
em: &TurnEmitter<'_>,
) -> RoundLlm {
let mut tried_this_round: Vec<String> = vec![cur_name.clone()];
loop {
// Re-derive the tool defs for the model actually serving this attempt:
// a fallback across DTL modes must re-shape (deferred candidates or not).
let cur_tool_defs = config.all_tool_defs(cur_llm.dtl);
let request_id = uuid::Uuid::new_v4().to_string();
// Tell the model, in read_file's description, which media formats it can
// open directly — keyed on the model actually serving this attempt, so a
// fallback to a text-only model drops the claim. `None` (no media
// capability) leaves the shared defs untouched, avoiding a clone.
let annotated = media_annotated_tools(&cur_tool_defs, &cur_llm.capabilities);
let defs: &[Value] = annotated.as_deref().unwrap_or(&cur_tool_defs);
// Clone the Arc so the in-flight future does not borrow `cur_llm` across
// the fallback reassignment below. On cancel we drop the future
// (aborting the request) and return immediately.
let client = cur_llm.client.clone();
let request = ModelRequest {
messages: messages.clone(),
tools: defs.to_vec(),
model: cur_llm.model.clone(),
max_tokens: None,
temperature: None,
request_id: request_id.clone(),
conversation: ConversationId::new(format!("session:{}", self.session_id)),
frame: FrameId(stack_id),
extras: Value::Null,
// Correlation for the LoggingModel decorator (never sent).
log: Some(json!({
"session_id": self.session_id,
"stack_id": stack_id,
"user_id": self.user_id,
})),
};
// Streaming side-channel: providers that support SSE push deltas here;
// the forwarder re-emits them as `TokenDelta` events on the turn bus.
// Best-effort — the round's final events remain authoritative.
let (delta_tx, delta_rx) = mpsc::channel::<StreamDelta>(256);
let forwarder = spawn_delta_forwarder(delta_rx, em.sender());
let call_result = tokio::select! {
_ = token.cancelled() => return RoundLlm::Cancelled,
r = client.complete(&request, Some(delta_tx)) => r,
};
// The client's sender dropped with the completed future: the forwarder
// drains any queued deltas and exits, so every `TokenDelta` precedes the
// round's outcome events (Thinking / Done) in bus order.
forwarder.await.ok();
let e = match call_result {
Ok(resp) => {
self.llm_manager.mark_success(cur_name).await;
// Persist the payload (request/response bodies + headers) to the
// user's own database. Fire-and-forget — a failed write must not
// break the turn. The metadata row is already written by the
// LoggingModel decorator to system.db with the same request_id.
if let Some(meta) = resp.raw() {
let pool = Arc::clone(&self.db);
let rid = request_id.clone();
let row = llm_request_payloads::PayloadRow {
request_id: rid,
request_json: meta.request_body.as_ref().map(|v| v.to_string()).unwrap_or_default(),
request_headers: meta.request_headers.as_ref().map(|v| v.to_string()),
response_json: meta.response_body.as_ref().map(|v| v.to_string()),
response_headers: meta.response_headers.as_ref().map(|v| v.to_string()),
};
tokio::spawn(async move {
if let Err(e) = llm_request_payloads::insert(&pool, row).await {
tracing::warn!(error = %e, "llm_request_payloads: failed to insert");
}
});
}
return RoundLlm::Turn(Box::new(resp));
}
Err(e) => e,
};
// Persist the payload even on failure so the debug log shows the request
// that was rejected (e.g. a provider 400). Only HTTP failures attach a
// body (`ModelError::raw`); a network/parse/cancel error carries none.
// Fire-and-forget, keyed on the same `request_id` as the metadata row the
// LoggingModel decorator wrote to system.db.
if let Some(meta) = e.raw.as_ref() {
let row = llm_request_payloads::PayloadRow {
request_id: request_id.clone(),
request_json: meta.request_body.as_ref().map(|v| v.to_string()).unwrap_or_default(),
request_headers: meta.request_headers.as_ref().map(|v| v.to_string()),
response_json: meta.response_body.as_ref().map(|v| v.to_string()),
response_headers: meta.response_headers.as_ref().map(|v| v.to_string()),
};
let pool = Arc::clone(&self.db);
tokio::spawn(async move {
if let Err(e) = llm_request_payloads::insert(&pool, row).await {
tracing::warn!(error = %e, "llm_request_payloads: failed to insert error payload");
}
});
}
error!(session_id = self.session_id, client = %cur_name, error = %e, "LLM call failed");
self.llm_manager.mark_failure(cur_name, &e.to_string()).await;
let can_fallback = tried_this_round.len() < MAX_LLM_ATTEMPTS
&& client.is_retriable(&e);
if !can_fallback {
em.llm_failed(tried_this_round.clone(), e.to_string()).await;
return RoundLlm::Failed(e.into());
}
let excluded: Vec<&str> = tried_this_round.iter().map(String::as_str).collect();
match self.llm_manager.select_excluding(&excluded, req_strength).await {
Ok((next_name, next_llm)) => {
warn!(session_id = self.session_id, from = %cur_name, to = %next_name, "LLM fallback");
em.model_fallback(cur_name.clone(), next_name.clone(), first_line(&e.to_string())).await;
tried_this_round.push(next_name.clone());
*cur_name = next_name;
*cur_llm = next_llm;
// Rebuild messages if the new model uses different prompt_cache
// settings (e.g. switching from OpenRouter/Anthropic to DeepSeek)
// or different input capabilities (a non-vision fallback drops
// inline media back to the textual path block).
let activation_stack = if config.depth == 0 { None } else { Some(stack_id) };
match self.build_openai_messages(
&self.db, stack_id, &config.agent_id,
config.extra_system.as_deref(), config.extra_system_dynamic.as_deref(),
config.tail_reminder.as_deref(), active_grants,
&config.system_substitutions, cur_llm.prompt_cache, &cur_llm.capabilities,
cur_llm.dtl, &config.config_tool_defs, activation_stack,
).await {
Ok(m) => *messages = m,
Err(e) => return RoundLlm::Failed(e),
}
}
Err(_) => {
em.llm_failed(tried_this_round.clone(), e.to_string()).await;
return RoundLlm::Failed(e.into());
}
}
}
}
}
/// Forwards streaming deltas from the LLM client onto the turn's event channel
/// as `TokenDelta` events. Exits when the client drops its sender (call
/// completed or aborted) or when the turn receiver is gone.
fn spawn_delta_forwarder(
mut rx: mpsc::Receiver<StreamDelta>,
tx: mpsc::Sender<ServerEvent>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(d) = rx.recv().await {
let (kind, delta) = match d {
StreamDelta::Text(t) => (TokenDeltaKind::Content, t),
StreamDelta::Reasoning(t) => (TokenDeltaKind::Reasoning, t),
};
if tx.send(ServerEvent::TokenDelta { kind, delta }).await.is_err() {
break;
}
}
})
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or(s).to_string()
}
/// Appends a per-model media hint to `read_file`'s description when the resolved
/// model can view images/video/PDFs, so the model knows reading one of those shows
/// it the content natively. Returns `None` (leaving the shared, model-independent
/// defs untouched — no clone) when the model has no media modality. Done here, per
/// attempt, so a fallback to a different model re-derives the hint from its caps.
fn media_annotated_tools(tool_defs: &[Value], capabilities: &[String]) -> Option<Vec<Value>> {
let hint = super::media::media_capability_hint(capabilities)?;
let mut out = tool_defs.to_vec();
for def in &mut out {
if def["function"]["name"].as_str() == Some("read_file") {
if let Some(d) = def["function"]["description"].as_str() {
def["function"]["description"] = Value::String(format!("{d}{hint}"));
}
break;
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use agent_loop::model::{Model, ModelError, ModelRequest, ModelResponse, StreamDelta};
use async_trait::async_trait;
use tokio::sync::mpsc;
struct Dummy;
#[async_trait]
impl agent_loop::model::Model for Dummy {
async fn complete(
&self,
_req: &ModelRequest,
_d: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
unreachable!()
}
}
/// Retriability classification lives on the `Model` trait default (the crate
/// owns the protocols, blueprint D13): 401/403/404/422 don't retry,
/// 400/429/5xx/network do. Classification keys on the structured status,
/// never on the message string (bug B6 regression).
#[test]
fn retriability_keys_on_structured_status() {
let m = Dummy;
for code in [401, 403, 404, 422] {
assert!(!m.is_retriable(&ModelError::new(Some(code), "nope")), "{code} must not retry");
}
for code in [400, 429, 500, 502, 503] {
assert!(m.is_retriable(&ModelError::new(Some(code), "retry")), "{code} must retry");
}
// A 500 whose body mentions "1401 tokens" / "code 404" must still retry.
assert!(m.is_retriable(&ModelError::new(
Some(500),
"provider error: too many (1401) tokens, see code 404 in docs"
)));
assert!(m.is_retriable(&ModelError::new(None, "connection reset by peer")));
}
}
@@ -1,472 +0,0 @@
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, trace};
use crate::chat_event_bus::ToolCallEvent;
use agent_loop::model::{ModelResponse, ToolCall};
use crate::db::{chat_history, chat_llm_tools};
use crate::events::ServerEvent;
use crate::tools::{
ExecutionOutcome, SimpleExecution, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
};
use futures::stream::{self, StreamExt};
use super::{ChatSessionHandler, PendingUserInput, TurnOutcome};
use super::dispatch::{is_sync_sub_agent, DispatchResult};
use super::emitter::TurnEmitter;
use super::gate::GateOutcome;
use super::llm_call::RoundLlm;
use super::outcome::RecordFlow;
use super::interface_tools::AgentRunConfig;
/// Whether, after handling one tool call, the round loop should continue to the
/// next call or the whole turn should end.
enum CallFlow {
Continue,
End(TurnOutcome),
}
/// Outcome of gating + dispatching one call inside a concurrent sub-agent batch,
/// carried from the concurrent phase to the ordered recording phase.
enum GatedExec {
/// Gate passed; the sub-agent produced an outcome to record. `arguments` is
/// the call's args (used for FileChanged / logging).
Done { arguments: serde_json::Value, outcome: ExecutionOutcome },
/// Approval gate rejected the call — already marked/emitted by the gate; skip it.
Rejected,
/// The turn must end now: the clarification WS channel closed (dispatch returned
/// `AbortPending`) or the approval gate's channel closed.
AbortTurn,
}
impl ChatSessionHandler {
/// Inner loop of an agent (root or sub). Persists messages to `stack_id`,
/// emits Thinking/ToolStart/ToolDone/PendingWrite/ApprovalRequired/AgentStart/AgentDone events.
/// Returns the outcome; the caller decides what to emit on completion
/// (Done for root, AgentDone+tool-result for sub-agents).
pub(super) fn run_agent_turn<'a>(
&'a self,
stack_id: i64,
config: &'a AgentRunConfig,
token: &'a CancellationToken,
tx: &'a mpsc::Sender<ServerEvent>,
// Queued user input for live injection (root interactive turn only).
// `None` for sub-agents / resume / non-interactive runners.
pending_input: Option<&'a Arc<dyn PendingUserInput>>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<TurnOutcome>> + Send + 'a>> {
Box::pin(async move {
let pool = &self.db;
let em = TurnEmitter::new(tx);
// Resolve the initial model. `cur_name`/`cur_llm` are updated in-place
// when the fallback logic switches to a different model mid-turn.
let mut cur_name = config.client_name.clone();
let mut cur_llm = self.llm_manager.get(&cur_name).await
.ok_or_else(|| anyhow::anyhow!("LLM client '{}' not found", cur_name))?;
// Strength needed for fallback re-selection.
let meta = crate::agents::load_meta(&config.agent_id).ok();
let req_strength = meta.as_ref().and_then(|m| m.strength);
// Accumulates tool calls across all rounds for the event bus.
let mut all_tool_calls: Vec<ToolCallEvent> = Vec::new();
for round in 0..self.max_tool_rounds {
if token.is_cancelled() {
return Ok(TurnOutcome::Cancelled);
}
// ── Live user-message injection ─────────────────────────────────────
// A round boundary is the one clean ordering point: the previous
// round's assistant message + tool results are all persisted, so a
// `user` row appended here is well-ordered. Each queued message is
// saved individually and echoed (telnet-style: the bubble appears only
// now), then picked up by `build_openai_messages` below in this same
// round — so the model sees it immediately. The MessageBuilder merges
// consecutive user rows into one `role:user` for the LLM. Does not
// reset the round budget. Only ever `Some` for the root interactive turn.
if let Some(input) = pending_input {
for msg in input.drain_user().await {
let attachments = msg.metadata.as_ref()
.map(|m| m.attachments.clone())
.unwrap_or_default();
// A custom slash command persists its expanded template (for LLM
// replay) but the bubble must show the typed command — emit the
// command's `display` form when present.
let echo = msg.metadata.as_ref()
.and_then(|m| m.command.as_ref())
.map(|c| c.display.clone())
.unwrap_or_else(|| msg.content.clone());
let id = chat_history::append_with_metadata(
pool, stack_id, &chat_history::Role::User,
&msg.content, false, None, msg.metadata.as_ref(),
).await?;
em.user_message(id, echo, attachments).await;
}
}
trace!(session_id = self.session_id, stack_id, agent_id = config.agent_id, round, "starting round");
let active_grants_snapshot = config.active_mcp_grants
.read()
.map(|g| g.clone())
.unwrap_or_default();
// Messages are (re)built with the current model's prompt_cache flag.
// On fallback within the same round `call_llm_round` rebuilds them again
// if the replacement model has a different prompt_cache setting.
// Activation scope for the DTL serializer: session-scoped for the root
// agent (stack_id NULL), the frame itself for a sub-agent.
let activation_stack = if config.depth == 0 { None } else { Some(stack_id) };
let mut messages = self.build_openai_messages(pool, stack_id, &config.agent_id, config.extra_system.as_deref(), config.extra_system_dynamic.as_deref(), config.tail_reminder.as_deref(), &active_grants_snapshot, &config.system_substitutions, cur_llm.prompt_cache, &cur_llm.capabilities, cur_llm.dtl, &config.config_tool_defs, activation_stack).await?;
let tool_defs = config.all_tool_defs(cur_llm.dtl);
// Record every tool actually offered to the LLM so the Security-groups
// UI can list/gate dynamically-injected tools. Cheap no-op once each
// name is known; new names are persisted off the turn's critical path.
self.tool_discovery.observe(&tool_defs);
// One LLM call for this round, with automatic model fallback on
// retriable errors. `cur_name`/`cur_llm`/`messages` are updated in place.
let turn_result = match self.call_llm_round(
stack_id, config, &active_grants_snapshot,
req_strength,
&mut cur_name, &mut cur_llm, &mut messages, token, &em,
).await {
RoundLlm::Turn(t) => t,
RoundLlm::Cancelled => return Ok(TurnOutcome::Cancelled),
RoundLlm::Failed(e) => return Err(e),
};
match *turn_result {
ModelResponse::Message { content, reasoning, usage, .. } => {
let message_id = chat_history::append(
pool, stack_id, &chat_history::Role::Assistant, &content, false,
reasoning.as_deref(),
).await?;
if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
chat_history::set_usage(pool, message_id, i, o, 0, usage.cost_usd).await?;
}
return Ok(TurnOutcome::Final {
content,
message_id,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
truncated: usage.truncated,
reasoning_content: reasoning,
tool_calls: all_tool_calls,
});
}
ModelResponse::ToolCalls { content: assistant_text, calls, usage, reasoning, .. } => {
let (input_tokens, output_tokens) = (usage.input_tokens, usage.output_tokens);
let message_id = chat_history::append(
pool, stack_id, &chat_history::Role::Assistant, &assistant_text, false,
reasoning.as_deref(),
).await?;
if let (Some(i), Some(o)) = (input_tokens, output_tokens) {
chat_history::set_usage(pool, message_id, i, o, 0, usage.cost_usd).await?;
}
if !assistant_text.trim().is_empty() || input_tokens.is_some() {
em.thinking(message_id, assistant_text, input_tokens, output_tokens, reasoning).await;
}
// A homogeneous batch of ≥2 synchronous sub-agent calls is fanned
// out concurrently (bounded by `max_parallel_subagents`). Any other
// shape — a single call, or a mix with regular tools — keeps the
// strictly sequential path, so tool ordering and side-effects are
// unchanged for everything except this well-defined case.
if calls.len() >= 2 && calls.iter().all(|c| is_sync_sub_agent(&c.name, &c.arguments)) {
match self.handle_sub_agent_batch(
stack_id, config, message_id, &calls, token, tx, &em, &mut all_tool_calls,
).await? {
CallFlow::Continue => {}
CallFlow::End(outcome) => return Ok(outcome),
}
} else {
for call in &calls {
// Stop before each call so a /stop (or a cancelled sub-agent,
// which shares this token) aborts the rest of the round.
if token.is_cancelled() {
return Ok(TurnOutcome::Cancelled);
}
match self.handle_tool_call(
stack_id, config, message_id, call, token, tx, &em, &mut all_tool_calls,
).await? {
CallFlow::Continue => {}
CallFlow::End(outcome) => return Ok(outcome),
}
}
}
}
}
}
Ok(TurnOutcome::Exhausted)
}) // end Box::pin
}
/// Handles a single tool call within a round: persists the call row, emits
/// `ToolStart`, resolves the working directory, runs the approval gate, handles
/// `restart`, dispatches, and records the outcome. Returns [`CallFlow::Continue`]
/// Card metadata (friendly display name + semantic icon key) for a tool call.
/// Delegates to the registry seam [`ToolRegistry::display_meta`], then layers the
/// MCP display-name override on for an `mcp__server__tool` name (manifest title >
/// live MCP `title` > the prettified name the seam already produced). The single
/// place the live loop resolves a card title, mirroring `describe_call`.
pub(super) fn tool_ui_meta(&self, name: &str, args: &serde_json::Value) -> (String, String) {
let mut meta = self.tools.display_meta(name, args);
if let Some((server, tool)) = crate::mcp::parse_mcp_tool_name(name) {
if let Some(friendly) = self.mcp.tool_display_name(server, tool) {
meta.display_name = friendly;
}
}
(meta.display_name, meta.icon)
}
/// to move on to the next call, or [`CallFlow::End`] to end the whole turn.
#[allow(clippy::too_many_arguments)]
async fn handle_tool_call(
&self,
stack_id: i64,
config: &AgentRunConfig,
message_id: i64,
call: &ToolCall,
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
em: &TurnEmitter<'_>,
all_tool_calls: &mut Vec<ToolCallEvent>,
) -> anyhow::Result<CallFlow> {
let pool = &self.db;
let args_str = serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "{}".to_string());
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?;
let (display_name, icon) = self.tool_ui_meta(&call.name, &call.arguments);
em.tool_start(
tool_call_id, message_id,
call.name.clone(),
call.arguments.clone(),
display_name, icon,
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Short),
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
self.tools.target_path(&call.name, &call.arguments),
).await;
// Tool calls receive their arguments unchanged — the session working
// directory is always the user's home (`~`), and the agent references
// project files via their absolute agent path. `call.arguments` is both
// logged and executed.
match self.run_approval_gate(tool_call_id, &call.name, &call.arguments, &config.agent_id, em).await? {
GateOutcome::Proceed => {}
GateOutcome::Rejected => return Ok(CallFlow::Continue),
GateOutcome::ChannelClosed => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
}
debug!(session_id = self.session_id, tool = %call.name, tool_call_id, "dispatching");
// Route the approved call to its executor. `AbortPending` means the
// clarification WS channel closed — end the turn and leave the tool
// `pending` for resume to re-ask.
let (outcome, preview) = match self.execute_tool_call(
stack_id, config, tool_call_id, &call.name, &call.arguments, token, tx,
).await {
DispatchResult::Outcome { outcome, preview } => (outcome, preview),
DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
};
// Persist the durable effect of `activate_tools`, anchored at the assistant
// `message_id` that triggered it (the anchor the DTL serializer positions
// injected tool blocks against). The in-memory grant set was already updated
// inside the tool; this records it across turns and restarts.
if call.name == crate::tools::tool_names::ACTIVATE_TOOLS {
if let Some(groups) = call.arguments.get("groups").and_then(|g| g.as_array()) {
// Root (depth 0) → session-scoped (stack_id NULL); sub-agent → its frame.
let anchor_stack = if config.depth == 0 { None } else { Some(stack_id) };
for g in groups.iter().filter_map(|v| v.as_str()) {
let kind = if g == crate::tools::tool_names::CONFIG_GROUP { "builtin" } else { "mcp" };
if let Err(e) = crate::db::activated_tools::grant(
pool, self.session_id, anchor_stack, message_id, kind, g,
).await {
tracing::warn!(session_id = self.session_id, group = g, error = %e, "activate_tools: failed to persist activation");
}
}
}
}
match self.record_tool_outcome(
tool_call_id, &call.name, &call.arguments, outcome, preview, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => Ok(CallFlow::Continue),
RecordFlow::Abort => Ok(CallFlow::End(TurnOutcome::Cancelled)),
}
}
/// Concurrent variant of the tool-call loop for a homogeneous batch of
/// synchronous sub-agent calls (`execute_task` mode=sync / `execute_subtask`).
/// Only called when every call in the round is such a sub-agent (see the
/// dispatch in `run_agent_turn`), so `restart` and side-effecting tools can
/// never appear here and the sequential path is left byte-for-byte intact.
///
/// Ordering invariant: the LLM reconstructs tool results by autoincrement id
/// (`chat_llm_tools ORDER BY id ASC`). **Phase 1** therefore allocates every
/// call's row in `calls` order *before* any concurrent work, so completion
/// order is irrelevant. **Phase 2** runs the approval gate + dispatch for all
/// calls concurrently, bounded by `max_parallel_subagents`. **Phase 3** records
/// the outcomes back in `calls` order, so `all_tool_calls` ordering and the
/// shared-token cancellation semantics match the sequential path.
#[allow(clippy::too_many_arguments)]
async fn handle_sub_agent_batch(
&self,
stack_id: i64,
config: &AgentRunConfig,
message_id: i64,
calls: &[ToolCall],
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
em: &TurnEmitter<'_>,
all_tool_calls: &mut Vec<ToolCallEvent>,
) -> anyhow::Result<CallFlow> {
let pool = &self.db;
// ── Phase 1: allocate tool_call_id rows in `calls` order ────────────────────
// The id fixes the LLM-visible order regardless of which sub-agent finishes
// first, so this pre-pass MUST stay sequential and precede the fan-out.
let mut started: Vec<(&ToolCall, i64)> = Vec::with_capacity(calls.len());
for call in calls {
let args_str = serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "{}".to_string());
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?;
let (display_name, icon) = self.tool_ui_meta(&call.name, &call.arguments);
em.tool_start(
tool_call_id, message_id,
call.name.clone(),
call.arguments.clone(),
display_name, icon,
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Short),
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
self.tools.target_path(&call.name, &call.arguments),
).await;
started.push((call, tool_call_id));
}
// ── Phase 2: gate + dispatch concurrently, bounded ──────────────────────────
// Every future borrows `&self`/`config`/`token`/`tx`/`em` (all shared refs)
// and writes only to its own distinct child stack + tool_call_id, so there is
// no shared mutable state between siblings. Results are keyed back by index.
let limit = self.max_parallel_subagents.max(1);
let mut results: Vec<Option<GatedExec>> = (0..started.len()).map(|_| None).collect();
// Feed the stream fully-owned items `(idx, tool_call_id, name, arguments)`.
// Passing a borrowed `&ToolCall` as the closure input makes the returned async
// block's lifetime higher-ranked ("FnOnce is not general enough"); owning the
// per-call data means each future only borrows `self`/`config`/`token`/`tx`/`em`
// from the enclosing scope, all at the single concrete turn lifetime.
let jobs: Vec<(usize, i64, String, serde_json::Value)> = started.iter().enumerate()
.map(|(idx, (call, id))| (idx, *id, call.name.clone(), call.arguments.clone()))
.collect();
{
let mut stream = stream::iter(jobs)
.map(|(idx, tool_call_id, name, arguments)| async move {
let gated = match self.run_approval_gate(
tool_call_id, &name, &arguments, &config.agent_id, em,
).await {
Ok(GateOutcome::Proceed) => match self.execute_tool_call(
stack_id, config, tool_call_id, &name, &arguments, token, tx,
).await {
// Sub-agent batches never carry a file-write preview.
DispatchResult::Outcome { outcome, .. } => Ok(GatedExec::Done { arguments, outcome }),
DispatchResult::AbortPending => Ok(GatedExec::AbortTurn),
},
Ok(GateOutcome::Rejected) => Ok(GatedExec::Rejected),
Ok(GateOutcome::ChannelClosed) => Ok(GatedExec::AbortTurn),
Err(e) => Err(e),
};
(idx, gated)
})
.buffer_unordered(limit);
while let Some((idx, gated)) = stream.next().await {
results[idx] = Some(gated?);
}
}
// ── Phase 3: record outcomes in `calls` order ───────────────────────────────
let mut abort = false;
for (idx, (call, tool_call_id)) in started.iter().enumerate() {
match results[idx].take().expect("every started sub-agent call produced a result") {
// The gate already marked the row rejected and emitted the event.
GatedExec::Rejected => {}
GatedExec::AbortTurn => abort = true,
GatedExec::Done { arguments, outcome } => {
match self.record_tool_outcome(
*tool_call_id, &call.name, &arguments, outcome, None, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => abort = true,
}
}
}
}
// The shared token means a /stop (or a cancelled sibling) has already stopped
// the others; ending the turn here mirrors the sequential path's early return.
if abort || token.is_cancelled() {
Ok(CallFlow::End(TurnOutcome::Cancelled))
} else {
Ok(CallFlow::Continue)
}
}
/// Builds a [`ToolExecution`] for a single tool call, covering every tool that
/// flows through the unified (cancellable) dispatch path: interface tools,
/// memory/image tools, MCP tools, and the built-in registry (incl.
/// `execute_cmd`). Returns `None` only for an unknown tool name. The handle
/// borrows `self` and `config`, both of which outlive the turn.
pub(super) fn build_execution<'a>(
&'a self,
name: &str,
args: serde_json::Value,
config: &'a AgentRunConfig,
) -> Option<Box<dyn ToolExecution + 'a>> {
// Interface tools (closures injected per-interface, e.g. activate_tools).
if let Some(tool) = config.interface_tools.iter().find(|t| t.name() == name) {
let handler = std::sync::Arc::clone(&tool.handler);
return Some(Box::new(SimpleExecution::new(
Box::pin(async move { handler(args).await.map(ToolResult::Text) }),
)));
}
// The ToolContext carries this session's id, owner user id and owner pool
// so owner-bound tools (cron management, the Honcho memory peer) act on the
// caller's own data. Built once and shared by memory tools and the registry.
let ctx = ToolContext {
session_id: self.session_id,
user_id: self.user_id.clone(),
pool: Arc::clone(&self.db),
// Snapshot the fs cell for the duration of this tool call — a concurrent
// shared-folder remount swaps the cell, the next call picks it up (§6).
fs: self.fs.load(),
};
// Memory + image tools (registered ad-hoc on the config). Memory tools route
// through `run_with` so the Honcho tools reach the caller's own peer.
if let Some(tool) = config.memory_tools.iter().find(|t| t.name() == name) {
return Some(tool.run_with(&ctx, args));
}
if let Some(tool) = config.image_tools.iter().find(|t| t.name() == name) {
return Some(tool.run(args));
}
// MCP tools (`server::tool`). Clone the Arc so the work future is 'static.
if let Some((srv, mcp_tool)) = crate::mcp::parse_mcp_tool_name(name) {
let mcp = std::sync::Arc::clone(&self.mcp);
let srv = srv.to_string();
let mcp_tool = mcp_tool.to_string();
let fut: std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<ToolResult>> + Send>> =
Box::pin(async move { mcp.call(&srv, &mcp_tool, args).await });
return Some(Box::new(SimpleExecution::new(fut)));
}
// Built-in registry tools (incl. execute_cmd, whose SimpleExecution kills
// the child via kill_on_drop when the work future is dropped on /stop).
self.tools.run(name, &ctx, args)
}
}
+38 -495
View File
@@ -1,282 +1,28 @@
//! Inline multimodal media for chat attachments.
//! Media helpers that are Skald's, not the protocol's.
//!
//! Attachments normally reach the model as a textual list of paths (see
//! `attachments_block`) and the agent decides whether to read them. When the
//! resolved model declares a matching capability (`vision`, `video`), media
//! attachments of the **current turn** are instead sent as native content
//! parts — `image_url` / `video_url` data URLs, the OpenAI wire shape, which
//! non-OpenAI clients translate — so the model actually sees the bytes.
//! The wire half — which modality a model can take, the content-part shapes,
//! the data-URL encoding, the byte budgets, the magic-byte sniffing — lives in
//! `agent_loop::projection::media`. What is left here is the app's own:
//!
//! Promotion is deliberately strict: an attachment is inlined only when ALL of
//! these hold —
//! - the model has the modality's capability;
//! - the file lives under the caller's `~/uploads/` (where the upload handler
//! saves it), resolved through their per-user filesystem — attachments stored
//! anywhere else stay textual;
//! - the sniffed magic bytes match an allowed MIME — the client-supplied
//! `mimetype` is never trusted;
//! - the per-file and per-turn byte/count budgets are not exhausted.
//! - [`probe_media`] / [`media_capability_hint`]: what `read_file` tells the
//! agent it can hand back as native model input.
//!
//! Anything failing a check silently stays on the textual path.
//! Everything that decides WHICH files may be inlined is
//! `loop_adapters::media_source::SkaldMediaSource` (§6 containment), and the
//! projection itself is the library's — neither lives here.
use std::path::{Path, PathBuf};
use std::path::Path;
use base64::Engine as _;
use serde_json::{json, Value};
use tracing::debug;
use agent_loop::projection::media::MediaKind;
use core_api::message_meta::Attachment;
use core_api::tool::MediaRef;
use core_api::user_fs::{UserFs, UPLOADS_SUBDIR};
pub use agent_loop::projection::media::sniff_mime;
/// Max media parts inlined per turn.
const MAX_MEDIA_PER_TURN: usize = 4;
/// Max bytes for one inlined image.
const MAX_IMAGE_BYTES: u64 = 10 * 1024 * 1024;
/// Max bytes for one inlined video.
const MAX_VIDEO_BYTES: u64 = 32 * 1024 * 1024;
/// Max bytes for one inlined PDF (Anthropic's per-request document ceiling).
const MAX_PDF_BYTES: u64 = 32 * 1024 * 1024;
/// Max combined media bytes inlined per turn.
const MAX_TOTAL_MEDIA_BYTES: u64 = 48 * 1024 * 1024;
/// A model-input modality: the capability that unlocks it, the content-part
/// type it maps to, its byte cap, the sniffed MIME types accepted, and a
/// human-readable format list for the `read_file` description.
struct Modality {
capability: &'static str,
part_type: &'static str,
max_bytes: u64,
mimes: &'static [&'static str],
formats: &'static str,
}
const MODALITIES: &[Modality] = &[
Modality {
capability: "vision",
part_type: "image_url",
max_bytes: MAX_IMAGE_BYTES,
mimes: &["image/png", "image/jpeg", "image/gif", "image/webp"],
formats: "images (PNG, JPEG, GIF, WebP)",
},
Modality {
capability: "video",
part_type: "video_url",
max_bytes: MAX_VIDEO_BYTES,
mimes: &[
"video/mp4",
"video/mpeg",
"video/quicktime",
"video/webm",
"video/x-msvideo",
"video/x-flv",
"video/3gpp",
],
formats: "video (MP4, WebM, MOV, …)",
},
// PDF documents. The `file` part is the OpenAI file-input shape
// (`{"type":"file","file":{"filename","file_data"}}`), forwarded verbatim by
// OpenAI-compatible clients and translated to a native `document` block by the
// Anthropic client. Gated on the `document` capability, so a model row without
// it (any OpenAI-compat endpoint that can't take a `file` part) never receives
// one — set the capability only on rows whose endpoint accepts PDFs.
Modality {
capability: "document",
part_type: "file",
max_bytes: MAX_PDF_BYTES,
mimes: &["application/pdf"],
formats: "PDF documents",
},
];
/// Builds the OpenAI-wire content part for one inlined medium. Images/video use the
/// `{"type":"image_url"|"video_url","…":{"url":data-URL}}` shape; PDFs use the
/// `file` shape carrying a filename + `file_data` data-URL.
fn build_media_part(part_type: &str, mime: &str, b64: &str, filename: &str) -> Value {
let url = format!("data:{mime};base64,{b64}");
match part_type {
"file" => json!({ "type": "file", "file": { "filename": filename, "file_data": url } }),
t => json!({ "type": t, t: { "url": url } }),
}
}
/// The result of partitioning a message's attachments.
pub struct MediaPartition {
/// OpenAI-style content parts, ready to append after the text part.
pub parts: Vec<Value>,
/// Attachments that stay on the textual path block.
pub rest: Vec<Attachment>,
}
/// Splits a message's attachments into inline media parts and leftovers.
///
/// Each attachment path is resolved through the caller's per-user [`UserFs`] —
/// the same resolver the fs-tools use, fail-closed on traversal / workspace
/// escape — and inlined only when it lands under their `~/uploads/` directory,
/// where the upload handler saves them. Attachments stored anywhere else (a
/// path outside the home, or another surface's directory) stay textual.
pub async fn partition(
attachments: &[Attachment],
capabilities: &[String],
fs: &UserFs,
) -> MediaPartition {
let capable = MODALITIES
.iter()
.any(|m| capabilities.iter().any(|c| c == m.capability));
let root = std::fs::canonicalize(fs.home_host.join(UPLOADS_SUBDIR)).ok();
if !capable || root.is_none() {
return MediaPartition { parts: Vec::new(), rest: attachments.to_vec() };
}
let root = root.unwrap();
let mut parts: Vec<Value> = Vec::new();
let mut rest: Vec<Attachment> = Vec::new();
let mut total: u64 = 0;
for a in attachments {
if parts.len() >= MAX_MEDIA_PER_TURN {
debug!(path = %a.path, "media not inlined: per-turn count budget exhausted");
rest.push(a.clone());
continue;
}
match try_inline(a, capabilities, fs, &root, total).await {
Some((part, bytes)) => {
total += bytes;
parts.push(part);
}
None => rest.push(a.clone()),
}
}
MediaPartition { parts, rest }
}
/// Promotes one uploaded attachment to a content part, or `None` when any check
/// fails (logged at debug level; the caller keeps it on the textual path). The
/// agent path is resolved through the per-user filesystem (fail-closed) and then
/// re-checked to land under the uploads `root`; the rest is [`promote`].
async fn try_inline(
a: &Attachment,
capabilities: &[String],
fs: &UserFs,
root: &Path,
used_total: u64,
) -> Option<(Value, u64)> {
let abs = crate::tools::fs::resolve_host_path(fs, &a.path).ok()?;
if !abs.starts_with(root) {
debug!(path = %a.path, "media not inlined: outside the uploads root");
return None;
}
promote(&abs, &a.name, capabilities, used_total).await
}
/// Read + sniff + capability/budget check + build the content part for one file at
/// an **already-contained** absolute path. Shared by the uploaded-attachment path
/// ([`try_inline`]) and the tool-produced-media path ([`inline_paths`]); neither
/// containment nor per-turn count budget is enforced here — the callers do that.
/// `None` (logged at debug) when the file is not a recognized medium, the model
/// lacks the modality, or a byte budget is exhausted.
async fn promote(
abs: &Path,
filename: &str,
capabilities: &[String],
used_total: u64,
) -> Option<(Value, u64)> {
let mut file = tokio::fs::File::open(abs).await.ok()?;
let mut head = [0u8; 16];
let n = tokio::io::AsyncReadExt::read(&mut file, &mut head).await.ok()?;
let mime = sniff_mime(&head[..n])?;
let modality = MODALITIES.iter().find(|m| m.mimes.contains(&mime))?;
if !capabilities.iter().any(|c| c == modality.capability) {
debug!(path = %abs.display(), mime, "media not inlined: model lacks the capability");
return None;
}
let size = file.metadata().await.ok()?.len();
if size > modality.max_bytes {
debug!(path = %abs.display(), size, "media not inlined: file too large");
return None;
}
if used_total + size > MAX_TOTAL_MEDIA_BYTES {
debug!(path = %abs.display(), "media not inlined: per-turn byte budget exhausted");
return None;
}
let bytes = tokio::fs::read(abs).await.ok()?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Some((build_media_part(modality.part_type, mime, &b64, filename), size))
}
/// Inline media a tool produced (e.g. `read_file` on an image) as content parts,
/// for the current turn only. Mirrors [`partition`] but contains against the
/// caller's **workspace roots** (home + shared + projects + docs) rather than the
/// uploads dir — the tool already resolved + contained the path, so this is a
/// fail-closed re-check against a symlink swap since the read (§6). Same per-file,
/// per-count and per-turn byte budgets; the capability gate lives here, so a
/// tool always records the media and the model only sees it when able.
pub async fn inline_paths(
refs: &[MediaRef],
capabilities: &[String],
fs: &UserFs,
) -> Vec<Value> {
let capable = MODALITIES
.iter()
.any(|m| capabilities.iter().any(|c| c == m.capability));
if !capable || refs.is_empty() {
return Vec::new();
}
let roots = workspace_roots(fs);
if roots.is_empty() {
return Vec::new();
}
let mut parts: Vec<Value> = Vec::new();
let mut total: u64 = 0;
for r in refs {
if parts.len() >= MAX_MEDIA_PER_TURN {
break;
}
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");
continue;
}
let filename = canon
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
if let Some((part, bytes)) = promote(&canon, &filename, capabilities, total).await {
total += bytes;
parts.push(part);
}
}
parts
}
/// The caller's workspace roots, canonicalized for prefix-checking: 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
}
/// Sentence appended to `read_file`'s description when the resolved model can view
/// media, naming the formats it takes as native input. `None` when the model has
/// no media modality (description stays unchanged). See `call_llm_round`.
/// Sentence appended to `read_file`'s description when the resolved model can
/// view media, naming the formats it takes as native input. `None` when the
/// model has no media modality (the description stays unchanged).
pub fn media_capability_hint(capabilities: &[String]) -> Option<String> {
let forms: Vec<&'static str> = MODALITIES
.iter()
.filter(|m| capabilities.iter().any(|c| c == m.capability))
.map(|m| m.formats)
.collect();
let forms: Vec<&'static str> =
MediaKind::enabled(capabilities).into_iter().map(|k| k.formats()).collect();
if forms.is_empty() {
return None;
}
@@ -296,10 +42,9 @@ fn join_human(items: &[&str]) -> String {
}
}
/// Opens a file and sniffs its first bytes, returning a recognized media MIME
/// (`image/*`, `video/*`, `application/pdf`) or `None` for an ordinary/unreadable
/// file. Used by `read_file` to decide whether to hand a file back as native media
/// rather than trying to read it as UTF-8 text.
/// Opens a file and sniffs its first bytes, returning a recognized media MIME or
/// `None` for an ordinary/unreadable file. Used by `read_file` to decide whether
/// to hand a file back as native media rather than reading it as UTF-8 text.
pub async fn probe_media(path: &Path) -> Option<&'static str> {
let mut file = tokio::fs::File::open(path).await.ok()?;
let mut head = [0u8; 16];
@@ -307,234 +52,14 @@ pub async fn probe_media(path: &Path) -> Option<&'static str> {
sniff_mime(&head[..n])
}
/// 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 stay on the textual path).
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::*;
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 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 partition_inlines_png_for_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 p = partition(&[att("uploads/1/a.png")], &caps(&["vision"]), &fs).await;
assert!(p.rest.is_empty());
assert_eq!(p.parts.len(), 1);
let url = p.parts[0]["image_url"]["url"].as_str().unwrap();
assert!(url.starts_with("data:image/png;base64,"));
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test]
async fn partition_gates_on_capability_and_containment() {
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 p = partition(&[att("uploads/1/a.png")], &caps(&[]), &fs).await;
assert_eq!(p.rest.len(), 1);
assert!(p.parts.is_empty());
// vision capability does not unlock video parts.
let p = partition(&[att("uploads/1/a.png")], &caps(&["video"]), &fs).await;
assert_eq!(p.rest.len(), 1);
// A real image in the home but outside the uploads dir is never inlined.
let p = partition(&[att("secret.png")], &caps(&["vision"]), &fs).await;
assert_eq!(p.rest.len(), 1);
assert!(p.parts.is_empty());
// Traversal out of the workspace is rejected fail-closed.
let p = partition(&[att("uploads/../../secret.png")], &caps(&["vision"]), &fs).await;
assert_eq!(p.rest.len(), 1);
assert!(p.parts.is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test]
async fn partition_enforces_count_budget() {
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();
let mut atts = Vec::new();
for i in 0..(MAX_MEDIA_PER_TURN + 2) {
tokio::fs::write(dir.join(format!("{i}.png")), png_bytes()).await.unwrap();
atts.push(att(&format!("uploads/1/{i}.png")));
}
let fs = fs_home(&home);
let p = partition(&atts, &caps(&["vision"]), &fs).await;
assert_eq!(p.parts.len(), MAX_MEDIA_PER_TURN);
assert_eq!(p.rest.len(), 2);
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
fn pdf_bytes() -> Vec<u8> {
let mut v = b"%PDF-1.7\n".to_vec();
v.extend_from_slice(&[0x00; 64]);
v
}
#[tokio::test]
async fn partition_inlines_pdf_as_file_part_for_document_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.pdf"), pdf_bytes()).await.unwrap();
let fs = fs_home(&home);
// A document-capable model inlines the PDF as the OpenAI `file` part shape.
let p = partition(&[att("uploads/1/a.pdf")], &caps(&["document"]), &fs).await;
assert!(p.rest.is_empty());
assert_eq!(p.parts.len(), 1);
assert_eq!(p.parts[0]["type"], "file");
assert_eq!(p.parts[0]["file"]["filename"], "a.pdf");
let fd = p.parts[0]["file"]["file_data"].as_str().unwrap();
assert!(fd.starts_with("data:application/pdf;base64,"), "{fd}");
// vision alone does not unlock PDFs.
let p = partition(&[att("uploads/1/a.pdf")], &caps(&["vision"]), &fs).await;
assert_eq!(p.rest.len(), 1);
assert!(p.parts.is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
/// A throwaway [`UserFs`] whose private home is `root/homes/u1`.
fn fs_home(home: &std::path::Path) -> UserFs {
UserFs::new(
"u1",
home.to_path_buf(),
"skald-u1",
PathBuf::from("/root"),
vec![],
vec![],
None,
)
}
#[tokio::test]
async fn inline_paths_contains_and_gates_on_capability() {
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() };
// capable + inside the home → one image part.
let parts = inline_paths(std::slice::from_ref(&inside), &caps(&["vision"]), &fs).await;
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,"));
// no capability → nothing inlined.
assert!(inline_paths(std::slice::from_ref(&inside), &caps(&[]), &fs).await.is_empty());
// a real image outside the workspace is rejected fail-closed.
assert!(inline_paths(std::slice::from_ref(&outside), &caps(&["vision"]), &fs).await.is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[test]
fn media_capability_hint_lists_enabled_formats_only() {
assert!(media_capability_hint(&caps(&[])).is_none());
@@ -544,4 +69,22 @@ mod tests {
let h = media_capability_hint(&caps(&["vision", "document"])).unwrap();
assert!(h.contains("images (PNG, JPEG, GIF, WebP)") && h.contains("PDF documents"), "{h}");
}
#[tokio::test]
async fn probe_media_recognizes_a_png_and_ignores_text() {
let dir = std::env::temp_dir().join(format!("skald-probe-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&dir).await.unwrap();
let png = dir.join("a.png");
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.extend_from_slice(&[0xAA; 32]);
tokio::fs::write(&png, bytes).await.unwrap();
let txt = dir.join("a.txt");
tokio::fs::write(&txt, b"hello").await.unwrap();
assert_eq!(probe_media(&png).await, Some("image/png"));
assert_eq!(probe_media(&txt).await, None);
assert_eq!(probe_media(&dir.join("missing")).await, None);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,54 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde_json::Value;
use crate::llm::DtlMode;
use super::ChatSessionHandler;
use super::message_builder::MessageBuilder;
impl ChatSessionHandler {
/// Thin wrapper: constructs a `MessageBuilder` from this handler's fields
/// and delegates to `MessageBuilder::build`.
///
/// See `MessageBuilder::build` for the full documentation and message ordering.
pub(super) async fn build_openai_messages(
&self,
pool: &sqlx::SqlitePool,
stack_id: i64,
agent_id: &str,
extra_system_static: Option<&str>,
extra_system_dynamic: Option<&str>,
tail_reminder: Option<&str>,
active_mcp_grants: &HashSet<String>,
system_substitutions: &HashMap<String, String>,
cache_hints: bool,
capabilities: &[String],
dtl: DtlMode,
config_tool_defs: &[Value],
activation_stack: Option<i64>,
) -> anyhow::Result<Vec<Value>> {
let project_root = self.run_context.read().await
.as_ref()
.and_then(|rc| rc.project_root.clone());
let builder = MessageBuilder {
pool: Arc::clone(&self.db),
shared_pool: Arc::clone(&self.shared_pool),
user_id: self.user_id.clone(),
session_id: self.scratchpad_sid(),
mcp: Arc::clone(&self.mcp),
datetime_config: self.datetime_config.clone(),
max_history_messages: self.max_history_messages,
max_tool_result_chars: self.max_tool_result_chars,
compactor: self.compactor.clone(),
project_root,
// Snapshot the fs cell for this build — its workspace roots contain the
// tool-produced media inlined into the current turn (§6 remount-safe).
fs: Some(self.fs.load()),
};
// `pool` is passed in from the caller (always `&self.db`) but we take
// ownership via Arc::clone above so the signature stays backward-compatible.
let _ = pool; // suppress unused-variable warning; MessageBuilder uses its own Arc
builder.build(stack_id, agent_id, extra_system_static, extra_system_dynamic, tail_reminder, active_mcp_grants, system_substitutions, cache_hints, capabilities, dtl, config_tool_defs, activation_stack).await
}
}
+48 -114
View File
@@ -6,7 +6,6 @@ use async_trait::async_trait;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use tokio::sync::{Mutex, mpsc};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, trace, warn};
@@ -16,7 +15,6 @@ use crate::tools::tool_names as tn;
use crate::chat_event_bus::{ChatEvent, ChatEventBus, ChatEventRole};
use crate::clarification::ClarificationManager;
use crate::compactor::ContextCompactor;
use crate::config::DatetimeConfig;
use crate::db::{chat_history, chat_sessions_stack};
use crate::events::ServerEvent;
use core_api::message_meta::MessageMetadata;
@@ -25,24 +23,12 @@ use crate::llm::LlmManager;
use crate::mcp::McpProvider;
use crate::image_generate::ImageGeneratorManager;
use crate::memory::MemoryManager;
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
mod approval;
mod agent_dispatch;
mod config;
mod dispatch;
mod emitter;
mod gate;
pub(crate) mod config;
mod kernel_turn;
mod interface_tools;
mod llm_call;
mod llm_loop;
pub(crate) mod interface_tools;
pub mod media;
pub mod message_builder;
mod messages;
mod outcome;
mod resume;
pub use interface_tools::{InterfaceTool, ToolFuture};
@@ -64,7 +50,7 @@ pub struct PendingMsg {
}
/// Source of queued user input for the in-flight turn. Implemented by `ChatHub`
/// over a source's inbox; it lets `run_agent_turn` pull newly-queued user
/// over a source's inbox; it lets the kernel pull newly-queued user
/// messages at each round boundary and inject them live into the running turn.
///
/// Passed as `Some` only for the root interactive turn. Sub-agents, resume, and
@@ -76,35 +62,18 @@ pub trait PendingUserInput: Send + Sync {
async fn drain_user(&self) -> Vec<PendingMsg>;
}
/// Control-flow signals returned as `anyhow::Error` by internal dispatch methods.
/// Using a typed enum instead of two separate sentinel structs allows a single
/// `downcast_ref` in `llm_loop` instead of two separate type checks.
#[derive(Debug)]
pub(super) enum AgentFlowSignal {
/// The WS disconnected while `dispatch_ask_user_clarification` was blocking.
/// The tool stays `'pending'` in DB so `resume_pending_tools` can re-ask on reconnect.
QuestionChannelClosed,
}
impl std::fmt::Display for AgentFlowSignal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::QuestionChannelClosed => write!(f, "question channel closed (WS disconnected)"),
}
}
}
impl std::error::Error for AgentFlowSignal {}
/// What a turn ended as, for the caller of `handle_message`. Deliberately
/// thinner than the kernel's outcome: the content the UI shows (`Done`,
/// `Truncated`, the reasoning trace) is already on the wire by the time a turn
/// returns — the event translator emitted it live — so what is left here is
/// what the app still has to do afterwards (publish on the chat bus, record
/// token counts for the compaction threshold).
pub(super) enum TurnOutcome {
Final {
content: String,
message_id: i64,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
truncated: bool,
/// Chain-of-thought produced by the final round, when any.
reasoning_content: Option<String>,
/// All tool calls executed during this turn, across all rounds.
tool_calls: Vec<crate::chat_event_bus::ToolCallEvent>,
},
@@ -188,10 +157,8 @@ pub(crate) fn write_todos_tool_def() -> Value {
}
/// Tool definition that lets a sub-agent (depth > 0) dispatch a further
/// synchronous sub-agent. The call is intercepted in `run_agent_turn` and routed
/// to `dispatch_sub_agent` (the InterfaceTool handler is never reached), so only
/// the definition is needed here. `agent_id` is required because
/// `dispatch_sub_agent` rejects calls without it.
/// synchronous sub-agent. The behaviour is the crate's `DelegateTool`; this is
/// the legacy schema it is advertised with, kept byte-for-byte (D11).
pub(crate) fn execute_subtask_tool_def() -> Value {
json!({
"type": "function",
@@ -280,16 +247,10 @@ pub struct ChatSessionHandler {
/// tool call without being rebuilt — see [`SharedFs`].
pub(super) fs: SharedFs,
pub(super) llm_manager: Arc<LlmManager>,
pub(super) max_history_messages: usize,
pub(super) max_tool_rounds: usize,
/// Max synchronous sub-agents dispatched concurrently for a homogeneous batch
/// of sub-agent calls in a single LLM response (`1` = sequential).
pub(super) max_parallel_subagents: usize,
/// If `Some(n)`, tool results from previous turns that exceed `n` characters
/// are replaced with a placeholder when building the LLM context.
/// The database always retains the original content.
pub(super) max_tool_result_chars: Option<usize>,
pub(super) datetime_config: DatetimeConfig,
/// Round budget, for the error message when a turn exhausts it. Every other
/// loop limit (history window, result caps, fan-out width, datetime block)
/// belongs to the turn, so it lives on the `UserLoopRuntime`'s `LoopConfig`.
pub(super) max_tool_rounds: usize,
pub(super) agent_id: String,
/// Source of the session: "web", "telegram", "cron", etc.
pub(super) source: String,
@@ -299,9 +260,6 @@ pub struct ChatSessionHandler {
pub(super) is_ephemeral: bool,
pub(super) tools: Arc<ToolRegistry>,
pub(super) mcp: Arc<dyn McpProvider>,
/// Records tools offered to the LLM each round so the Security-groups UI can
/// list/gate dynamically-injected tools (interface/plugin/provider tools).
pub(super) tool_discovery: Arc<ToolDiscovery>,
pub(super) approval: Arc<ApprovalManager>,
pub(super) clarification: Arc<ClarificationManager>,
pub(super) event_bus: Arc<ChatEventBus>,
@@ -311,14 +269,6 @@ pub struct ChatSessionHandler {
pub(super) image_generator_manager: Arc<ImageGeneratorManager>,
/// Prevents concurrent handle_message calls on the same session.
pub(super) processing: Mutex<()>,
/// Cancellation scope for the in-flight turn. A fresh token is minted per
/// user message (`handle_message`) and per resume (`resume_turn`), then a
/// clone is threaded by value through the whole (possibly recursive) call
/// tree. `cancel()` cancels whatever token is currently stored, which the
/// running chain observes because it holds its own clone of that same token.
/// Replacing the field only affects the *next* turn — that is what makes a
/// stop sticky across sub-agent recursion (it is never reset mid-turn).
pub(super) current_cancel: std::sync::Mutex<CancellationToken>,
/// When true, any tool call that would require human approval is automatically
/// denied instead of blocking. Used by TicManager and other headless runners
/// that cannot process approval requests.
@@ -330,9 +280,9 @@ pub struct ChatSessionHandler {
/// Context compactor, shared across all sessions. `None` when compaction
/// is disabled (no `compaction` section in config).
pub(super) compactor: Option<Arc<ContextCompactor>>,
/// The live kernel-driven turn (manager + conversation) for `/stop`
/// routing (phase 2). `None` between turns / on legacy paths.
pub(super) kernel_live: std::sync::Mutex<Option<(Arc<agent_loop::manager::LoopManager>, agent_loop::ids::ConversationId)>>,
/// This user's loop stack (manager, store, gate, catalog, delegate), built
/// once per `ChatSessionManager` and shared by every session of the owner.
pub(super) loop_runtime: Arc<crate::loop_adapters::runtime::UserLoopRuntime>,
/// Input token count from the most recently completed turn, stored
/// atomically so the next `handle_message` call can decide whether to
/// compact before processing the new message. Zero means unknown
@@ -353,11 +303,7 @@ impl ChatSessionHandler {
user_id: String,
fs: SharedFs,
llm_manager: Arc<LlmManager>,
max_history_messages: usize,
max_tool_rounds: usize,
max_parallel_subagents: usize,
max_tool_result_chars: Option<usize>,
datetime_config: DatetimeConfig,
agent_id: String,
source: String,
is_interactive: bool,
@@ -371,7 +317,7 @@ impl ChatSessionHandler {
image_generator_manager: Arc<ImageGeneratorManager>,
compactor: Option<Arc<ContextCompactor>>,
run_context: Option<RunContext>,
tool_discovery: Arc<ToolDiscovery>,
loop_runtime: Arc<crate::loop_adapters::runtime::UserLoopRuntime>,
) -> Self {
Self {
session_id,
@@ -380,18 +326,13 @@ impl ChatSessionHandler {
user_id,
fs,
llm_manager,
max_history_messages,
max_tool_rounds,
max_parallel_subagents,
max_tool_result_chars,
datetime_config,
agent_id,
source,
is_interactive,
is_ephemeral,
tools,
mcp,
tool_discovery,
approval,
clarification,
event_bus,
@@ -400,13 +341,12 @@ impl ChatSessionHandler {
compactor,
context_label: Arc::new(std::sync::RwLock::new(None)),
processing: Mutex::new(()),
current_cancel: std::sync::Mutex::new(CancellationToken::new()),
auto_deny_approvals: Arc::new(AtomicBool::new(false)),
pre_approved: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
last_input_tokens: AtomicU32::new(0),
run_context: Arc::new(tokio::sync::RwLock::new(run_context)),
scratchpad_session_id: std::sync::OnceLock::new(),
kernel_live: std::sync::Mutex::new(None),
loop_runtime,
}
}
@@ -451,17 +391,17 @@ impl ChatSessionHandler {
self.run_context.read().await.as_ref().and_then(|rc| rc.tool_group_id().map(str::to_owned))
}
/// Cancels the in-flight turn. The running call tree holds its own clone of
/// the same token, so it stops at the next round boundary, on the in-flight
/// LLM call, and on cancellable tools (e.g. `execute_cmd`). Sticky across
/// sub-agent recursion: the token is never reset mid-turn.
/// Cancels the in-flight turn. The manager cancels the conversation's live
/// loop, and every frame under it holds a child of that token — so a `/stop`
/// is sticky across sub-agent recursion, and lands on the next round
/// boundary, on the in-flight LLM call, and on cancellable tools
/// (e.g. `execute_cmd`).
pub fn cancel(&self) {
self.current_cancel.lock().unwrap().cancel();
self.cancel_kernel_turn();
}
/// True if a turn is currently in flight (the `processing` mutex is held for
/// the whole duration of `handle_message` / `resume_turn`). Used to tell a
/// the whole duration of `handle_message` / a recovery). Used to tell a
/// freshly (re)connected client to show the STOP button.
pub fn is_processing(&self) -> bool {
self.processing.try_lock().is_err()
@@ -495,7 +435,7 @@ impl ChatSessionHandler {
/// Cancels all pending clarification requests for this session (WS disconnected).
/// The blocked `rx.await` in dispatch_ask_user_clarification returns Err → TurnOutcome::Cancelled,
/// leaving the tool as 'pending' so resume_pending_tools re-dispatches on reconnect.
/// leaving the tool as 'pending' so the next recovery re-asks on reconnect.
pub async fn cancel_pending_questions(&self) {
self.clarification.cancel_for_session(self.session_id).await;
}
@@ -511,7 +451,9 @@ impl ChatSessionHandler {
};
match self.compactor {
Some(ref compactor) => {
compactor.force_compact(pool, self.session_id, stack.id, self.is_ephemeral).await
compactor.force_compact(
self.loop_runtime.manager(), pool, self.session_id, stack.id, self.is_ephemeral,
).await
}
None => Ok(false),
}
@@ -538,19 +480,17 @@ impl ChatSessionHandler {
// (TicManager ticks, notification briefings from ChatHub).
is_synthetic: bool,
// Structured metadata persisted on the user turn (e.g. file attachments).
// The MessageBuilder derives the LLM-facing block; the UI renders chips.
// The projection derives the LLM-facing block; the UI renders chips.
metadata: Option<MessageMetadata>,
// Queued user input for this source. When `Some`, `run_agent_turn` drains
// Queued user input for this source. When `Some`, the kernel drains
// it at each round boundary and injects newly-arrived user messages into
// the running turn. `None` for sub-agents / resume / non-interactive runners.
pending_input: Option<Arc<dyn PendingUserInput>>,
) -> anyhow::Result<()> {
let _guard = self.processing.lock().await;
// Fresh cancellation scope for this user message. Stored so `cancel()`
// can reach it, and cloned-by-value into the call tree so a /stop during
// the turn is sticky across sub-agent recursion (never reset mid-turn).
let token = CancellationToken::new();
*self.current_cancel.lock().unwrap() = token.clone();
// NB: the turn's cancellation scope is the manager's — minted by
// `start_turn` and cloned by value down the whole call tree, so a /stop
// is sticky across sub-agent recursion (see `cancel`).
let pool = &self.db;
let user_content = content.to_string(); // saved for the ChatEvent publication
@@ -614,7 +554,9 @@ impl ChatSessionHandler {
// happens here, before the LLM loop, and is not a separate turn.
if let Some(ref compactor) = self.compactor {
let last_tokens = self.last_input_tokens.load(Ordering::Relaxed);
match compactor.try_compact(pool, self.session_id, stack.id, last_tokens, self.is_ephemeral).await {
match compactor.try_compact(
self.loop_runtime.manager(), pool, self.session_id, stack.id, last_tokens, self.is_ephemeral,
).await {
Ok(true) => info!(session_id = self.session_id, stack_id = stack.id, "handle_message: context compacted"),
Ok(false) => {}
Err(e) => warn!(session_id = self.session_id, error = %e, "handle_message: compaction failed (non-fatal), continuing"),
@@ -622,30 +564,22 @@ impl ChatSessionHandler {
}
// ─────────────────────────────────────────────────────────────────────
// If the previous turn was cancelled before the LLM responded, the history ends on a
// User message with no following assistant. This breaks the user→assistant alternation
// required by strict APIs (e.g. OpenRouter). Mark the orphaned message as failed so
// for_stack() excludes it from the context we send to the LLM.
let prior = chat_history::for_stack(pool, stack.id).await?;
if let Some(last) = prior.last() {
if matches!(last.role, chat_history::Role::User | chat_history::Role::Agent) {
warn!(session_id = self.session_id, message_id = last.id, "orphaned user message (cancelled turn) — marking failed");
chat_history::mark_failed(pool, last.id).await?;
}
}
// NB: a trailing orphan User/Agent message (a turn cancelled before the
// LLM answered, which breaks the alternation strict APIs require) is
// marked failed by `LoopManager::start_turn` — it is a well-formedness
// rule of the history, so the library owns it, and it runs there at the
// right moment: right before the new user message is appended.
// Resume any tool calls left pending from a previous interrupted session.
// They are re-gated (rules may have changed) and executed before the LLM runs.
// (Runs before the kernel turn, which appends the user message itself —
// resumed results belong to the previous turn and land first.)
self.resume_pending_tools(stack.id, &config, &token, &tx).await?;
// NB: tool calls left dangling by an interrupted session are repaired
// inside `run_kernel_turn` — it owns the event translator, so the
// re-execution's cards reach the client like any other.
let outcome = self.run_kernel_turn(
stack.id, &config, content, is_synthetic, metadata.as_ref(), pending_input.as_ref(), &tx,
&config, content, is_synthetic, metadata.as_ref(), pending_input.as_ref(), &tx,
).await?;
match outcome {
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, truncated: _, reasoning_content: _, tool_calls } => {
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, tool_calls } => {
// Persist token count so the *next* handle_message call knows
// whether to compact before running the LLM loop.
if let Some(t) = input_tokens {
@@ -1,111 +0,0 @@
//! Shared recording of a single tool-call outcome.
//!
//! The persist-then-emit tail of a tool call (`ExecutionOutcome` → DB row +
//! `ToolDone`/`ToolError`/`ToolCancelled` event) was copy-pasted into both the live
//! loop (`run_agent_turn`) and `resume_pending_tools`. `record_tool_outcome` is the
//! single implementation both call.
use serde_json::Value;
use tracing::{debug, info, warn};
use crate::chat_event_bus::ToolCallEvent;
use crate::db::chat_llm_tools;
use crate::tools::{is_file_write_tool, ExecutionOutcome};
use super::ChatSessionHandler;
use super::dispatch::WritePreview;
use super::emitter::TurnEmitter;
/// Whether the enclosing loop should keep going after an outcome is recorded.
pub(super) enum RecordFlow {
/// Continue with the next tool call / round.
Continue,
/// The tool was cancelled by the user — the caller must end the turn.
Abort,
}
impl ChatSessionHandler {
/// Persists one tool-call outcome and emits the matching lifecycle event.
/// Returns [`RecordFlow::Abort`] for a user cancellation (the caller ends the
/// turn), [`RecordFlow::Continue`] otherwise.
///
/// When `accumulate` is `Some` (the live turn), the call is also appended to the
/// turn's `ToolCallEvent` list for the chat-event bus, and a `FileChanged` event
/// is emitted for a successful file-write tool. `resume_pending_tools` passes
/// `None`: it neither accumulates nor re-emits `FileChanged`.
pub(super) async fn record_tool_outcome(
&self,
tool_call_id: i64,
tool_name: &str,
args: &Value,
outcome: ExecutionOutcome,
preview: Option<WritePreview>,
em: &TurnEmitter<'_>,
accumulate: Option<&mut Vec<ToolCallEvent>>,
) -> anyhow::Result<RecordFlow> {
let pool = &self.db;
match outcome {
ExecutionOutcome::Completed(result) => {
let wire = result.to_wire();
let kind = result.kind();
debug!(session_id = self.session_id, tool = %tool_name, tool_call_id, result_len = wire.len(), "tool done");
chat_llm_tools::complete(pool, tool_call_id, &wire, kind).await?;
// Media the tool produced (e.g. read_file on an image/PDF) rides
// out of band in the `media` column; the message builder inlines it
// as a synthetic user message for a capable model on the current turn.
let media = result.media();
if !media.is_empty() {
let media_json = serde_json::to_string(media).unwrap_or_else(|_| "[]".to_string());
chat_llm_tools::set_media(pool, tool_call_id, &media_json).await?;
}
// Persist a file-write's diff snapshot so it re-renders after a reload,
// and carry it on the event so an auto-allowed write shows the diff live.
let (preview_old, preview_new) = match preview {
Some(WritePreview { old, new }) => {
chat_llm_tools::set_preview(pool, tool_call_id, old.as_deref(), new.as_deref()).await?;
(old, new)
}
None => (None, None),
};
if let Some(acc) = accumulate {
if is_file_write_tool(tool_name)
&& let Some(p) = args["path"].as_str()
{
em.file_changed(crate::approval::normalize_path(p)).await;
}
acc.push(ToolCallEvent {
name: tool_name.to_string(),
arguments: Some(serde_json::to_string(args).unwrap_or_default()),
result: Some(wire.clone()),
status: "done".to_string(),
});
}
em.tool_done(tool_call_id, wire, kind.to_string(), preview_old, preview_new).await;
Ok(RecordFlow::Continue)
}
ExecutionOutcome::Failed(msg) => {
warn!(session_id = self.session_id, tool = %tool_name, tool_call_id, error = %msg, "tool failed");
chat_llm_tools::fail(pool, tool_call_id, &msg).await?;
if let Some(acc) = accumulate {
acc.push(ToolCallEvent {
name: tool_name.to_string(),
arguments: Some(serde_json::to_string(args).unwrap_or_default()),
result: Some(msg.clone()),
status: "failed".to_string(),
});
}
em.tool_error(tool_call_id, msg).await;
Ok(RecordFlow::Continue)
}
ExecutionOutcome::Cancelled => {
// A /stop hit this tool mid-flight. Record it as cancelled (not
// failed); the sticky token cancels the rest of the loop by
// construction, so the caller just ends the turn.
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, "tool cancelled by user");
chat_llm_tools::cancel(pool, tool_call_id, "Cancelled by user.").await?;
em.tool_cancelled(tool_call_id).await;
Ok(RecordFlow::Abort)
}
}
}
}
@@ -1,438 +0,0 @@
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use crate::db::{chat_history, chat_llm_tools, chat_sessions_stack};
use crate::events::ServerEvent;
use crate::tools::{drive_execution, ExecutionOutcome, ToolDescriptionLength, ToolResult, tool_names as tn};
use super::{ChatSessionHandler, TurnOutcome};
use super::emitter::TurnEmitter;
use super::gate::GateOutcome;
use super::outcome::RecordFlow;
use super::interface_tools::{AgentRunConfig, InterfaceTool};
impl ChatSessionHandler {
/// Dispatches a single already-approved tool call by name+args, without running
/// the LLM loop. The sole caller is the REST `resolve` endpoint's post-restart
/// "simple tools" branch (no live oneshot to unblock; sub-agent and `restart`
/// tools are handled earlier there). Does NOT touch the DB — the caller records
/// `complete`/`fail`.
///
/// Runs through the **same canonical path as the live loop** — `build_execution`
/// (which constructs the [`ToolContext`]: owner pool + per-user container fs)
/// driven by `drive_execution`. The previous `self.tools.dispatch(name, args)`
/// bypassed the context entirely, so a resolved `write_file` landed in the server
/// cwd (no containment, memory paths hit disk) and `execute_cmd` ran on the host —
/// a blueprint §6 sandbox escape (bug B1). MCP tools are covered by
/// `build_execution` too, so no name special-casing is needed here.
pub async fn execute_tool(&self, name: &str, args: Value) -> anyhow::Result<ToolResult> {
// No interface tools post-restart: a pending-approval tool is a built-in /
// memory / MCP call, never a per-interface closure like `activate_tools`.
let config = self.build_agent_config(
None, None, None, Vec::new(), std::collections::HashMap::new(),
).await?;
let exec = self.build_execution(name, args, &config)
.ok_or_else(|| anyhow::anyhow!("unknown tool: {name}"))?;
// A resolve is a one-shot; nothing wires /stop to it, so a fresh (never
// cancelled) token satisfies the driver contract.
let token = CancellationToken::new();
match drive_execution(exec.as_ref(), &token).await {
ExecutionOutcome::Completed(result) => Ok(result),
ExecutionOutcome::Failed(msg) => Err(anyhow::anyhow!(msg)),
ExecutionOutcome::Cancelled => Err(anyhow::anyhow!("tool execution cancelled")),
}
}
/// Resumes the LLM loop for the current session WITHOUT appending a new user message.
/// Intended for use after pending tool calls have been resolved externally
/// (e.g. via the REST approve endpoint) so the LLM can produce a final response
/// or make further tool calls using the now-complete history.
pub async fn resume_turn(
&self,
client_name: Option<String>,
extra_system_context: Option<String>,
interface_tools: Vec<InterfaceTool>,
tx: mpsc::Sender<ServerEvent>,
) -> anyhow::Result<()> {
let _guard = self.processing.lock().await;
// A resume is a fresh unit of work (async result injection, app-restart
// recovery, WS resume): mint a new token so it does not inherit a stale
// cancellation, while a /stop *during* the resume still cancels this token.
let token = CancellationToken::new();
*self.current_cancel.lock().unwrap() = token.clone();
let pool = &self.db;
let em = TurnEmitter::new(&tx);
let mut config = self.build_agent_config(
client_name, extra_system_context, None, interface_tools, std::collections::HashMap::new(),
).await?;
config.tail_reminder = None;
// Prune any interrupted parallel sub-agent batch before the linear cascade,
// which assumes a single active frame per depth (see method doc).
self.reap_interrupted_parallel_batches().await?;
let stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
Some(s) => s,
None => {
warn!(session_id = self.session_id, "resume_turn: no active stack, nothing to resume");
return Ok(());
}
};
info!(session_id = self.session_id, stack_id = stack.id, depth = stack.depth, "resume_turn start");
// B3: resume each frame with ITS OWN agent's config (prompt/tools/client), not
// the session root's. After a restart the deepest active frame may be a
// sub-agent; running it under `config` would resume e.g. a `researcher` as the
// `assistant`. The root frame keeps `config`; a sub-agent frame gets a freshly
// built sub-agent config for its own agent (deferred-init so the root path
// borrows `config` and the sub-agent path borrows the owned value).
let seed_frame_config;
let seed_config: &AgentRunConfig = if stack.parent_tool_call_id.is_none() {
&config
} else {
seed_frame_config = self.build_recovery_frame_config(&config, &stack).await?;
&seed_frame_config
};
// Resume pending/interrupted tools before running the LLM loop.
let had_pending = self.resume_pending_tools(stack.id, seed_config, &token, &tx).await?;
// Seed the cascade. Normally we (re)run the deepest active frame's LLM loop
// (live injection only applies to a fresh interactive turn from handle_message).
// Two special cases when nothing was pending AND the frame's last message is a
// pure-text assistant reply (its own turn is already complete):
// • root frame (no parent) → nothing to do, skip the LLM.
// • child frame (has parent) → its result was produced but never propagated
// (e.g. the turn task died right after the child finished). Seed the cascade
// from the existing final message — without re-running the LLM — so the
// parent's tool call is completed and the parent continues. Skipping here
// (as the old guard did unconditionally) left the parent wedged forever.
let (mut current_outcome, mut current_stack) = 'seed: {
if !had_pending {
if let Some(msg) = chat_history::last_message_for_stack(pool, stack.id).await? {
if matches!(msg.role, chat_history::Role::Assistant)
&& chat_llm_tools::for_message(pool, msg.id).await?.is_empty()
{
if stack.parent_tool_call_id.is_none() {
info!(session_id = self.session_id, stack_id = stack.id, "resume_turn: last message is pure-text assistant, turn already complete — skipping LLM");
return Ok(());
}
info!(session_id = self.session_id, stack_id = stack.id, "resume_turn: deepest frame is a completed child — cascading its existing result to the parent");
let outcome = TurnOutcome::Final {
content: msg.content,
message_id: msg.id,
input_tokens: None,
output_tokens: None,
truncated: false,
reasoning_content: msg.reasoning_content,
tool_calls: Vec::new(),
};
break 'seed (outcome, stack);
}
}
}
(self.run_agent_turn(stack.id, seed_config, &token, &tx, None).await?, stack)
};
// Cascade completion upward through parent stacks (handles app-restart recovery
// when a sub-agent was running — child completes, then parent continues).
loop {
let Some(parent_tool_call_id) = current_stack.parent_tool_call_id else { break };
// Determine the result string to propagate to the parent's call_agent tool.
let (result_str, is_error) = match &current_outcome {
TurnOutcome::Final { content, .. } => (content.clone(), false),
TurnOutcome::Cancelled => (format!("Sub-agent `{}` was cancelled.", current_stack.agent_id), true),
TurnOutcome::Exhausted => (format!("Sub-agent `{}` exhausted tool-call rounds.", current_stack.agent_id), true),
};
let result_preview = super::preview_truncate(&result_str, 500);
// Complete or fail the parent's call_agent tool call.
if is_error {
chat_llm_tools::fail(pool, parent_tool_call_id, &result_str).await?;
} else {
chat_llm_tools::complete(pool, parent_tool_call_id, &result_str, "string").await?;
}
// Terminate the child stack so active_for_session() returns the parent next.
let _ = chat_sessions_stack::terminate(pool, current_stack.id).await;
// Emit events to the frontend.
if is_error {
em.tool_error(parent_tool_call_id, result_str).await;
} else {
em.tool_done(parent_tool_call_id, result_str, "string".to_string(), None, None).await;
}
// Now the parent is the deepest active stack.
let parent_stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
Some(s) => s,
None => {
warn!(session_id = self.session_id, "resume_turn cascade: no active stack after child terminated");
break;
}
};
em.agent_done(
current_stack.id,
current_stack.agent_id.clone(),
parent_stack.agent_id.clone(),
result_preview,
).await;
info!(
session_id = self.session_id,
child_stack = current_stack.id,
parent_stack = parent_stack.id,
depth = parent_stack.depth,
"resume_turn: cascading to parent stack"
);
// B3: run the parent under its own agent's config (the root keeps `config`).
let parent_frame_config;
let parent_run_config: &AgentRunConfig = if parent_stack.parent_tool_call_id.is_none() {
&config
} else {
parent_frame_config = self.build_recovery_frame_config(&config, &parent_stack).await?;
&parent_frame_config
};
self.resume_pending_tools(parent_stack.id, parent_run_config, &token, &tx).await?;
current_outcome = self.run_agent_turn(parent_stack.id, parent_run_config, &token, &tx, None).await?;
current_stack = parent_stack;
}
// current_stack is now the root (depth=0); emit the final event.
match current_outcome {
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, truncated, reasoning_content, .. } => {
info!(session_id = self.session_id, "resume_turn done");
if truncated {
warn!(session_id = self.session_id, "response truncated");
em.truncated(output_tokens).await;
}
em.done(message_id, current_stack.id, content, input_tokens, output_tokens, reasoning_content).await;
}
TurnOutcome::Cancelled => {
info!(session_id = self.session_id, "resume_turn cancelled");
em.error("Cancelled by user.".to_string()).await;
}
TurnOutcome::Exhausted => {
error!(session_id = self.session_id, "resume_turn exhausted tool rounds");
em.error("Exceeded tool-call rounds without a final answer.".to_string()).await;
}
}
Ok(())
}
/// Restart recovery for an interrupted **parallel** sub-agent batch.
///
/// A purely linear stack has at most one active frame per depth. Two or more
/// active frames at the same depth can only mean a concurrent sub-agent batch
/// (`handle_sub_agent_batch`) was in flight when the process died. This app is
/// single-user and deliberately tolerates losing mid-turn work on restart, so
/// rather than a complex multi-sibling re-drive we simply prune the batch:
/// terminate every active frame from the shallowest multi-frame depth downward
/// and fail the sub-agent tool call that spawned each. The parent frame is then
/// left with a clean, fully-resolved set of tool calls and the normal linear
/// cascade resumes it. A single interrupted sub-agent (one frame at its depth)
/// is untouched and still recovers via the existing cascade.
async fn reap_interrupted_parallel_batches(&self) -> anyhow::Result<()> {
let pool = &self.db;
let active = chat_sessions_stack::active_all_for_session(pool, self.session_id).await?;
let Some(d_min) = shallowest_parallel_depth(&active) else {
return Ok(()); // linear stack — nothing to reap
};
warn!(
session_id = self.session_id, depth = d_min,
"restart recovery: pruning interrupted parallel sub-agent batch"
);
for frame in active.iter().filter(|f| f.depth >= d_min) {
if let Some(parent_tool_call_id) = frame.parent_tool_call_id {
let _ = chat_llm_tools::fail(
pool, parent_tool_call_id, "Sub-agent interrupted by restart (parallel batch).",
).await;
}
let _ = chat_sessions_stack::terminate(pool, frame.id).await;
}
Ok(())
}
/// Called at the start of `handle_message` (and by the REST endpoint after a manual
/// resolve). Finds any `pending` tool calls left from a previous interrupted session,
/// re-runs them through the approval gate, executes approved ones, and fails rejected
/// or denied ones — so `run_agent_turn` sees complete history and can continue cleanly.
pub async fn resume_pending_tools(
&self,
stack_id: i64,
config: &AgentRunConfig,
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
) -> anyhow::Result<bool> {
let pool = &self.db;
let em = TurnEmitter::new(tx);
let pending = chat_llm_tools::pending_for_stack(pool, stack_id).await?;
if pending.is_empty() {
return Ok(false);
}
info!(
session_id = self.session_id, stack_id,
count = pending.len(), "resuming pending tool calls"
);
for tc in pending {
let args: Value = tc.arguments.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Object(Default::default()));
// A pending `execute_task` (mode=sync) or `execute_subtask` means a
// sub-agent stack was active. The cascade in resume_turn() handles it
// by running the child stack to completion and propagating the result
// up — skip it here.
if tc.name == tn::EXECUTE_TASK || tc.name == tn::EXECUTE_SUBTASK {
info!(session_id = self.session_id, tool_call_id = tc.id, "resume: skipping sub-agent dispatch (handled by stack cascade)");
continue;
}
// `ask_user_clarification` is a synthetic tool (not in the registry).
// Re-dispatch it directly so the question is re-asked to the user.
if tc.name == tn::ASK_USER_CLARIFICATION {
info!(session_id = self.session_id, tool_call_id = tc.id, "resume: re-asking clarification question");
let (display_name, icon) = self.tool_ui_meta(&tc.name, &args);
em.tool_start(
tc.id,
tc.message_id,
tc.name.clone(),
args.clone(),
display_name, icon,
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
self.tools.target_path(&tc.name, &args),
).await;
let result = self.dispatch_ask_user_clarification(tc.id, &args, tx).await;
match result {
Ok(answer) => {
chat_llm_tools::complete(pool, tc.id, &answer, "string").await?;
em.tool_done(tc.id, answer, "string".to_string(), None, None).await;
}
Err(e) if matches!(e.downcast_ref::<super::AgentFlowSignal>(), Some(super::AgentFlowSignal::QuestionChannelClosed)) => {
// WS disconnected again mid-resume. Tool stays 'pending' — next resume re-asks.
warn!(session_id = self.session_id, tool_call_id = tc.id, "clarification channel closed during resume — aborting");
return Ok(true);
}
Err(e) => {
let msg = e.to_string();
chat_llm_tools::fail(pool, tc.id, &msg).await?;
em.tool_error(tc.id, msg).await;
}
}
continue;
}
// Announce the tool is being re-tried.
let (display_name, icon) = self.tool_ui_meta(&tc.name, &args);
em.tool_start(
tc.id,
tc.message_id,
tc.name.clone(),
args.clone(),
display_name, icon,
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
self.tools.target_path(&tc.name, &args),
).await;
// Re-run through the same approval gate as a live turn (current rules,
// RunContext fast-path, auto-deny). Deny/reject paths mark the DB row and
// emit the event internally; a closed channel leaves the tool pending.
match self.run_approval_gate(tc.id, &tc.name, &args, &config.agent_id, &em).await? {
GateOutcome::Proceed => {}
GateOutcome::Rejected => continue,
GateOutcome::ChannelClosed => return Ok(true), // pending still, WS disconnected
}
// Re-run the persisted intent through the SAME dispatcher as a live turn
// (`execute_tool_call`), not the flat `build_execution`. This routes
// sub-agent tools (`execute_task` mode=sync, `execute_subtask`,
// `run_subtask`) through the recursive interception in `dispatch.rs`;
// `build_execution` alone does not know them and would fail with
// "Unknown tool: execute_task". Args are passed through unchanged.
let (outcome, preview) = match self.execute_tool_call(
stack_id, config, tc.id, &tc.name, &args, token, tx,
).await {
super::dispatch::DispatchResult::Outcome { outcome, preview } => (outcome, preview),
// Clarification WS channel closed mid-resume — leave the tool pending
// so the next resume re-asks (mirrors the live turn's AbortPending).
super::dispatch::DispatchResult::AbortPending => return Ok(true),
};
// resume passes `None` for accumulate: it does not accumulate ToolCallEvents
// nor re-emit FileChanged (only a live turn does). The write preview IS
// persisted so a re-run write's diff survives. A /stop mid-resume returns Abort.
match self.record_tool_outcome(tc.id, &tc.name, &args, outcome, preview, &em, None).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => return Ok(true),
}
}
Ok(true)
}
}
/// Shallowest stack depth that has more than one active (non-terminated) frame —
/// the top of an interrupted parallel sub-agent batch. Returns `None` for a linear
/// stack, where every depth has at most one active frame. Pure (see tests).
fn shallowest_parallel_depth(active: &[chat_sessions_stack::SessionStack]) -> Option<i64> {
let mut by_depth: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
for f in active {
*by_depth.entry(f.depth).or_default() += 1;
}
by_depth.iter()
.filter_map(|(depth, count)| (*count > 1).then_some(*depth))
.min()
}
#[cfg(test)]
mod tests {
use super::shallowest_parallel_depth;
use crate::db::chat_sessions_stack::SessionStack;
fn frame(id: i64, depth: i64, parent: Option<i64>) -> SessionStack {
SessionStack { id, agent_id: "agent".into(), depth, parent_tool_call_id: parent }
}
#[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));
}
}
+38 -20
View File
@@ -13,6 +13,7 @@ use crate::compactor::ContextCompactor;
use crate::config::DatetimeConfig;
use crate::db::{chat_sessions, chat_sessions_stack};
use crate::llm::LlmManager;
use crate::loop_adapters::runtime::{LoopConfig, UserLoopRuntime};
use crate::mcp::McpProvider;
use crate::image_generate::ImageGeneratorManager;
use crate::memory::MemoryManager;
@@ -33,11 +34,7 @@ pub struct ChatSessionManager {
/// membership change ([`refresh_fs`](Self::refresh_fs)) reaches live sessions.
user_fs: SharedFs,
llm_manager: Arc<LlmManager>,
max_history_messages: usize,
max_tool_rounds: usize,
max_parallel_subagents: usize,
max_tool_result_chars: Option<usize>,
datetime_config: DatetimeConfig,
tools: Arc<ToolRegistry>,
/// The MCP tools visible to this owner: the access-filtered global runtime
/// unioned with their per-user runtime (blueprint §7), behind one trait.
@@ -50,9 +47,10 @@ pub struct ChatSessionManager {
/// Shared compactor instance, `None` when compaction is disabled.
compactor: Option<Arc<ContextCompactor>>,
run_context_manager: Arc<RunContextManager>,
/// Shared tool-discovery recorder, passed to every handler so each turn can
/// register the tools it actually offers to the LLM (see `ToolDiscovery`).
tool_discovery: Arc<ToolDiscovery>,
/// This user's loop stack (blueprint D12): built once here and shared by
/// every session of the owner, so the manager keeps a global view of what
/// is running and a turn only contributes its own parameters.
loop_runtime: Arc<UserLoopRuntime>,
active: Mutex<HashMap<i64, Arc<ChatSessionHandler>>>,
}
@@ -78,18 +76,36 @@ impl ChatSessionManager {
compactor: Option<Arc<ContextCompactor>>,
run_context_manager: Arc<RunContextManager>,
tool_discovery: Arc<ToolDiscovery>,
) -> Self {
Self {
) -> anyhow::Result<Self> {
let loop_runtime = UserLoopRuntime::build(
db.clone(),
shared_pool.clone(),
user_id.clone(),
user_fs.clone(),
tools.clone(),
mcp.clone(),
llm_manager.clone(),
approval.clone(),
clarification.clone(),
tool_discovery.clone(),
LoopConfig {
max_rounds: max_tool_rounds,
max_parallel_calls: max_parallel_subagents,
max_history_messages,
max_tool_result_chars,
compaction_enabled: compactor.is_some(),
datetime: datetime_config.clone(),
max_agent_depth: crate::session::handler::MAX_AGENT_DEPTH as u32,
},
)?;
Ok(Self {
db,
shared_pool,
user_id,
user_fs,
llm_manager,
max_history_messages,
max_tool_rounds,
max_parallel_subagents,
max_tool_result_chars,
datetime_config,
tools,
mcp,
approval,
@@ -99,9 +115,9 @@ impl ChatSessionManager {
image_generator_manager,
compactor,
run_context_manager,
tool_discovery,
loop_runtime,
active: Mutex::new(HashMap::new()),
}
})
}
pub fn llm_manager(&self) -> Arc<LlmManager> {
@@ -112,6 +128,12 @@ impl ChatSessionManager {
Arc::clone(&self.run_context_manager)
}
/// This owner's loop stack (blueprint D12) — the wiring hands it the pieces
/// that only exist after the session manager does (the `TaskManager`).
pub fn loop_runtime(&self) -> &Arc<UserLoopRuntime> {
&self.loop_runtime
}
/// Returns the live handler for `session_id` if it is currently loaded,
/// without creating a new one. Used by the API for in-place updates.
pub async fn active_handler(&self, session_id: i64) -> Option<Arc<ChatSessionHandler>> {
@@ -178,11 +200,7 @@ impl ChatSessionManager {
self.user_id.clone(),
self.user_fs.clone(),
Arc::clone(&self.llm_manager),
self.max_history_messages,
self.max_tool_rounds,
self.max_parallel_subagents,
self.max_tool_result_chars,
self.datetime_config.clone(),
session.agent_id,
session.source,
session.is_interactive,
@@ -196,7 +214,7 @@ impl ChatSessionManager {
Arc::clone(&self.image_generator_manager),
self.compactor.clone(),
run_context,
Arc::clone(&self.tool_discovery),
Arc::clone(&self.loop_runtime),
));
self.active.lock().await.insert(session_id, handler.clone());
+1 -1
View File
@@ -373,7 +373,7 @@ impl Conversation {
compactor,
Arc::clone(&run_context_manager),
Arc::new(ToolDiscovery::new(Arc::clone(&rt.db))),
));
)?);
let chat_hub = ChatHub::new(
Arc::clone(&rt.db),
+4 -1
View File
@@ -321,7 +321,7 @@ impl UserContextFactory {
Arc::clone(&self.run_context_manager),
// known_tools is registry data → discovery writes to the registry pool.
Arc::new(ToolDiscovery::new(Arc::clone(&self.registry_pool))),
));
)?);
// The owner's default entry agent, snapshotted at login from their role
// (like fs membership / MCP access above): every lazy session-creation path
@@ -346,6 +346,9 @@ impl UserContextFactory {
cron.set_hub(Arc::clone(&chat_hub));
cron.set_self_arc(Arc::clone(&cron));
chat_hub.set_task_mgr(Arc::clone(&cron));
// …and the loop's async executor, so `execute_task mode=async` runs as a
// durable cron job (blueprint §7.2) instead of an interface-tool call.
manager.loop_runtime().set_task_manager(Arc::clone(&cron));
// Per-user cron loop. `start()` observes the shutdown token, so it stops on
// shutdown; adopting it lets the supervisor also join it. The name is leaked
+30 -41
View File
@@ -218,6 +218,15 @@ pub async fn resolve_tool(
let msg = ApprovalDecision::rejection_message(&body.note);
if !live {
chat_llm_tools::reject(db, tc_id, &msg).await?;
// The refusal is part of the conversation: let the model read it and
// carry on, instead of leaving the turn dead where it stopped.
let hub = ctx.chat_hub.clone();
tokio::spawn(async move {
if let Err(e) = hub.resume_session(session_id).await {
tracing::warn!(session_id, tool_call_id = tc_id, error = %e,
"post-restart continue after rejection failed");
}
});
}
return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
@@ -241,48 +250,28 @@ pub async fn resolve_tool(
}
// ── Post-restart path: no in-memory oneshot to unblock. ───────────────────
// Sub-agent tools (`execute_task` etc.) cannot run through the flat
// `execute_tool` path — they need the recursive dispatcher. Mark the call
// pre-approved and drive the owning session's resume, which re-dispatches it
// via `execute_tool_call` (gate skipped) and continues the loop. Events stream
// to the reconnected client through the global bus; return immediately.
if tc_name == "execute_task" || tc_name == tn::EXECUTE_SUBTASK || tc_name == "run_subtask" {
let handler = ctx.chat_hub.handler_for_session(session_id).await?;
handler.mark_pre_approved(tc_id);
let hub = ctx.chat_hub.clone();
tokio::spawn(async move {
if let Err(e) = hub.resume_session(session_id).await {
tracing::warn!(session_id, tool_call_id = tc_id, error = %e, "post-restart resume of sub-agent tool failed");
}
});
return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "running".to_string(),
result: None,
result_type: "string".to_string(),
}));
}
// Simple tools: execute directly on the owning session and return the result.
let handler = ctx.chat_hub.handler_for_session(session_id).await?;
match handler.execute_tool(&tc_name, args).await {
Ok(result) => {
let wire = result.to_wire();
let kind = result.kind();
chat_llm_tools::complete(db, tc_id, &wire, kind).await?;
Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "done".to_string(),
result: Some(wire),
result_type: kind.to_string(),
}))
// One path for every tool: the loop's `resolve_pending` runs the call with
// the gate skipped (the human just decided) but with this session's real
// context — owner pool, per-user container — then continues the turn. A
// sub-agent dispatch works here too: it opens its child frame like any
// other call. Events stream to the reconnected client through the global
// bus, so the endpoint returns as soon as the work is scheduled.
let hub = ctx.chat_hub.clone();
tokio::spawn(async move {
if let Err(e) = hub
.resolve_pending_call(session_id, tc_id, ApprovalDecision::Approved)
.await
{
tracing::warn!(session_id, tool_call_id = tc_id, error = %e,
"post-restart approval failed");
}
Err(e) => {
let msg = e.to_string();
chat_llm_tools::fail(db, tc_id, &msg).await?;
Err(anyhow::anyhow!(msg).into())
}
}
});
Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "running".to_string(),
result: None,
result_type: "string".to_string(),
}))
}
// ── GET /api/tools/:tool_call_id — full execution detail for the detail page ──
+1 -1
View File
@@ -381,7 +381,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
// Attachments uploaded beforehand, plus an optional custom-command
// marker. Persisted on the user turn as MessageMetadata; the
// [SYSTEM INFO] block the LLM sees is generated on the fly by the
// MessageBuilder (never stored as text), and the UI renders the
// projection (never stored as text), and the UI renders the
// command's `display` instead of the expanded `content`.
let attachments = client_msg.attachments.clone();
let metadata = (!attachments.is_empty() || command_ref.is_some())