Files
Skald-Circle/docs/session.md
T
2026-07-10 15:02:09 +01:00

44 KiB
Raw Blame History

Session & Message Handling

RunContext (approval policy, system prompt injection, file-write pre-authorization, working directory) is documented separately — see run-context.md.


ChatSessionHandler Fields

Field Type Purpose
session_id i64 DB session identifier
db Arc<SqlitePool> Persistent storage
llm_manager Arc<LlmManager> Resolves which LLM client to use
max_history_messages usize Max messages kept in context when compaction is disabled; ignored when compaction is configured
max_tool_rounds usize Max LLM rounds per turn before Exhausted
max_tool_result_chars Option<usize> When set, tool results from previous turns that exceed this char count are replaced with a placeholder in the LLM context. DB content is unchanged. See Tool Result Hiding.
agent_id String Agent owning this session (default: "main")
tools Arc<ToolRegistry> Built-in tools
mcp Arc<McpManager> MCP tools
approval Arc<ApprovalManager> Central approval service (rules + pending registry)
event_bus Arc<ChatEventBus> Publishes completed turns (user + assistant) to the in-process event bus
question_registry Arc<Mutex<HashMap<i64, oneshot::Sender<String>>>> Pending ask_user_clarification channels
processing Mutex<()> Prevents concurrent handle_message / resume_turn calls
current_cancel std::sync::Mutex<CancellationToken> Cancellation scope for the in-flight turn. A fresh token is minted per user message / resume and a clone is threaded by value through the whole recursive call tree; cancel() cancels the stored token. Never reset mid-turn → a /stop is sticky across sub-agent recursion

build_agent_config

Private helper called by both handle_message and resume_turn to avoid duplicating the LLM-resolution and tool-assembly logic.

  1. Load meta.json for the current agent_id (scope, strength).
  2. Resolve LLM client key via LlmManager::resolve(client_name, scope, strength).
  3. Build base_tool_defs: built-in tools + call_agent + update_scratchpad. MCP tools are no longer included here — they are resolved dynamically in all_tool_defs() each round based on active_mcp_grants.
  4. Load session MCP grants from session_mcp_grants DB → populate active_mcp_grants. If enabled_mcp_servers override is provided, merge those names in-memory without touching the DB.
  5. Inject activate_tools as an InterfaceTool (session-scoped, stack_id = None). Skipped if enabled_mcp_servers override is active.
  6. RunContext system prompt injection: read RunContext.extra_system_prompt() and append its result to extra_system_dynamic (the dynamic tail system message, injected after conversation history, not cached). If both the caller-provided extra_system_dynamic and the RunContext fragments are non-empty, they are joined with "\n\n".
  7. Return AgentRunConfig { ..., mcp: Arc<McpManager>, active_mcp_grants }.

handle_message Flow

  1. Acquire processing mutex (blocks if another message is being processed).
  2. Mint a fresh CancellationToken, store it in current_cancel, and thread a clone by value through run_agent_turn (and the sub-agent recursion).
  3. Memory context — call memory_manager.query_context(session_id, user_message) for all sessions (including cron and tic). If a string is returned it is stored as extra_system_dynamicnot merged into extra_system_context. It will be injected as a dynamic tail system message after the conversation history (see Context Building). Only the write path filters by is_interactive/is_ephemeral.
  4. Call build_agent_config(client_name, enabled_mcp_servers, extra_system_static, extra_system_dynamic, interface_tools)AgentRunConfig. This also calls memory_manager.tools() and stores them in AgentRunConfig::memory_tools.
  5. Get or create the active chat_sessions_stack frame.
  6. Check for orphaned user message (see below) and mark it failed if found.
  7. Append the user message to chat_history (with is_synthetic and optional metadata persisted via append_with_metadata); capture the returned user_message_id. metadata (type MessageMetadata in core-api) carries file attachments for web/mobile/Telegram messages; content stays the clean typed text. For non-synthetic messages, emit a UserMessage { message_id, content, attachments } event right after the append — the telnet-style echo that makes the bubble appear (clients never render the message optimistically).
  8. Call resume_pending_tools(stack_id, &config, &tx) — re-gates and executes any pending tool calls left from an interrupted session.
  9. Call run_agent_turn(stack_id, &config, &tx, pending_input) and await outcome. pending_input: Option<Arc<dyn PendingUserInput>> is the source's inbox handle for live mid-turn injectionSome for interactive web/mobile turns, None for cron/tic.
  10. On Final: send Done event (and Truncated if applicable); then publish two events to ChatEventBus — one User event (with is_synthetic from the caller) and one Assistant event (with all tool_calls collected during the turn).
  11. On Cancelled: send Error event ("interrupted by user"); return Err("Turn cancelled by user"). Background runners (cron, tickets) see the Err and record the job as "failed". The WS handler logs at INFO level (not ERROR) when it detects this error string, since the client already received the error event.
  12. On Exhausted: send Error event (tool round limit exceeded); return Err(...). Background runners (cron, tickets) see the Err and record the job as "failed". Interactive WS sessions already received the ServerEvent::Error; the returned Err is logged by the WS handler.

is_synthetic is a parameter of handle_message. It is true for TicManager ticks (system-generated messages injected as user turns), false for all real user input. Additionally, ChatHub::notification_consumer injects synthetic Assistant messages with is_synthetic = true containing the read_notification tool call and reasoning trace — these are not user turns, but share the same flag for UI filtering. The flag is persisted to chat_history.is_synthetic so that the UI history API (GET /api/sessions/:id) can filter those rows out on page reload — synthetic messages never appear in the conversation visible to the user. They are still included in the LLM context (via build_openai_messages) so the assistant can see what it previously said in response to a notification.

Session detail debug view

GET /api/sessions/:id returns the full session tree in a debug-friendly format. Unlike the live message API, this endpoint:

  • Includes synthetic user messages (marked is_synthetic: true in the JSON)
  • Includes reasoning_content on assistant / thinking messages
  • Returns session metadata (source, agent_id, is_interactive, is_ephemeral, created_at)

Response shape:

{
  "session": { "id": 42, "source": "tic", "agent_id": "main", "is_interactive": false, "is_ephemeral": true, "created_at": "…" },
  "messages": [
    { "kind": "user",      "content": "…", "is_synthetic": true,  "created_at": "…" },
    { "kind": "thinking",  "content": "…", "reasoning": "…|null", "created_at": "…", "input_tokens": N, "output_tokens": N },
    { "kind": "assistant", "content": "…", "reasoning": "…|null", "created_at": "…", "input_tokens": N, "output_tokens": N },
    { "kind": "tool",      "name": "…", "arguments": {}, "status": "done|error|pending", "result": "…" },
    { "kind": "agent",     "agent_id": "…", "depth": N },
    { "kind": "agent_end", "agent_id": "…", "depth": N }
  ]
}

The frontend <session-detail-page> renders this at hash #session/{id}. The detail page includes a Back button that calls history.back(). The page is not linked directly in the sidebar but is fully functional when the hash is set directly, or when navigated to from the TIC Sessions page.

Session list API

GET /api/sessions?source=tic&page=1&per_page=20 — paginated list of sessions, optionally filtered by source.

Response shape:

{
  "items": [
    { "id": 42, "source": "tic", "agent_id": "main", "is_ephemeral": true, "is_interactive": false,
      "created_at": "…", "message_count": 7, "last_message_at": "…" }
  ],
  "total": 100, "page": 1, "per_page": 20
}

The <tic-sessions-page> component renders this at hash #tic (linked from the sidebar under TIC Sessions). Each row is clickable and navigates to #session/{id}.


resume_turn Flow

Called by ChatHub::resume() (routed through the global event bus) when the client sends {"type":"resume"}, and by inject_async_result after an async task finishes. Continues without appending a new user message. It is not part of the normal synchronous sub-agent path (that is plain recursion in dispatch_sub_agent); resume_turn exists for app-restart recovery of an active child stack, async result injection, and the WS resume message.

  1. Acquire processing mutex.
  2. Mint a fresh CancellationToken (a resume is a new unit of work — it must not inherit a stale cancellation, but a /stop during the resume still cancels this token) and store it in current_cancel.
  3. Call build_agent_config(...)AgentRunConfig. 3b. reap_interrupted_parallel_batches — the linear cascade below assumes one active frame per depth, which an interrupted parallel sub-agent batch violates. Detect a batch by ≥2 active chat_sessions_stack frames sharing a depth (impossible for a linear stack), then — accepting the single-user tolerance for restart loss — fail each such frame's spawning tool call and terminate the frame, from the shallowest multi-frame depth downward. The parent is left with a fully-resolved tool-call set and the normal cascade resumes it. A lone interrupted sub-agent (one frame at its depth) is left untouched.
  4. Get the active chat_sessions_stack frame — if none exists, return immediately.
  5. Call resume_pending_tools(stack_id).
  6. Seed the cascade: if no pending tools were found AND the deepest active frame's last assistant message has no associated tool calls (pure-text final response), that frame's own turn is already complete. Two sub-cases:
    • Root frame (no parent_tool_call_id) → nothing to do, return immediately.
    • Child frame (has a parent_tool_call_id) → its result was produced but never propagated to the parent (e.g. the turn task died right after the child finished — see below). Seed current_outcome from the existing final message (its content) without re-running the LLM, and enter the cascade so the parent's tool call is completed and the parent continues. (Skipping this case unconditionally — as an earlier version did — left the parent wedged forever: tool call stuck running, child frame never terminated, main agent never resumed.) Otherwise (last assistant message does have tool calls, e.g. a task_completed injected asynchronously), call run_agent_turn(stack_id, …, None) — resume never does live user-message injection.
  7. Cascade loop: while the current stack has a parent_tool_call_id, complete/fail the parent's tool call, terminate the child stack, and run run_agent_turn on the parent stack. Repeat until reaching the root (depth = 0). Handles both restart recovery of an active child stack and the "completed-child never propagated" repair seeded in step 6.
  8. At root: same Final / Cancelled / Exhausted handling as handle_message.

resume_pending_tools

Called at the start of handle_message (and by the REST endpoint after a manual resolve). Finds any running/pending tool calls left from a previous interrupted session, re-runs them through the approval gate, executes approved ones, and rejects denied ones — so run_agent_turn sees complete history and can continue cleanly. Takes the turn's token so a /stop during resume cancels cleanly.

It shares the same collaborators as the live loop: run_approval_gate (so resume applies the RunContext fast-path and the auto-deny short-circuit identically — previously only the live loop did) and record_tool_outcome (so resume does not accumulate ToolCallEvents nor re-emit FileChanged, which are live-turn concerns — it passes None for both).

Rehydration = re-run from intent. Each pending row is (name, args, status); the live future was never serialized. The tool is reconstructed with the same build_execution(name, args) → ToolExecution used by the live loop and re-run from the start via drive_execution. This uniformly covers registry / memory / image / interface / MCP tools (previously only memory + registry were handled). cancelled/rejected rows are terminal and are not re-run.

restart is handled as a special case: it marks the call done in the DB before calling std::process::exit(-1).


AgentFlowSignal

AgentFlowSignal (src/core/session/handler/mod.rs) is a typed pub(super) enum used by internal dispatch methods to communicate control-flow outcomes through anyhow::Error without sentinel structs:

Variant Emitted by Handled in
QuestionChannelClosed dispatch_ask_user_clarification (WS dropped) llm_loop.rs → returns TurnOutcome::Cancelled; resume.rs → aborts resume

Dispatch checks it with a single downcast_ref::<AgentFlowSignal>().


run_agent_turn Inner Loop

Called recursively via Box::pin to support async recursion without stack overflow.

run_agent_turn is a thin round orchestrator: each round's real work is delegated to focused collaborators (each an impl ChatSessionHandler block in its own file under src/core/session/handler/), so the loop reads as a sequence of named steps rather than one large function. The collaborators are shared with resume_pending_tools, so a live turn and a resume gate/execute/record identically.

Collaborator File Responsibility
TurnEmitter emitter.rs Typed, fire-and-forget seam over the per-turn mpsc::Sender<ServerEvent> — one semantic method per event (tool_done, thinking, …). All turn events flow through it.
call_llm_roundRoundLlm llm_call.rs One LLM call for the round + automatic model fallback (retries, ModelFallback/LlmFailed, message rebuild on prompt_cache change). Mutates cur_name/cur_llm/messages in place.
handle_tool_callCallFlow llm_loop.rs Handles one tool call end-to-end (persist row → ToolStart → gate → restart → dispatch → record). Returns Continue or End(outcome).
effective_args dispatch.rs Applies the RunContext working directory to a call's args (relative path → absolute, inject workdir for execute_cmd).
run_approval_gateGateOutcome gate.rs The approval decision + human-approval flow (see Approval Gate).
execute_tool_callDispatchResult dispatch.rs Routes an approved call to the right executor (special non-cancellable paths + the unified cancellable ToolExecution path).
record_tool_outcomeRecordFlow outcome.rs Persists a tool outcome and emits ToolDone/ToolError/ToolCancelled.

Takes the per-turn token: &CancellationToken by value-clone from the caller, plus pending_input: Option<&Arc<dyn PendingUserInput>> (see Mid-turn injection). For each round (up to max_tool_rounds):

  1. Check token.is_cancelled() — return Cancelled immediately if set. 1b. Mid-turn injection: if pending_input is Some, drain_user() and append each queued message as its own user row + emit a UserMessage echo. These rows are read by build_openai_messages() in this same round, so the model sees them immediately. None for sub-agents / resume / non-interactive runners.
  2. build_openai_messages() — reconstruct full context from DB.
  3. call_llm_round(...) — one LLM call wrapped in tokio::select! against token.cancelled() (a /stop aborts the in-flight request → RoundLlm::CancelledCancelled), with automatic model fallback on retriable errors. Returns RoundLlm::{Turn, Cancelled, Failed}.
  4. On LlmTurn::Message — persist assistant message, return Final (with all tool_calls accumulated across rounds).
  5. On LlmTurn::ToolCalls — persist the assistant "thinking" message (emit Thinking if non-empty). If the response is a homogeneous batch≥2 calls that are all synchronous sub-agents (is_sync_sub_agent) — dispatch them concurrently via handle_sub_agent_batch (see Parallel sub-agent batches); otherwise fall back to the sequential loop below: for each call (checking token.is_cancelled() before each one) call handle_tool_call, which:
    • Records the tool call in chat_llm_tools (status: pending) and emits ToolStart (with original LLM-provided args, before WD injection).
    • Computes effective_argsworking directory injection: if RunContext.effective_working_dir() is set, resolve relative path args to absolute and inject workdir into execute_cmd args (if the LLM didn't already set one).
    • Runs run_approval_gate on effective_args (see Approval Gate). GateOutcome::Rejected → the gate already marked the row rejected and emitted ToolRejectedCallFlow::Continue (skip); GateOutcome::ChannelClosedCallFlow::End(Cancelled).
    • Handles restart inline (mark done, emit ToolDone, libc::_exit(-1)).
    • Dispatches via execute_tool_call. Special, non-cancellable paths return a plain Result<String>: sync sub-agent (execute_task mode=sync / execute_subtask) → dispatch_sub_agent (recursive, inline); update_scratchpad/write_todos; ask_user_clarification → emit AgentQuestion, await answer; task_completed stub. Everything else (built-in registry incl. execute_cmd, memory/image tools, MCP, interface tools) goes through the unified cancellable path: build_execution(name, args) → ToolExecution, driven by drive_execution(exec, token). See Tool execution lifecycle. If the clarification WS channel closed while awaiting an answer, execute_tool_call returns DispatchResult::AbortPending → the tool stays pending (not recorded) and the turn ends Cancelled so resume re-asks it.
    • Records the outcome via record_tool_outcome: CompletedToolDone, status done (+ FileChanged for file-write tools); FailedToolError, status failed; Cancelled (a /stop hit the tool mid-flight) → ToolCancelled, status cancelled, and handle_tool_call returns CallFlow::End(Cancelled). The execution's stop() was called (e.g. dropping the work future kills an execute_cmd child via kill_on_drop), so the tool aborts immediately instead of running to completion.
  6. Loop back — next round rebuilds context with tool results included.
  7. If all rounds exhausted: return Exhausted.

A sync sub-agent runs via dispatch_sub_agent, which awaits run_agent_turn recursively in the same task (same processing lock, same token clone) and returns the child's result as the parent tool call's result. Because parent and child share the token, a /stop that cancels a running child also stops the parent at its next check — no WaitingChild / task-spawn / resume cascade involved.

Parallel sub-agent batches

When a single assistant response emits ≥2 synchronous sub-agent calls and nothing else, handle_sub_agent_batch (in llm_loop.rs) runs them concurrently instead of one-by-one. It is a three-phase restructuring of the same seams handle_tool_call uses, so the sequential path is left untouched as the fallback for every other shape (a lone call, or any mix with regular/side-effecting tools).

  • Phase 1 (sequential, in call order): chat_llm_tools::append allocates each call's row id and emits ToolStart. The row id is what the LLM uses to reconstruct tool-result order (ORDER BY id ASC), so allocating them up front — before any concurrency — is what preserves ordering regardless of which sub-agent finishes first.
  • Phase 2 (concurrent, bounded): the approval gate + execute_tool_call (→ dispatch_sub_agent) for all calls run through futures::stream::…buffer_unordered(max_parallel_subagents) (default 4, config llm.max_parallel_subagents, 1 = sequential). Each future writes only to its own child stack frame + tool_call_id and borrows &self/config/token/tx — no shared mutable state between siblings. The shared cancellation token means a /stop (or one cancelled sibling) stops the others.
  • Phase 3 (sequential, in call order): record_tool_outcome persists each result and appends to all_tool_calls in the original call order, so completion order never leaks into history or the event stream.

Siblings share the session-scoped scratchpad blackboard: concurrent writes to the same key are last-writer-wins by design (write distinct keys to avoid clobbering). Restart recovery of an interrupted batch is handled by reap_interrupted_parallel_batches (see resume_turn Flow).

Mid-turn injection

A user can send a message while a turn is still running, and the agent picks it up at its next round boundary — without /stop and without waiting for the whole turn to finish.

  • run_agent_turn receives pending_input: Option<&Arc<dyn PendingUserInput>> (the source's inbox handle from ChatHub). It is Some only for the root interactive turn; sub-agents (dispatch_sub_agent), resume_turn, and non-interactive runners (cron, tic) pass None.
  • At the top of each round (step 1b above), the turn drains the inbox and appends each queued message as its own chat_history user row, then emits a UserMessage event (telnet-style echo — see frontend.md). The round boundary is the only safe ordering point: the previous round's assistant message + tool results are all persisted, so a trailing user row is well-ordered.
  • It does not interrupt the in-flight LLM call or tool, and does not reset the round budget. Messages that arrive after the turn's last boundary stay queued and seed the next turn.
  • MessageBuilder later merges consecutive non-failed user/agent rows into one role:user (see Context Building), so several injected messages read as one clean user turn for the LLM while the DB keeps each message distinct.
  • A /stop clears the inbox, so queued-but-not-yet-injected messages are dropped, never persisted, never echoed. See chat-hub.md for the inbox/consumer side.

Approval Gate

The gate is ApprovalManager.check(session_id, category, agent_id, source, tool_name, args)GateResult.

Evaluation order:

  1. Hardcoded exception: file-write tools targeting a path that starts with memory/Allow (always auto-approved).
  2. Rules from the approval_rules table, sorted by priority ASC (lower = evaluated first). First match wins.
  3. Session bypass (in-memory, not persisted): if the result would be Require and an active bypass exists for this session_id whose scope matches (All, Category, or McpServer), convert to Allow. Deny is never bypassed.
  4. No match → Allow (default-open policy).

Default rules (seeded at startup if the table is empty): execute_cmd, restart, write_file, edit_file, insert_at_line, replace_linesrequire

Session bypass is activated by the human (not the LLM) from the Agent Inbox UI or via the REST endpoint. Each bypass entry targets a BypassScope:

Scope What it covers
All Every tool regardless of category
Category(ToolCategory) Only tools with the given registered category (e.g. Filesystem, Shell)
McpServer(String) Only tools from the named MCP server (matched by the mcp__<server>__ prefix)

The bypass state lives in ApprovalManager::session_bypasses (Mutex<HashMap<i64, Vec<ApprovalBypass>>>). check() receives session_id, category, and tool_name. Expired entries are pruned lazily on each check() call. All entries for a session are cleared when cancel_for_session() is called (WS disconnect). The state is never persisted — it is reset on app restart.

run_approval_gate (gate.rs) wraps the whole decision + human-approval flow and returns a GateOutcome, so run_agent_turn and resume_pending_tools gate identically. It first calls ApprovalManager.check(...), then applies the RunContext fast-path (relax RequireAllow for a pre-authorized file read/write path; never overrides a Deny), then:

  • AllowGateOutcome::Proceed.
  • Deny → mark tool call rejected, emit ToolRejected, GateOutcome::Rejected.
  • Require:
    1. If the session has auto_deny_approvals set (headless runners that cannot answer, e.g. TIC), mark rejected + emit ToolRejectedGateOutcome::Rejected (no blocking).
    2. Otherwise mark the row pending, register a oneshot channel via ApprovalManager.register(...)(request_id, rx).
    3. Call emit_approval_event(em, request_id, tool_call_id, name, args) (emits through the TurnEmitter), which selects the event type:
      • file-write tools (write_file, edit_file, insert_at_line, replace_lines): read current file + compute predicted result concurrently → PendingWrite { old_content, new_content }. Falls back to ApprovalRequired if the diff cannot be computed.
      • execute_cmd: PendingWrite with path = "$ execute_cmd", new_content = "$ <command>".
      • restart: PendingWrite with restart description.
      • everything else: ApprovalRequired { tool_name, arguments }.
    4. Await rx:
      • ApprovedGateOutcome::Proceed.
      • Rejected { note } → mark tool call rejected with the reason, emit ToolRejectedGateOutcome::Rejected. The saved reason — including the user's justification — is surfaced to the LLM as the tool-result content on the next request (see MessageBuilder). Every reject surface (copilot WS, Agent Inbox, REST /sessions and /inbox, mobile, Telegram) passes the raw user note; the canonical message string is built in one place by ApprovalDecision::rejection_message(note)"User rejected this tool call. Reason: <note>" (or "User rejected this tool call." when the note is empty), so wording stays consistent and no surface-specific prefix leaks into the LLM context.
      • Channel closed (WS disconnected) → GateOutcome::ChannelClosed, which the caller maps to TurnOutcome::Cancelled (live) / aborts the resume.

MessageBuilder

build_openai_messages is now a thin wrapper that delegates to MessageBuilder (src/core/session/handler/message_builder.rs). MessageBuilder is a self-contained struct with no reference to ChatSessionHandler:

pub struct MessageBuilder {
    pub pool:                  Arc<SqlitePool>,
    pub session_id:            i64,
    pub mcp:                   Arc<McpManager>,
    pub datetime_config:       DatetimeConfig,
    pub max_history_messages:  usize,
    pub max_tool_result_chars: Option<usize>,
    pub compactor:             Option<Arc<ContextCompactor>>,
}

This allows the message-building logic to be tested in isolation with an in-memory SQLite database (no full ChatSessionHandler required). ChatSessionHandler::build_openai_messages constructs a MessageBuilder from its own fields and delegates.


Context Building

build_openai_messages (backed by MessageBuilder::build) assembles the message array in the following order, optimised for prefix KV caching:

1. Static system message

Contents: AGENT.md + inject_memory files + extra_system_static (e.g. Telegram format rules) + MCP list.

Runtime substitutions: after assembling the static content, MessageBuilder::build applies system_substitutions — each entry replaces the __KEY__ sentinel with the provided value. These sentinels originate from <!-- KEY --> directives in AGENT.md (resolved by agents::resolve_includes).

When cache_hints = true (Anthropic models via OpenRouter), the content is wrapped in a cache_control: ephemeral block so the provider caches it as a KV prefix. For all other providers this message is a plain string that never changes turn-to-turn, so the provider's own automatic prefix cache (if any) hits on it.

2. Scratchpad system message (if non-empty)

The session scratchpad emitted as a separate [system] message before the conversation. Kept isolated from the static block so a mid-turn update_scratchpad call only invalidates this small message, not the large cacheable prefix.

Async sub-tasks share the parent session's scratchpad: when a task is launched with kind='async', its handler is initialised with scratchpad_session_id = parent_session_id. All reads and writes via update_scratchpad are then scoped to the parent session instead of the task's own isolated session, so 5 parallel async tasks launched by the same parent all read/write the same shared pad.

3. Compaction summary system message (if present)

See Context Compaction.

4. Conversation history

chat_history for the stack. When compaction is disabled, the list is truncated to max_history_messages (oldest dropped first). When compaction is enabled, max_history_messages has no effect — the compactor owns the token budget and truncating by count would silently discard history that should be summarised instead. For a user/agent row that carries attachments in its metadata column, the builder appends an [SYSTEM INFO] block (core_api::message_meta::attachments_block, path-only) to that turn's content on the fly — the block is never persisted; content stays the clean typed text and the UI renders the same metadata.attachments as chips. Consecutive non-failed user/agent rows are coalesced into a single role:user message (their contents joined by blank lines, attachment blocks preserved) — for_stack already excludes failed rows. This is what keeps the LLM context clean when several messages were stored as distinct rows, e.g. injected back-to-back mid-turn (see Mid-turn injection). Each assistant entry with tool calls in chat_llm_tools is reconstructed with a tool_calls array and one tool result message per call. The tool-result content is derived from the call's terminal status:

Status LLM-visible tool content
done the saved result
failed Error: <result>
rejected the saved reason (e.g. User rejected this tool call. Reason: <note>) — the human's justification reaches the LLM verbatim
cancelled the saved note (a /stop cancellation)
pending / running (interrupted by a crash or lost connection) Error: tool call was interrupted (connection lost before user approval). Please retry the operation.

Tool result hiding (see below) is applied to results from previous turns.

5. Dynamic tail system message

Contains extra_system_dynamic (e.g. Honcho long-term memories, retrieved fresh each turn) followed by a date/time/OS/working-directory block:

  • Date/time — formatted in the effective timezone (the datetime.timezone config value if set, otherwise the OS timezone via iana-time-zone); the IANA name is shown alongside the offset, e.g. 2026-06-17T21:20:00+01:00 (Europe/Rome).
  • Operating system — type + version via os_info (e.g. Mac OS 15.5.0 [64-bit]), computed once and cached.
  • Working directory — the session's effective WD, followed by a note that filesystem tools and execute_cmd use it for relative paths (no need to cd).

Placed after the conversation so the stable prefix (messages 14) is never invalidated by per-turn changes. The model's recency-biased attention also ensures it reads fresh user context immediately before generating its response.

6. Tail reminder system message (if provided)

Short anti-drift reminder (e.g. Telegram HTML format rules) at the very end.


Tool Result Hiding

Controlled by max_tool_result_chars in config.yml (llm.max_tool_result_chars).

When set, build_openai_messages calls maybe_hide_tool_result for every tool result it reconstructs. The replacement happens only when all three conditions hold:

  1. The result belongs to a previous turn — i.e. the assistant message that produced it appears before the last user/agent message in the (truncated) history.
  2. max_tool_result_chars is Some(n).
  3. The result string exceeds n characters.

When all three are true, the content sent to the LLM is replaced with:

[Tool response for `<tool_name>` hidden: response was N chars, exceeding the L-char limit. Call the tool again if you need this information.]

What is never affected:

  • The database row — always retains the original content.
  • The frontend — always displays the full result.
  • Tool results from the current turn — always shown in full, regardless of size, so the LLM can work with them within the same turn.

Current-turn boundary detection: the last User or Agent role entry in the truncated history marks the start of the current turn. Any assistant message at a lower index is from a previous turn.

Scratchpad injection format

<scratchpad>
  <!-- Temporary notes shared by all agents in this session (including async sub-tasks). Not persisted across sessions. -->
  <note key="db_url">postgres://localhost/mydb</note>
  <note key="main_struct">src/session/handler/mod.rs</note>
</scratchpad>

Only injected when the session_scratchpad table has at least one row for the session. For async sub-tasks the session_id used here is the parent's (see above).


TurnOutcome Enum

Variant Meaning
Final { content, message_id, input_tokens, output_tokens, truncated, tool_calls } LLM produced a final text response; tool_calls carries all ToolCallEvents from all rounds
Cancelled The turn's CancellationToken was cancelled (/stop), or WS closed during approval. handle_message returns Ok(()).
Exhausted All max_tool_rounds used without a final message. handle_message returns Err(...) so background runners record the job as "failed".

Session Cancellation via System Bus

Forceful task termination (e.g. the kill-task API) goes through the system bus to avoid direct coupling between the HTTP layer and the session internals.

Flow:

  1. POST /api/cron/jobs/{id}/kill reads running_session_id from the DB and emits SystemEvent::SessionCancelled { session_id } on the system bus. Returns 202 immediately.
  2. A background subscriber started in Skald::new() receives the event and calls ChatSessionManager::cancel_session(session_id).
  3. cancel_session — operates only on handlers already in the active map (no side-effectful creation for an unknown session):
    • handler.cancel() — cancels the CancellationToken; LLM calls and execute_cmd unblock via tokio::select!.
    • handler.cancel_pending_approvals() — drops the oneshot::Sender for every pending approval of that session; approve_rx.await returns Err, which the loop interprets as TurnOutcome::Cancelled.
    • handler.cancel_pending_questions() — same for clarification channels; rx.await returns Err(QuestionChannelClosed), which also yields TurnOutcome::Cancelled.
  4. handle_message returns Err("Turn cancelled by user")run_job records the job run as "failed".

This means kill works correctly even when the task is blocked on ask_user_clarification or waiting for human approval — both unblock the moment cancel_session drops their sender channels.


Concurrency Constraint

Only one handle_message / resume_turn call can run per ChatSessionHandler at a time. The processing: Mutex<()> is held for the entire duration. A second call blocks until the first completes or is cancelled.

Note that callers don't reach handle_message directly: ChatHub serializes user messages per source through a single-consumer inbox before the processing lock, and messages that arrive during an in-flight turn are injected into that turn at a round boundary (not queued as a separate turn). So in practice the processing lock is rarely contended for interactive sources — see chat-hub.md.

Synchronous sub-agents run inline in the same task as the parent (plain recursion in dispatch_sub_agent), so the single processing lock covers the whole parent+child tree — one user message is one logical critical section. A parallel sub-agent batch still runs within that one critical section and one task: handle_sub_agent_batch drives the concurrent children on the current task's async runtime (via buffer_unordered, not tokio::spawn), so they share the same processing guard and cancellation token — the concurrency is between the children, not between top-level turns. (Asynchronous tasks — execute_task mode=async — are a separate mechanism: a new ephemeral session driven by the cron runner, whose result is later injected via inject_async_resultresume_turn.)


Orphaned Message Handling

If the last message in history has role = User or role = Agent (no following assistant message), the previous turn was cancelled before the LLM responded. That message is marked status = failed and excluded from the context sent to the LLM, preventing user→assistant alternation errors.


AgentRunConfig

Built once per handle_message call and passed by reference through the entire agent/sub-agent recursion.

Field Purpose
agent_id ID of the current agent
client_name Resolved LLM client key
depth Recursion depth: 0 = root, 1+ = sub-agent
base_tool_defs Built-in tool definitions only (no MCP — those come from all_tool_defs() dynamically)
extra_system Optional extra system context (set to None for sub-agents)
system_substitutions HashMap<String, String> — named substitutions applied to the system prompt at build time. Each entry replaces __KEY__ sentinels in the prompt text.
interface_tools Interface-specific tools. For sub-agents contains only activate_tools; all other interface tools are dropped
memory_tools Memory backend tools (inherited by sub-agents)
mcp Arc<McpManager> — used by all_tool_defs() to resolve MCP tools dynamically
active_mcp_grants Arc<RwLock<HashSet<String>>> — granted tool groups. Holds MCP server names and/or the reserved keyword "config" (which unlocks config_tool_defs). Re-read on every round so activate_tools in round N makes tools visible in round N+1. Root: session-scoped (from session_mcp_grants DB). Sub-agents: stack-scoped (from stack_mcp_grants DB), starts empty
config_tool_defs Vec<Value> — built-in Config-category tool defs (the lazy config group). Appended by all_tool_defs() only when active_mcp_grants contains "config". Pre-filtered (interactive-only / approval) by build_agent_config

all_tool_defs() — dynamic group resolution

Called on every LLM round. Returns base_tool_defs + MCP tools for currently-granted servers (re-queried from McpManager using active_mcp_grants) + config_tool_defs when the config group is granted + memory tools + interface tools.

This means that calling activate_tools in round N makes those tools available to the LLM starting from round N+1 of the same turn — no cross-turn delay.

Uniqueness is guaranteed by construction, not by a dedup pass: built-in tools have distinct names by registry construction, MCP tools are namespaced mcp__{server}__{tool}, and sub-agent inheritance cannot re-introduce a name because for_sub_agent() strips the per-level augmentations it re-derives (see below).

for_sub_agent()

Derives a child config: inherits base_tool_defs (after filtering), memory_tools, and mcp; starts with empty active_mcp_grants; clears interface_tools; increments depth.

It drops two categories from the inherited base_tool_defs:

  • root_agent_only tools (registry flag) — never exposed to sub-agents.
  • Per-level augmentations that the config builders re-derive for every agent: ask_user_clarification and execute_subtask. Stripping them here makes dispatch_sub_agent the single owner of sub-agent augmentation, so a name can never be both inherited and re-added — the OpenAI-compat APIs reject non-unique tool names with HTTP 400, and this eliminates that failure mode structurally (no dedup pass needed).

dispatch_sub_agent then:

  1. Replaces the empty active_mcp_grants arc with one pre-populated from stack_mcp_grants DB (restart recovery).
  2. Appends sub_agents_only tools, ask_user_clarification, and (below the depth limit) execute_subtask to base_tool_defs — each present exactly once, since the inherited copy was stripped by for_sub_agent().
  3. Injects activate_tools (stack-scoped, stack_id = Some(child.id)) as the only interface tool.

ask_user_clarification Flow

Available to every agent except hidden system agents (e.g. TIC), at any depth.

  1. An agent calls ask_user_clarification(question).
  2. run_agent_turn intercepts it before ToolRegistry dispatch.
  3. A oneshot channel is registered in question_registry keyed by request_id.
  4. AgentQuestion { request_id, question } event is emitted to the frontend.
  5. Execution is suspended until the client sends {"type":"answer_question","request_id":<N>,"answer":"..."}.
  6. resolve_question() unblocks the channel; the answer is returned as the tool result.
  7. On WS disconnect: cancel_pending_questions() drops all senders, causing the await to return Err, which propagates as a tool error.

WS Resume Event Routing

When the client sends {"type":"resume"}, the WS handler calls ChatHub::resume(&source) which:

  1. Finds the session handler for source.
  2. Spawns a task running handler.resume_turn(...) with an mpsc sender.
  3. Bridges every event from that sender to the global broadcast bus (tagged with the session's source).

All WS connections for the same source (including newly reconnected ones) receive the events via their global bus subscription. This avoids the previous design where events went to a local mpsc channel and were silently lost if the client reconnected while resume_turn was in flight.

Running-state on (re)connect

A turn runs on a detached task and survives a page reload (closing the WS just returns from the socket loop — it does not cancel the turn). To let a reloaded client restore its SEND→STOP button, the WS handler — right after subscribing to the global bus — sends a TurnRunning { running } event to that socket, where running = ChatSessionHandler::is_processing() (a try_lock on the processing mutex, held for the whole turn). Because the send happens after subscribing, a turn that finishes immediately after still delivers its Done via the bus, which resets the client's state. The client also flips to "running" on any live streaming event (thinking / tool_start / agent_start / pending_write / approval_required) as a fallback. Note: with synchronous sub-agents now running recursively, the processing lock is held continuously for the whole parent+child tree, so is_processing() is a reliable signal.


When to Update This File

  • needs_approval() rules change (new tool added, path exemption modified)
  • The tool-calling loop gains new behavior (new event type, new cancellation path)
  • build_openai_messages changes (new context injected, truncation logic modified)
  • AgentRunConfig fields change
  • build_agent_config changes (new default tool added, resolution logic modified)