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
@@ -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());