feat(users): UserManager with per-user SQLCipher, and extract skald-core crate

Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.

## UserManager + per-user encryption (§9/§11)

New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.

New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).

- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
  <0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
  the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
  the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
  `create_owner_tables` (one owner's content, identical in every file). No FK in
  the owner bucket may reach the registry — enforced by a standalone test.
  Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
  key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
  so a crash leaves an orphan file, never a user without a database. `open_db`
  never creates: a missing file is an error, not a silent empty database.

Not consumed yet: no login, call sites still use the shared system.db pool.

## Extract crates/skald-core

The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:

- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
  (sibling of `http_router`), so the core no longer downcasts to
  `MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
  teardown-and-respawn; the core defaults to the supervisor exit code. The core
  loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
  only emits tracing events.

All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
2026-07-10 16:48:51 +01:00
parent 38494a85a9
commit 178a38357e
173 changed files with 2650 additions and 1106 deletions
@@ -0,0 +1,329 @@
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::{chat_history, chat_llm_tools, chat_sessions_stack, scratchpad, stack_mcp_grants};
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.scope.as_deref(),
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?;
let persisted_grants = stack_mcp_grants::list_for_stack(pool, child.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(target_id.to_string(), resolved_client.clone());
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());
// Let the sub-agent dispatch a further sub-agent (e.g. tech-lead → architect/engineer).
// `execute_subtask` is intercepted in `run_agent_turn` and routed back here. Only expose it
// while the child can still recurse — at the depth limit `dispatch_sub_agent` would reject it.
if new_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");
let group_rules = crate::db::approval_rules::list_for_group(
pool, Some(gid),
).await.unwrap_or_default();
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 pool_clone = Arc::clone(&self.db);
let session_id = self.session_id;
let stack_id = child.id;
let mcp_clone = Arc::clone(&self.mcp);
let grants_clone = Arc::clone(&active_mcp_grants);
let activate_tool = crate::tools::activate_tools::ActivateTools {
pool: pool_clone,
session_id,
stack_id: Some(stack_id),
mcp: mcp_clone,
active_mcp_grants: grants_clone,
};
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}"))?
})
}),
});
}
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) = stack_mcp_grants::delete_for_stack(pool, child.id).await {
tracing::warn!(stack_id = child.id, error = %e, "dispatch_sub_agent: failed to delete stack MCP grants");
}
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
}
/// 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))
}
}
@@ -0,0 +1,115 @@
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 if tool_name == tn::RESTART {
em.pending_write(
request_id, tool_call_id,
"$ restart".to_string(),
None,
"Riavvia il processo (exit -1 → supervisor ricompila e rilancia)".to_string(),
).await;
} else {
em.approval_required(request_id, tool_call_id, tool_name.to_string(), arguments.clone()).await;
}
}
/// Reads the current content of a file from disk (for diff generation in PendingWrite events).
pub(super) async fn read_current_content(&self, path: &str) -> Option<String> {
let abs = crate::tools::fs::resolve(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,
}
}
}
@@ -0,0 +1,205 @@
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use serde_json::Value;
use crate::tools::tool_names as tn;
use super::{ChatSessionHandler, update_scratchpad_tool_def, write_todos_tool_def};
use super::interface_tools::{AgentRunConfig, InterfaceTool, ToolFuture};
/// Returns an `activate_tools` OpenAI tool definition.
pub(super) fn activate_tools_tool_def() -> Value {
serde_json::json!({
"type": "function",
"function": {
"name": tn::ACTIVATE_TOOLS,
"description": "Activate one or more tool groups so their tools become available. \
A group is either an MCP server name (see the MCP list) or the reserved \
keyword `config`, which loads all system-configuration tools (managing \
MCP servers, plugins, scheduled cron jobs, and secrets). \
Pass an array of group names (e.g. [\"gmail\", \"config\"]). \
Once activated, the tools are available from the next tool-call round onward.",
"parameters": {
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": { "type": "string" },
"description": "Tool groups to activate: MCP server names and/or the reserved \
keyword \"config\" (e.g. [\"gmail\", \"config\"])."
}
},
"required": ["groups"]
}
}
})
}
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`.
pub(super) async fn build_agent_config(
&self,
client_name: Option<String>,
extra_system: Option<String>,
extra_system_dynamic: Option<String>,
mut interface_tools: Vec<InterfaceTool>,
system_substitutions: HashMap<String, String>,
) -> anyhow::Result<AgentRunConfig> {
let meta = crate::agents::load_meta(&self.agent_id).ok();
let (key, _) = self.llm_manager.resolve(
client_name.as_deref(),
meta.as_ref().and_then(|m| m.scope.as_deref()),
meta.as_ref().and_then(|m| m.strength),
).await?;
let mut base_tool_defs = self.tools.openai_definitions_excluding_config();
// Config-category built-ins are hidden from the always-on set and lazy-loaded
// via `activate_tools(["config"])`. They go through the same interactive-only /
// approval-visibility filters as base_tool_defs below, then ride in AgentRunConfig
// as `config_tool_defs` (appended by `all_tool_defs()` only when granted).
let mut config_tool_defs = self.tools.openai_definitions_config_only();
base_tool_defs.push(update_scratchpad_tool_def());
base_tool_defs.push(write_todos_tool_def());
// `ask_user_clarification` is available to every agent except hidden `system`
// agents (e.g. TIC), which have no user-facing channel. Interactive sessions
// emit AgentQuestion inline (plus the Inbox); background sessions rely on the
// Inbox alone.
let is_system = meta
.as_ref()
.map(|m| m.agent_type == crate::agents::AgentType::System)
.unwrap_or(false);
if !is_system {
base_tool_defs.push(super::ask_user_clarification_tool_def());
}
// Background sessions (cron, tic): remove tools that only make sense in
// interactive sessions (e.g. read_notification, which is synthetically
// injected by ChatHub and returns EMPTY if called directly).
if !self.is_interactive {
let interactive_only = self.tools.interactive_only_names();
let keep = |def: &Value| {
let name = def["function"]["name"].as_str().unwrap_or("");
!interactive_only.iter().any(|n| n == name)
};
base_tool_defs.retain(|d| keep(d));
config_tool_defs.retain(|d| keep(d));
}
// Interactive sessions get read_agent_result so the LLM can poll for async
// task status. The real delivery happens via inject_async_result (synthetic msg).
if self.is_interactive {
base_tool_defs.push(serde_json::json!({
"type": "function",
"function": {
"name": "task_completed",
"description": "Invoked BY THE SYSTEM (not by you) when an async task finishes, \
delivering its result. You will never need to call this yourself — \
the system calls it automatically when execute_task(mode=async) completes.",
"parameters": {
"type": "object",
"required": ["task_id"],
"properties": {
"task_id": { "type": "integer", "description": "The completed task id" }
}
}
}
}));
}
// Approval-rules visibility filter: hide tools whose effective action for
// this session's permission group is Deny. Rules are loaded once and applied
// synchronously; the execution-time gate in ApprovalManager remains as a
// second layer of enforcement.
{
let group_id = self.tool_group_id().await;
let gid = group_id.as_deref().unwrap_or("default");
let group_rules = crate::db::approval_rules::list_for_group(
&self.db, Some(gid),
).await.unwrap_or_default();
let visible = |def: &Value| {
let name = def["function"]["name"].as_str().unwrap_or("");
self.approval.is_tool_visible(&group_rules, name)
};
base_tool_defs.retain(|d| visible(d));
config_tool_defs.retain(|d| visible(d));
}
// ── Tool-group grant initialisation ─────────────────────────────────────
//
// Load persisted session grants from DB (MCP server names and/or the reserved
// `config` keyword), then inject `activate_tools` so the LLM can activate
// additional groups on demand.
let persisted = crate::db::session_mcp_grants::list_for_session(
&self.db, self.session_id,
).await.unwrap_or_default();
let active_mcp_grants: Arc<RwLock<HashSet<String>>> =
Arc::new(RwLock::new(persisted.into_iter().collect()));
{
let pool_clone = Arc::clone(&self.db);
let session_id = self.session_id;
let mcp_clone = Arc::clone(&self.mcp);
let grants_clone = Arc::clone(&active_mcp_grants);
let activate_tool = crate::tools::activate_tools::ActivateTools {
pool: pool_clone,
session_id,
stack_id: None,
mcp: mcp_clone,
active_mcp_grants: grants_clone,
};
let activate_tool = Arc::new(activate_tool);
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}"))?
})
}),
});
}
// ── End tool-group grant initialisation ─────────────────────────────────
// Append RunContext system prompt fragments to the dynamic tail (not cached).
let extra_system_dynamic = {
let rc = self.run_context.read().await;
let injected = rc.as_ref().and_then(|r| r.extra_system_prompt());
match (extra_system_dynamic, injected) {
(Some(e), Some(i)) => Some(format!("{e}\n\n{i}")),
(Some(e), None) => Some(e),
(None, Some(i)) => Some(i),
(None, None) => None,
}
};
let root_only_tool_names: Vec<String> = self.tools.root_agent_only_names();
let memory_tools = self.memory_manager.tools().await;
let image_tools = Arc::clone(&self.image_generator_manager).tools().await;
Ok(AgentRunConfig {
agent_id: self.agent_id.clone(),
client_name: key,
depth: 0,
base_tool_defs,
config_tool_defs,
extra_system,
extra_system_dynamic,
tail_reminder: None,
system_substitutions,
interface_tools,
memory_tools,
image_tools,
mcp: Arc::clone(&self.mcp),
active_mcp_grants,
root_only_tool_names,
})
}
}
@@ -0,0 +1,149 @@
//! Working-directory argument rewriting and the per-tool-call dispatch router.
//!
//! Extracted from `run_agent_turn`: `effective_args` applies the RunContext working
//! directory to a call's arguments, and `execute_tool_call` routes an approved call
//! to the right executor (special non-cancellable paths + the unified cancellable
//! `ToolExecution` path).
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, tool_names as tn, ExecutionOutcome, ToolResult};
use super::ChatSessionHandler;
use super::interface_tools::AgentRunConfig;
/// 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.
Outcome(ExecutionOutcome),
/// 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 {
/// Applies the RunContext working directory to a tool call's arguments:
/// resolves a relative `path` against the effective WD and injects `workdir`
/// for `execute_cmd`. The caller keeps the original `arguments` for the
/// `ToolStart` event / DB logging; this returns the copy used for execution.
pub(super) async fn effective_args(&self, tool_name: &str, args: &Value) -> Value {
let mut effective = args.clone();
let wd = self.run_context.read().await
.as_ref()
.map(|rc| rc.effective_working_dir());
if let Some(wd) = wd {
if let Some(path) = effective["path"].as_str()
&& !std::path::Path::new(path).is_absolute()
{
effective["path"] = Value::String(wd.join(path).to_string_lossy().into_owned());
}
if tool_name == tn::EXECUTE_CMD && effective.get("workdir").is_none() {
effective["workdir"] = Value::String(wd.to_string_lossy().into_owned());
}
}
effective
}
/// 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.
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}")),
}
};
DispatchResult::Outcome(outcome)
}
}
/// 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"})));
}
}
@@ -0,0 +1,155 @@
//! 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>) {
self.emit(ServerEvent::Thinking { message_id, content, input_tokens, output_tokens }).await;
}
/// 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>) {
self.emit(ServerEvent::Done { message_id, stack_id, content, input_tokens, output_tokens }).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,
label_short: String,
label_full: String,
path: Option<String>,
) {
self.emit(ServerEvent::ToolStart {
tool_call_id, message_id, name, arguments, label_short, label_full, path,
}).await;
}
pub(super) async fn tool_done(&self, tool_call_id: i64, result: String, result_type: String) {
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type }).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;
}
}
@@ -0,0 +1,138 @@
//! 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)
}
}
}
}
}
}
@@ -0,0 +1,168 @@
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use serde_json::Value;
use crate::mcp::McpManager;
use crate::tools::Tool;
use crate::tools::tool_names as tn;
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`.
/// 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`.
pub struct AgentRunConfig {
pub agent_id: String,
pub client_name: String,
/// Recursion depth: 0 = root agent, 1+ = sub-agent.
pub depth: i64,
/// Global tool definitions (built-in tools only, no MCP, **no `Config` category**).
/// MCP tools and the `Config` group are included dynamically in `all_tool_defs()`
/// based on `active_mcp_grants`.
pub base_tool_defs: Vec<Value>,
/// Definitions of the built-in `Config`-category tools (the lazy `config` group).
/// Appended by `all_tool_defs()` only when `active_mcp_grants` contains `"config"`.
/// Already filtered (interactive-only / approval visibility) by the builder.
pub config_tool_defs: Vec<Value>,
/// Static extra context injected into the first (cacheable) system message.
/// Example: Telegram HTML format instructions. Should never contain
/// per-turn data (timestamps, user-specific state) so the cached prefix
/// remains byte-identical across turns.
pub extra_system: Option<String>,
/// Dynamic extra context injected as a separate system message AFTER the
/// conversation history, just before the LLM generates its response.
/// Example: Honcho long-term memory retrieved fresh every turn.
/// Placing it at the tail keeps the stable prefix maximally cacheable
/// while giving the model fresh user context at generation time.
pub extra_system_dynamic: Option<String>,
/// Short reminder injected as a trailing `system` message in the message list.
pub tail_reminder: Option<String>,
/// Named substitutions applied to the agent's system prompt at build time.
/// Each entry replaces `__KEY__` sentinels produced by `agents::resolve_includes`.
pub system_substitutions: HashMap<String, String>,
/// Interface-specific tools.
/// For sub-agents this contains only `activate_tools`; all others are dropped.
pub interface_tools: Vec<InterfaceTool>,
/// Tools provided by the active memory backend (e.g. `memory_query`).
pub memory_tools: Vec<Arc<dyn Tool>>,
/// Image generation tools — present only when at least one provider is registered.
pub image_tools: Vec<Arc<dyn Tool>>,
/// MCP manager — used by `all_tool_defs()` to resolve which tools to include.
pub mcp: Arc<McpManager>,
/// Set of MCP server names currently granted (activated) for this agent run.
///
/// - Root agents: pre-populated from `session_mcp_grants` DB at config-build time;
/// updated in-place by `activate_tools`.
/// - Sub-agents: starts empty; populated by `activate_tools` (stack-scoped, no
/// session leak); deleted from DB when the stack frame terminates.
///
/// May also contain the reserved keyword `"config"`, which unlocks the built-in
/// `Config`-category tools (`config_tool_defs`) rather than an MCP server.
///
/// `all_tool_defs()` re-reads this set on every call, so tools activated via
/// `activate_tools` in round N are available in round N+1 within the same turn.
pub active_mcp_grants: Arc<RwLock<HashSet<String>>>,
/// Tool names that are restricted to the root agent (depth == 0).
/// Filtered out when deriving a sub-agent config via `for_sub_agent()`.
pub root_only_tool_names: Vec<String>,
}
impl AgentRunConfig {
/// Full tool list sent to the LLM on each round:
/// base tools + MCP tools for granted servers (dynamic) + `config` group (if granted)
/// + memory tools + interface tools.
///
/// Dynamic groups are re-queried every call so that an `activate_tools` call in
/// round N makes the tools visible in round N+1 without rebuilding the whole config.
pub fn all_tool_defs(&self) -> Vec<Value> {
let mut defs = self.base_tool_defs.clone();
// Dynamic groups: read the currently-granted set (MCP server names + `config`).
let granted: HashSet<String> = self.active_mcp_grants
.read()
.map(|g| g.clone())
.unwrap_or_default();
// MCP servers: include tools for the granted server names.
let servers: Vec<String> = granted.iter()
.filter(|n| n.as_str() != crate::tools::tool_names::CONFIG_GROUP)
.cloned()
.collect();
if !servers.is_empty() {
defs.extend(
self.mcp.tools_for(&servers)
.iter()
.map(|t| t.to_openai_definition()),
);
}
// `config` group: include the built-in Config-category tools on demand.
if granted.contains(crate::tools::tool_names::CONFIG_GROUP) {
defs.extend(self.config_tool_defs.iter().cloned());
}
defs.extend(self.memory_tools.iter().map(|t| t.openai_definition()));
defs.extend(self.image_tools.iter().map(|t| t.openai_definition()));
defs.extend(self.interface_tools.iter().map(|t| t.definition.clone()));
defs
}
/// Derives a config for a sub-agent:
/// - Inherits base tools, memory tools, and MCP manager.
/// - Starts with **empty** `active_mcp_grants` (sub-agents activate what they need).
/// - Drops all interface tools (caller re-injects `activate_tools` explicitly).
/// - Increments depth.
pub fn for_sub_agent(&self, agent_id: String, client_name: String) -> Self {
let root_only = |defs: &mut Vec<Value>| {
defs.retain(|def| {
let name = def["function"]["name"].as_str().unwrap_or("");
!self.root_only_tool_names.iter().any(|n| n == name)
});
};
let mut defs = self.base_tool_defs.clone();
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
// 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`
// is the single owner of sub-agent augmentation and duplication is
// structurally impossible — no dedup pass needed anywhere.
{
const RE_DERIVED: &[&str] = &[tn::ASK_USER_CLARIFICATION, tn::EXECUTE_SUBTASK];
defs.retain(|d| {
let name = d["function"]["name"].as_str().unwrap_or("");
!RE_DERIVED.contains(&name)
});
}
// Inherit the (already filtered) `config` group, dropping any root-only tool.
let mut config_defs = self.config_tool_defs.clone();
root_only(&mut config_defs);
Self {
agent_id,
client_name,
depth: self.depth + 1,
base_tool_defs: defs,
config_tool_defs: config_defs,
extra_system: None,
extra_system_dynamic: None,
tail_reminder: None,
system_substitutions: HashMap::new(),
interface_tools: vec![],
memory_tools: self.memory_tools.clone(),
image_tools: self.image_tools.clone(),
mcp: Arc::clone(&self.mcp),
active_mcp_grants: Arc::new(RwLock::new(HashSet::new())),
root_only_tool_names: self.root_only_tool_names.clone(),
}
}
}
@@ -0,0 +1,138 @@
//! 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.
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use tracing::{error, warn};
use crate::chatbot::{ChatOptions, LlmTurn};
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).
Turn(LlmTurn),
/// 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>,
tool_defs: &[Value],
req_scope: Option<&str>,
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 {
let options = ChatOptions {
model: cur_llm.model.clone(),
max_tokens: None,
temperature: None,
session_id: Some(self.session_id),
stack_id: Some(stack_id),
};
// 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 call_result = tokio::select! {
_ = token.cancelled() => return RoundLlm::Cancelled,
r = client.chat_with_tools(messages.as_slice(), tool_defs, &options) => r,
};
let e = match call_result {
Ok(t) => {
self.llm_manager.mark_success(cur_name).await;
return RoundLlm::Turn(t);
}
Err(e) => e,
};
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
&& is_retriable_llm_error(&e);
if !can_fallback {
em.llm_failed(tried_this_round.clone(), e.to_string()).await;
return RoundLlm::Failed(e);
}
let excluded: Vec<&str> = tried_this_round.iter().map(String::as_str).collect();
match self.llm_manager.select_excluding(&excluded, req_scope, 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).
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,
).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);
}
}
}
}
}
/// Whether an LLM error is worth retrying on a different model.
fn is_retriable_llm_error(e: &anyhow::Error) -> bool {
let msg = e.to_string().to_lowercase();
// Never retry client errors — the request itself is malformed or unauthorized.
// 400 is excluded: some providers reject valid requests that others accept
// (e.g. DeepSeek requires reasoning_content echo, OpenAI does not), so
// retrying on a different model can succeed.
for code in ["401", "403", "404", "422"] {
if msg.contains(code) {
return false;
}
}
true
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or(s).to_string()
}
@@ -0,0 +1,431 @@
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, trace};
use crate::tools::tool_names as tn;
use crate::chat_event_bus::ToolCallEvent;
use crate::chatbot::{LlmTurn, ToolCall};
use crate::db::{chat_history, chat_llm_tools};
use crate::events::ServerEvent;
use crate::tools::{
ExecutionOutcome, SimpleExecution, 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. `effective` is the
/// working-dir-resolved args used for recording (FileChanged / logging).
Done { effective: 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))?;
// Scope/strength needed for fallback re-selection.
let meta = crate::agents::load_meta(&config.agent_id).ok();
let req_scope = meta.as_ref().and_then(|m| m.scope.as_deref()).map(str::to_string);
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.
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).await?;
let tool_defs = config.all_tool_defs();
// 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, &tool_defs,
req_scope.as_deref(), 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 {
LlmTurn::Message(resp) => {
let message_id = chat_history::append(
pool, stack_id, &chat_history::Role::Assistant, &resp.content, false,
resp.reasoning_content.as_deref(),
).await?;
if let (Some(i), Some(o)) = (resp.input_tokens, resp.output_tokens) {
chat_history::set_usage(pool, message_id, i, o, 0, resp.cost).await?;
}
return Ok(TurnOutcome::Final {
content: resp.content,
message_id,
input_tokens: resp.input_tokens,
output_tokens: resp.output_tokens,
truncated: resp.truncated,
tool_calls: all_tool_calls,
});
}
LlmTurn::ToolCalls { content: assistant_text, calls, input_tokens, output_tokens, reasoning_content, cost, .. } => {
let message_id = chat_history::append(
pool, stack_id, &chat_history::Role::Assistant, &assistant_text, false,
reasoning_content.as_deref(),
).await?;
if let (Some(i), Some(o)) = (input_tokens, output_tokens) {
chat_history::set_usage(pool, message_id, i, o, 0, cost).await?;
}
if !assistant_text.trim().is_empty() || input_tokens.is_some() {
em.thinking(message_id, assistant_text, input_tokens, output_tokens).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`]
/// 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?;
em.tool_start(
tool_call_id, message_id,
call.name.clone(),
call.arguments.clone(),
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;
// Resolve relative paths / inject workdir from the RunContext.
// `call.arguments` (originals) were used for the ToolStart event and DB
// logging above; `effective_args` is used from here on.
let effective_args = self.effective_args(&call.name, &call.arguments).await;
match self.run_approval_gate(tool_call_id, &call.name, &effective_args, &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");
// `restart` calls process::exit — mark the call done in the DB first so it
// doesn't reappear as `pending` after the supervisor relaunches.
if call.name == tn::RESTART {
info!(session_id = self.session_id, tool_call_id, "restart approved — marking done then exiting");
chat_llm_tools::complete(pool, tool_call_id, "Riavvio avviato.", "string").await?;
em.tool_done(tool_call_id, "Riavvio avviato.".to_string(), "string".to_string()).await;
// Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in
// whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
// instead of 255 — breaking the run.sh restart supervisor).
unsafe { libc::_exit(-1) }
}
// 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 = match self.execute_tool_call(
stack_id, config, tool_call_id, &call.name, &effective_args, token, tx,
).await {
DispatchResult::Outcome(o) => o,
DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
};
match self.record_tool_outcome(
tool_call_id, &call.name, &effective_args, outcome, 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?;
em.tool_start(
tool_call_id, message_id,
call.name.clone(),
call.arguments.clone(),
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 effective = self.effective_args(&name, &arguments).await;
let gated = match self.run_approval_gate(
tool_call_id, &name, &effective, &config.agent_id, em,
).await {
Ok(GateOutcome::Proceed) => match self.execute_tool_call(
stack_id, config, tool_call_id, &name, &effective, token, tx,
).await {
DispatchResult::Outcome(outcome) => Ok(GatedExec::Done { effective, 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 { effective, outcome } => {
match self.record_tool_outcome(
*tool_call_id, &call.name, &effective, outcome, 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) }),
)));
}
// Memory + image tools (registered ad-hoc on the config).
if let Some(tool) = config.memory_tools.iter().find(|t| t.name() == name) {
return Some(tool.run(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, args)
}
}
@@ -0,0 +1,567 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::compactor::{ContextCompactor, SUMMARY_PREFIX};
use crate::config::DatetimeConfig;
use crate::db::{chat_history, chat_llm_tools, chat_summaries};
use crate::mcp::McpManager;
use crate::tools::tool_names as tn;
/// Registry of installed skills, relative to Skald's process cwd. Injected into agents
/// that have `inject_skills` enabled (the default).
const SKILLS_INDEX_PATH: &str = "skills/index.md";
/// OS description (type + version), computed once — it does not change at runtime.
fn os_description() -> &'static str {
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
OS.get_or_init(|| os_info::get().to_string())
}
/// System IANA timezone name (e.g. `Europe/Rome`), computed once. `None` if it can't
/// be determined.
fn system_timezone() -> Option<&'static str> {
static TZ: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
TZ.get_or_init(|| iana_time_zone::get_timezone().ok()).as_deref()
}
/// Pure service that builds the OpenAI-format message array for one LLM round.
///
/// Extracting this from `ChatSessionHandler` allows the builder to be constructed
/// and called in isolation (e.g. in integration tests with an in-memory SQLite DB)
/// without needing the full handler and all its dependencies.
pub struct MessageBuilder {
pub pool: Arc<SqlitePool>,
pub session_id: i64,
pub mcp: Arc<McpManager>,
pub datetime_config: DatetimeConfig,
pub max_history_messages: usize,
pub max_tool_result_chars: Option<usize>,
pub compactor: Option<Arc<ContextCompactor>>,
/// Effective working directory for this session. When set (e.g. from a project
/// RunContext), it overrides the process cwd in the date/time/OS/WD tail block.
pub working_directory: Option<std::path::PathBuf>,
}
impl MessageBuilder {
/// Builds a raw OpenAI-format message array from the persisted history,
/// reconstructing assistant tool-call entries and tool-result entries from
/// the `chat_llm_tools` table.
///
/// `active_mcp_grants` is the set of MCP server names currently granted for
/// this session. It is used to build the compact MCP availability list injected
/// into the system prompt so the LLM knows which servers it can activate.
///
/// ## Message order (optimised for prefix KV caching)
///
/// ```text
/// 1. [system] Static content — AGENT.md + memory files + extra_system_static + MCP list
/// Tagged cache_control:ephemeral when cache_hints=true (Anthropic via OpenRouter).
///
/// 2. [system] Scratchpad — emitted only when non-empty, BEFORE the conversation.
///
/// 3. [system] Compaction summary — if a summary exists for this stack.
///
/// 4. [user / assistant / tool] Conversation history.
///
/// 5. [system] Dynamic tail — extra_system_dynamic + current date/time/OS/cwd.
///
/// 6. [system] Tail reminder — short anti-drift reminder (e.g. Telegram format).
/// ```
pub async fn build(
&self,
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,
) -> anyhow::Result<Vec<Value>> {
let pool = &*self.pool;
// ── 1. Static system message ──────────────────────────────────────────
let mut static_content = crate::agents::load_prompt(agent_id)?;
let meta = crate::agents::load_meta(agent_id)?;
if !meta.inject_memory.is_empty() {
static_content.push_str(
"\n\n---\nThe following memory files have been loaded automatically. \
You can edit them with `edit_file` or `write_file` using the path shown.\n"
);
for mem_path in &meta.inject_memory {
// Resolve the entry to (absolute path to read, path to show the agent).
let (abs, display) = self.resolve_memory_path(mem_path);
let content = tokio::fs::read_to_string(&abs).await.ok();
match content {
Some(c) => static_content.push_str(&format!(
"\n<memory_file path=\"{display}\">\n{c}\n</memory_file>\n"
)),
None => static_content.push_str(&format!(
"\n<memory_file path=\"{display}\">\n(file not created yet)\n</memory_file>\n"
)),
}
}
}
// ── Skills index ──────────────────────────────────────────────────────
// Injected for every agent unless it opts out (`inject_skills: false`).
// Reuses the memory-path resolution so the shown path is relative when the
// index is under the session WD, absolute otherwise (it lives under Skald's
// own cwd, so it shows as absolute inside project sessions). Skipped silently
// when no skills are installed.
if meta.inject_skills {
let (abs, display) = self.resolve_memory_path(SKILLS_INDEX_PATH);
if let Ok(c) = tokio::fs::read_to_string(&abs).await {
static_content.push_str(&format!(
"\n\n---\nInstalled skills you can use (read the linked `SKILL.md` before running a skill):\n\
\n<skills_index path=\"{display}\">\n{c}\n</skills_index>\n"
));
}
}
if let Some(extra) = extra_system_static {
static_content.push_str("\n\n---\n");
static_content.push_str(extra);
}
if static_content.contains("__MCP_LIST__") {
static_content = static_content.replace(
"__MCP_LIST__",
&self.render_mcp_list(active_mcp_grants),
);
}
for (key, value) in system_substitutions {
let sentinel = format!("__{key}__");
if static_content.contains(sentinel.as_str()) {
static_content = static_content.replace(sentinel.as_str(), value);
}
}
let static_msg = if cache_hints {
json!({
"role": "system",
"content": [{ "type": "text", "text": static_content, "cache_control": { "type": "ephemeral" } }]
})
} else {
json!({ "role": "system", "content": static_content })
};
let mut out = vec![static_msg];
// ── 2. Scratchpad system message (before conversation) ────────────────
let scratch = crate::db::scratchpad::for_session(pool, self.session_id).await?;
if !scratch.is_empty() {
let mut s = String::from(
"<scratchpad>\n \
<!-- Temporary notes shared by all agents in this session. Not persisted across sessions. -->\n"
);
for (k, v) in &scratch {
s.push_str(&format!(" <note key=\"{k}\">{v}</note>\n"));
}
s.push_str("</scratchpad>");
out.push(json!({ "role": "system", "content": s }));
}
// ── 3. Context compaction: inject summary + load messages after boundary ──
let summary = chat_summaries::latest_for_stack(pool, stack_id).await?;
let mut history = match &summary {
Some(s) => {
out.push(json!({
"role": "system",
"content": format!(
"{SUMMARY_PREFIX}\n\n{}\n\n\
[End of context summary — the following messages are the most recent exchanges in full.]",
s.content
)
}));
chat_history::for_stack_since(pool, stack_id, s.covers_up_to_message_id).await?
}
None => chat_history::for_stack(pool, stack_id).await?,
};
if self.compactor.is_none() && history.len() > self.max_history_messages {
history.drain(..history.len() - self.max_history_messages);
if matches!(history.first().map(|m| &m.role), Some(chat_history::Role::Assistant)) {
history.drain(..1);
}
}
let current_turn_boundary = history
.iter()
.rposition(|e| matches!(e.role, chat_history::Role::User | chat_history::Role::Agent));
for (idx, entry) in history.iter().enumerate() {
let is_previous_turn = current_turn_boundary.map_or(false, |b| idx < b);
match entry.role {
chat_history::Role::User | chat_history::Role::Agent => {
// Render attachments (if any) as a textual block appended to the
// user turn, generated on the fly — never persisted as content.
let content = match &entry.metadata {
Some(meta) if !meta.attachments.is_empty() => format!(
"{}{}",
entry.content,
core_api::message_meta::attachments_block(&meta.attachments),
),
_ => entry.content.clone(),
};
// Coalesce consecutive user/agent rows into a single `role:user`
// turn. The DB keeps each message as its own row (distinct bubbles,
// per-message attachments), but the model must see one clean user
// turn — e.g. when several messages were injected back-to-back at a
// round boundary, or queued together while idle. `for_stack` already
// excludes `failed` rows, so only non-failed messages merge here.
match out.last_mut() {
Some(last) if last["role"] == "user" => {
let prev = last["content"].as_str().unwrap_or("").to_string();
last["content"] = Value::String(format!("{prev}\n\n{content}"));
}
_ => out.push(json!({ "role": "user", "content": content })),
}
}
chat_history::Role::Assistant => {
let tool_calls = chat_llm_tools::for_message(pool, entry.id).await?;
if tool_calls.is_empty() {
let mut msg = json!({ "role": "assistant", "content": entry.content });
if let Some(rc) = &entry.reasoning_content {
// Echo under both names: DeepSeek expects "reasoning_content",
// MiniMax M3 and others expect "reasoning".
msg["reasoning_content"] = rc.clone().into();
msg["reasoning"] = rc.clone().into();
}
out.push(msg);
} else {
let tc_array: Vec<Value> = tool_calls
.iter()
.map(|tc| json!({
"id": format!("tc_{}", tc.id),
"type": "function",
"function": {
"name": tc.name,
"arguments": tc.arguments.as_deref().unwrap_or("{}"),
}
}))
.collect();
let mut msg = json!({
"role": "assistant",
"content": entry.content,
"tool_calls": tc_array,
});
if let Some(rc) = &entry.reasoning_content {
// Echo under both names: DeepSeek expects "reasoning_content",
// MiniMax M3 and others expect "reasoning".
msg["reasoning_content"] = rc.clone().into();
msg["reasoning"] = rc.clone().into();
}
out.push(msg);
for tc in &tool_calls {
let result_content = match tc.status.as_str() {
"done" => tc.result.as_deref().unwrap_or("").to_string(),
"failed" => format!(
"Error: {}",
tc.result.as_deref().unwrap_or("unknown error")
),
// A human/policy rejection or a /stop cancellation is a
// deliberate, terminal outcome — surface the saved reason
// (the user's justification) so the LLM understands the
// tool did NOT run and why, instead of retrying blindly.
"rejected" => tc.result.as_deref()
.unwrap_or("User rejected this tool call.")
.to_string(),
"cancelled" => tc.result.as_deref()
.unwrap_or("Tool call was cancelled by the user.")
.to_string(),
// 'pending'/'running' left behind by a crash or a lost
// connection: the call really was interrupted mid-flight.
_ => "Error: tool call was interrupted (connection lost before user approval). Please retry the operation.".to_string(),
};
let result_content = self.maybe_hide_tool_result(
result_content,
is_previous_turn,
&tc.name,
tc.arguments.as_deref(),
);
out.push(json!({
"role": "tool",
"tool_call_id": format!("tc_{}", tc.id),
"content": result_content,
}));
}
}
}
}
}
// ── 5. Dynamic tail system message (after conversation) ──────────────
{
let datetime_line = if self.datetime_config.enabled {
let now_utc = chrono::Utc::now();
let secs = now_utc.timestamp();
let secs = match self.datetime_config.round_minutes {
Some(m) if m > 0 => {
let bucket = (m as i64) * 60;
(secs / bucket) * bucket
}
_ => secs,
};
// Effective timezone: the one configured in config.yml if set, else the
// OS timezone. When resolvable we show the IANA name alongside the offset.
let tz = self.datetime_config.timezone.as_deref()
.and_then(|s| s.parse::<chrono_tz::Tz>().ok())
.or_else(|| system_timezone().and_then(|s| s.parse::<chrono_tz::Tz>().ok()));
let (formatted, tz_name) = match tz {
Some(tz) => {
use chrono::TimeZone as _;
let f = tz.timestamp_opt(secs, 0)
.single()
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
.unwrap_or_else(|| chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string());
(f, Some(tz.name().to_string()))
}
None => {
let f = chrono::DateTime::from_timestamp(secs, 0)
.map(|utc| utc.with_timezone(&chrono::Local).format("%Y-%m-%dT%H:%M:%S%:z").to_string())
.unwrap_or_else(|| chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string());
(f, None)
}
};
let date_line = match tz_name {
Some(name) => format!("Current date and time: {formatted} ({name})"),
None => format!("Current date and time: {formatted}"),
};
let cwd = self.working_directory.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
.display()
.to_string();
Some(format!(
"{date_line}\nOperating system: {}\nWorking directory: {cwd}\n\
Filesystem tools and execute_cmd use this working directory for relative paths — \
no need to `cd` into it first.",
os_description()
))
} else {
None
};
let tail = match (extra_system_dynamic, datetime_line.as_deref()) {
(Some(dyn_ctx), Some(dt)) => Some(format!("{dyn_ctx}\n\n---\n{dt}")),
(Some(dyn_ctx), None) => Some(dyn_ctx.to_string()),
(None, Some(dt)) => Some(dt.to_string()),
(None, None) => None,
};
if let Some(content) = tail {
out.push(json!({ "role": "system", "content": content }));
}
}
// ── 6. Tail reminder ──────────────────────────────────────────────────
if let Some(reminder) = tail_reminder {
out.push(json!({ "role": "system", "content": reminder }));
}
Ok(out)
}
/// Returns the tool result as-is, or replaces it with an informative 1-line
/// summary when the result belongs to a previous turn and exceeds `max_tool_result_chars`.
fn maybe_hide_tool_result(
&self,
result: String,
is_previous_turn: bool,
tool_name: &str,
arguments: Option<&str>,
) -> String {
if !is_previous_turn {
return result;
}
let Some(limit) = self.max_tool_result_chars else {
return result;
};
if result.len() <= limit {
return result;
}
summarize_tool_result(tool_name, arguments, &result)
}
/// Builds the MCP list section that replaces the `__MCP_LIST__` sentinel.
/// Resolves an `inject_memory` entry to `(absolute path to read, path to show)`.
///
/// `$WD` expands to the session's effective working directory (RunContext WD, or the
/// process cwd when unset). The shown path is **relative to that working directory
/// when the file lives under it, absolute otherwise** — so when the agent references
/// it back via `edit_file`/`write_file`, the loop's working-directory injection
/// (which rewrites relative paths against the WD) resolves to the very same file.
fn resolve_memory_path(&self, mem_path: &str) -> (std::path::PathBuf, String) {
let wd = self.working_directory.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let expanded = mem_path.replace("$WD", &wd.display().to_string());
let abs = crate::tools::fs::resolve(&expanded)
.unwrap_or_else(|_| std::path::PathBuf::from(&expanded));
let display = match abs.strip_prefix(&wd) {
Ok(rel) => rel.to_string_lossy().into_owned(),
Err(_) => abs.to_string_lossy().into_owned(),
};
(abs, display)
}
fn render_mcp_list(&self, active_mcp_grants: &HashSet<String>) -> String {
let all_servers: std::collections::BTreeSet<String> = self.mcp.tools()
.into_iter()
.map(|t| t.server_name)
.collect();
if all_servers.is_empty() {
return String::new();
}
let descriptions = self.mcp.server_descriptions();
let hidden: Vec<&String> = all_servers.iter()
.filter(|n| !active_mcp_grants.contains(*n))
.collect();
let active: Vec<&String> = all_servers.iter()
.filter(|n| active_mcp_grants.contains(*n))
.collect();
let mut out = String::from("## MCP servers\n");
if !hidden.is_empty() {
out.push_str("\n**Available** — call `activate_tools([\"name\"])` to load tools:\n\n");
out.push_str("| Server | Description |\n|--------|-------------|\n");
for name in &hidden {
let desc = descriptions.get(*name)
.and_then(|d| d.as_deref())
.unwrap_or("");
out.push_str(&format!("| `{name}` | {desc} |\n"));
}
}
if !active.is_empty() {
out.push_str("\n**Active** — tools callable as `mcp__<name>__<tool>`:\n");
for name in &active {
out.push_str(&format!("- `{name}`\n"));
}
}
out
}
}
// ── Free helpers ──────────────────────────────────────────────────────────────
/// Creates an informative 1-line summary of a tool call result.
///
/// Produces human-readable descriptions like:
/// ```text
/// [execute_cmd] ran `cargo build` → exit 0, 47 lines output
/// [read_file] read src/main.rs (3,200 chars)
/// [write_file] wrote to agents/foo/AGENT.md
/// ```
fn summarize_tool_result(tool_name: &str, arguments: Option<&str>, result: &str) -> String {
let args: serde_json::Value = arguments
.and_then(|a| serde_json::from_str(a).ok())
.unwrap_or(serde_json::Value::Null);
let char_count = result.len();
let line_count = if result.trim().is_empty() { 0 } else { result.lines().count() };
fn arg_str<'a>(args: &'a serde_json::Value, key: &str) -> &'a str {
args[key].as_str().unwrap_or("?")
}
match tool_name {
tn::EXECUTE_CMD => {
let cmd = args["command"].as_str().unwrap_or("");
let cmd_display = super::preview_truncate(cmd, 77);
let exit_code = result
.lines()
.next()
.and_then(|l| l.strip_prefix("exit: "))
.unwrap_or("?");
format!("[execute_cmd] ran `{cmd_display}` → exit {exit_code}, {line_count} lines output")
}
"read_file" | "read_file_chunk" => {
let path = arg_str(&args, "path");
format!("[{tool_name}] read {path} ({char_count} chars)")
}
"write_file" => {
let path = arg_str(&args, "path");
format!("[write_file] wrote to {path}")
}
"edit_file" | "patch_file" => {
let path = arg_str(&args, "path");
format!("[{tool_name}] edited {path}")
}
"list_dir" | "glob" => {
let path = args["path"].as_str()
.or_else(|| args["pattern"].as_str())
.unwrap_or("?");
format!("[{tool_name}] {path} ({char_count} chars)")
}
"list_items" => {
let kind = arg_str(&args, "type");
format!("[list_items] {kind} ({char_count} chars)")
}
"toggle_item" => {
let kind = arg_str(&args, "kind");
let id = arg_str(&args, "id");
let enabled = args["enabled"].as_bool().unwrap_or(false);
format!("[toggle_item] {kind} '{id}' → {}", if enabled { "enabled" } else { "disabled" })
}
tn::READ_NOTIFICATION => {
let count = serde_json::from_str::<Vec<serde_json::Value>>(result)
.map(|v| v.len())
.unwrap_or(0);
format!("[read_notification] {count} notification(s)")
}
tn::EXECUTE_TASK | tn::EXECUTE_SUBTASK => {
let agent = arg_str(&args, "agent_id");
format!("[{tool_name}] → {agent} ({char_count} chars result)")
}
tn::ACTIVATE_TOOLS => {
let groups = args["groups"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
.unwrap_or_else(|| "?".to_string());
format!("[activate_tools] loaded: {groups}")
}
_ if tool_name.starts_with("mcp__") => {
format!("[{tool_name}] ({char_count} chars result)")
}
_ => {
let first_arg = args.as_object()
.and_then(|m| m.iter().next())
.map(|(k, v)| {
let sv = super::preview_truncate(v.as_str().unwrap_or_default(), 40);
format!(" {k}={sv}")
})
.unwrap_or_default();
format!("[{tool_name}]{first_arg} ({char_count} chars result)")
}
}
}
@@ -0,0 +1,44 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde_json::Value;
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,
) -> anyhow::Result<Vec<Value>> {
let effective_wd = self.run_context.read().await
.as_ref()
.map(|rc| rc.effective_working_dir());
let builder = MessageBuilder {
pool: Arc::clone(&self.db),
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(),
working_directory: effective_wd,
};
// `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).await
}
}
@@ -0,0 +1,677 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
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};
use crate::approval::ApprovalManager;
use crate::run_context::RunContext;
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;
use crate::llm::LlmManager;
use crate::mcp::McpManager;
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;
mod interface_tools;
mod llm_call;
mod llm_loop;
pub mod message_builder;
mod messages;
mod outcome;
mod resume;
use emitter::TurnEmitter;
pub use interface_tools::{InterfaceTool, ToolFuture};
pub const DEFAULT_MAX_TOOL_ROUNDS: usize = 20;
/// Default maximum number of synchronous sub-agents dispatched concurrently when
/// the LLM emits a homogeneous batch of sub-agent calls in a single response.
/// Bounds fan-out so a large batch does not trigger provider rate-limit storms.
pub const DEFAULT_MAX_PARALLEL_SUBAGENTS: usize = 4;
pub(super) const MAX_AGENT_DEPTH: i64 = 5;
/// A queued user message to be appended to history mid-turn (drained from the
/// source inbox at a round boundary).
pub struct PendingMsg {
pub content: String,
pub metadata: Option<MessageMetadata>,
}
/// 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
/// 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
/// non-interactive runners (cron, TIC) pass `None` — they never inject.
#[async_trait]
pub trait PendingUserInput: Send + Sync {
/// Drains the leading run of queued non-synthetic user messages, one entry
/// each. Returns empty when there is nothing to inject.
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 {}
pub(super) enum TurnOutcome {
Final {
content: String,
message_id: i64,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
truncated: bool,
/// All tool calls executed during this turn, across all rounds.
tool_calls: Vec<crate::chat_event_bus::ToolCallEvent>,
},
Cancelled,
Exhausted,
}
/// Truncate `s` to at most `max_chars` characters, appending `…` when it was
/// longer. Char-boundary safe: a raw `&s[..n]` byte slice panics when byte `n`
/// lands inside a multi-byte UTF-8 character (e.g. an em-dash or emoji straddling
/// the cut point), which is exactly how a well-formed sub-agent result once
/// unwound a whole turn. Used for every event/log preview.
pub(super) fn preview_truncate(s: &str, max_chars: usize) -> String {
match s.char_indices().nth(max_chars) {
Some((byte_idx, _)) => format!("{}", &s[..byte_idx]),
None => s.to_string(),
}
}
pub(super) fn update_scratchpad_tool_def() -> Value {
json!({
"type": "function",
"function": {
"name": tn::UPDATE_SCRATCHPAD,
"description": "Write or update a key-value note in the session scratchpad. \
Notes are shared by all agents in this chat session and automatically \
injected into every agent's context. Not persisted across sessions. \
Use it for temporary discoveries: architecture notes, path lookups, \
decisions that other agents in this session need to know about.",
"parameters": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "Short identifier for this note (e.g. 'db_url', 'main_struct')." },
"value": { "type": "string", "description": "Content of the note." }
},
"required": ["key", "value"]
}
}
})
}
/// Tool definition for `write_todos` — a private, per-turn task list the agent
/// uses to plan and track its own progress.
///
/// Unlike `update_scratchpad` (a shared blackboard injected into every agent in
/// the session), `write_todos` is **stateless**: the list lives only in this
/// agent's own tool-result history. Because conversation history is per-stack,
/// it is never visible to sub-agents or to the caller — no DB storage needed.
/// The agent re-sends the whole list (TodoWrite-style) on every update.
pub(super) fn write_todos_tool_def() -> Value {
json!({
"type": "function",
"function": {
"name": tn::WRITE_TODOS,
"description": "Record and update your task list for the current turn, to plan multi-step \
work and track progress. Re-send the ENTIRE list on every call (including \
already-completed items with their new status) — this replaces the previous \
list. Keep exactly one item `in_progress` at a time. This list is PRIVATE \
to you: it is not shared with sub-agents you dispatch, nor returned to your \
caller (use `update_scratchpad` instead for notes other agents must see).",
"parameters": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"description": "The full, ordered task list. Re-send it entirely on every update.",
"items": {
"type": "object",
"properties": {
"content": { "type": "string", "description": "Short description of the task." },
"status": { "type": "string", "enum": ["pending", "in_progress", "completed"], "description": "Current status of this task." }
},
"required": ["content", "status"]
}
}
},
"required": ["todos"]
}
}
})
}
/// 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.
fn execute_subtask_tool_def() -> Value {
json!({
"type": "function",
"function": {
"name": tn::EXECUTE_SUBTASK,
"description": "Delegate work to another agent and get its result. Runs the \
named agent synchronously with the given prompt and blocks until \
it finishes, returning its final answer as the tool result. Use \
`list_agents` first to see which agents are available.",
"parameters": {
"type": "object",
"properties": {
"agent_id": { "type": "string", "description": "Id of the agent to run (see `list_agents`)." },
"title": { "type": "string", "description": "Short name for this sub-task." },
"description": { "type": "string", "description": "What this sub-task does." },
"prompt": { "type": "string", "description": "Prompt sent to the agent." }
},
"required": ["agent_id", "prompt"]
}
}
})
}
fn ask_user_clarification_tool_def() -> Value {
json!({
"type": "function",
"function": {
"name": tn::ASK_USER_CLARIFICATION,
"description": "Pause execution and ask the user a clarification question. \
Use when requirements are ambiguous, a dependency is missing, \
or a decision requires user input before continuing. \
The user's answer is returned as the tool result.",
"parameters": {
"type": "object",
"properties": {
"title": { "type": "string", "description": "Short label shown in the inbox card (e.g. 'Missing API key')." },
"question": { "type": "string", "description": "Full question text." },
"suggested_answers": {
"type": "array",
"items": { "type": "string" },
"description": "Optional list of suggested answers shown as chips. The user can pick one or type freely."
}
},
"required": ["title", "question"]
}
}
})
}
pub enum ApprovalDecision {
Approved,
Rejected { note: String },
}
impl ApprovalDecision {
/// Canonical tool-result text shown to the LLM for a human rejection,
/// given the raw user-supplied note (which may be empty). This is the
/// single source of truth: every reject path passes the raw note and lets
/// this build the message, so the wording stays consistent and the note
/// carries the user's justification verbatim — no surface-specific prefixes.
pub fn rejection_message(note: &str) -> String {
let note = note.trim();
if note.is_empty() {
"User rejected this tool call.".to_string()
} else {
format!("User rejected this tool call. Reason: {note}")
}
}
}
pub struct ChatSessionHandler {
pub session_id: i64,
pub(super) db: Arc<SqlitePool>,
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,
pub(super) agent_id: String,
/// Source of the session: "web", "telegram", "cron", etc.
pub(super) source: String,
/// True when a real user is actively participating (web, telegram).
pub(super) is_interactive: bool,
/// True for short-lived automated sessions (cron, tic).
pub(super) is_ephemeral: bool,
pub(super) tools: Arc<ToolRegistry>,
pub(super) mcp: Arc<McpManager>,
/// 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>,
/// Human-readable label injected by background runners (e.g. "CronJob: Daily Digest").
pub(super) context_label: std::sync::RwLock<Option<String>>,
pub(super) memory_manager: Arc<MemoryManager>,
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.
pub(super) auto_deny_approvals: AtomicBool,
/// Tool-call ids the user already approved via a resolve endpoint after a restart
/// (no live oneshot to unblock). The next resume's approval gate skips re-gating
/// these so a post-restart approve dispatches the tool without a second prompt.
pub(super) pre_approved: std::sync::Mutex<std::collections::HashSet<i64>>,
/// Context compactor, shared across all sessions. `None` when compaction
/// is disabled (no `compaction` section in config).
pub(super) compactor: Option<Arc<ContextCompactor>>,
/// 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
/// (provider did not report usage on the first turn).
pub(super) last_input_tokens: AtomicU32,
/// Active RunContext for this session. `None` means the "default" group is used implicitly.
pub(super) run_context: tokio::sync::RwLock<Option<RunContext>>,
/// When set, scratchpad reads/writes use this session_id instead of `self.session_id`.
/// Used by async sub-tasks to share the parent's scratchpad.
pub(super) scratchpad_session_id: std::sync::OnceLock<i64>,
}
impl ChatSessionHandler {
pub fn new(
session_id: i64,
db: Arc<SqlitePool>,
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,
is_ephemeral: bool,
tools: Arc<ToolRegistry>,
mcp: Arc<McpManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
event_bus: Arc<ChatEventBus>,
memory_manager: Arc<MemoryManager>,
image_generator_manager: Arc<ImageGeneratorManager>,
compactor: Option<Arc<ContextCompactor>>,
run_context: Option<RunContext>,
tool_discovery: Arc<ToolDiscovery>,
) -> Self {
Self {
session_id,
db,
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,
memory_manager,
image_generator_manager,
compactor,
context_label: std::sync::RwLock::new(None),
processing: Mutex::new(()),
current_cancel: std::sync::Mutex::new(CancellationToken::new()),
auto_deny_approvals: AtomicBool::new(false),
pre_approved: std::sync::Mutex::new(std::collections::HashSet::new()),
last_input_tokens: AtomicU32::new(0),
run_context: tokio::sync::RwLock::new(run_context),
scratchpad_session_id: std::sync::OnceLock::new(),
}
}
/// Sets the human-readable context label for this session (e.g. "CronJob: Daily Digest").
/// Called by background runners after the handler is created.
pub fn set_context_label(&self, label: impl Into<String>) {
if let Ok(mut g) = self.context_label.write() {
*g = Some(label.into());
}
}
/// Override the session used for scratchpad reads/writes.
/// Called by the cron runner for async tasks so they share the parent's scratchpad.
pub fn set_scratchpad_session_id(&self, id: i64) {
let _ = self.scratchpad_session_id.set(id);
}
/// Returns the session_id to use for scratchpad operations.
pub(super) fn scratchpad_sid(&self) -> i64 {
*self.scratchpad_session_id.get().unwrap_or(&self.session_id)
}
/// Updates the active RunContext for this session at runtime.
pub async fn set_run_context(&self, ctx: Option<RunContext>) {
*self.run_context.write().await = ctx;
}
/// Returns the serialised JSON blob of the active RunContext (for storing on child tasks).
pub async fn run_context_json(&self) -> Option<String> {
self.run_context.read().await.as_ref().map(|rc| rc.to_db())
}
/// Returns the active tool_permission_groups id for approval checks.
pub(super) async fn tool_group_id(&self) -> Option<String> {
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.
pub fn cancel(&self) {
self.current_cancel.lock().unwrap().cancel();
}
/// 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
/// freshly (re)connected client to show the STOP button.
pub fn is_processing(&self) -> bool {
self.processing.try_lock().is_err()
}
/// When set, any tool call that would require human approval is automatically
/// denied instead of blocking indefinitely.
pub fn set_auto_deny_approvals(&self) {
self.auto_deny_approvals.store(true, Ordering::Relaxed);
}
/// Records that the user already approved this tool_call via a resolve endpoint
/// after a restart (no live oneshot to unblock). The next resume's approval gate
/// consumes this and skips re-gating, so the tool dispatches without re-prompting.
pub fn mark_pre_approved(&self, tool_call_id: i64) {
self.pre_approved.lock().unwrap().insert(tool_call_id);
}
/// Cancels all pending approvals for this session in the ApprovalManager.
/// Called when the WS connection is lost mid-approval so the waiting future unblocks.
pub async fn cancel_pending_approvals(&self) {
self.approval.cancel_for_session(self.session_id).await;
}
/// Resolves a pending `ask_user_clarification` call with the user's answer.
pub async fn resolve_question(&self, request_id: i64, answer: String) {
if !self.clarification.resolve(request_id, answer).await {
warn!(session_id = self.session_id, request_id, "resolve_question: request_id not found in ClarificationManager");
}
}
/// 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.
pub async fn cancel_pending_questions(&self) {
self.clarification.cancel_for_session(self.session_id).await;
}
/// Force compaction of the current stack's conversation history.
/// Bypasses the token threshold check; still respects the ephemeral guard.
/// Returns `true` if a new summary was written, `false` if skipped.
pub async fn force_compact(&self) -> anyhow::Result<bool> {
let pool = &self.db;
let stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
Some(s) => s,
None => return Ok(false),
};
match self.compactor {
Some(ref compactor) => {
compactor.force_compact(pool, self.session_id, stack.id, self.is_ephemeral).await
}
None => Ok(false),
}
}
/// Processes a user message end-to-end:
/// saves it, runs the tool-calling loop, saves the final response,
/// sends a Done event. Only one call can run at a time per session.
pub async fn handle_message(
&self,
content: &str,
client_name: Option<String>,
extra_system_context: Option<String>,
// Per-turn dynamic system suffix injected AFTER conversation history.
// Merged with the Honcho memory context (which also lives at position 5).
// Use for per-turn framing that must not pollute the cacheable static prefix
// (e.g. notification behavioural instructions from ChatHub).
extra_system_dynamic_override: Option<String>,
tail_reminder: Option<String>,
interface_tools: Vec<InterfaceTool>,
system_substitutions: HashMap<String, String>,
tx: mpsc::Sender<ServerEvent>,
// True for system-generated messages injected as user turns
// (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.
metadata: Option<MessageMetadata>,
// Queued user input for this source. When `Some`, `run_agent_turn` 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();
let pool = &self.db;
let em = TurnEmitter::new(&tx);
// Retrieve memory context (Honcho or other backend) for this turn.
// Kept SEPARATE from extra_system_context (the static part) so it can be
// injected as a dynamic tail system message after the conversation history
// rather than embedded in the cacheable static prefix. This allows
// providers with prefix caching (e.g. Alibaba/DeepSeek via OpenRouter)
// to cache the stable system prompt across turns even though Honcho
// memories change on every call.
let honcho_dynamic = match self.memory_manager.query_context(self.session_id, content).await {
Some(mem_ctx) => {
trace!(
session_id = self.session_id,
chars = mem_ctx.len(),
"handle_message: memory context retrieved (will be injected as dynamic tail)"
);
Some(mem_ctx)
}
None => {
trace!(
session_id = self.session_id,
"handle_message: no memory context returned (cold start, unavailable, or nothing to say)"
);
None
}
};
// Merge Honcho memories with any per-turn override from the caller.
// The override goes last so it sits closest to the generation point (recency bias).
// extra_system_context (passed by the caller) is the STATIC part:
// interface-specific formatting rules (e.g. Telegram HTML format),
// never changes turn-to-turn, safe to include in the cached prefix.
let extra_system_dynamic = match (honcho_dynamic, extra_system_dynamic_override) {
(Some(honcho), Some(override_)) => Some(format!("{honcho}\n\n{override_}")),
(Some(honcho), None) => Some(honcho),
(None, Some(override_)) => Some(override_),
(None, None) => None,
};
let mut config = self.build_agent_config(
client_name, extra_system_context, extra_system_dynamic, interface_tools, system_substitutions,
).await?;
config.tail_reminder = tail_reminder;
let stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
Some(s) => s,
None => {
chat_sessions_stack::create(pool, self.session_id, "main", None, 0, None).await?
}
};
info!(session_id = self.session_id, stack_id = stack.id, client = %config.client_name, "handle_message start");
// ── Context compaction (Opzione C: at the start of the next turn) ────
// Check whether the previous turn's input token count exceeded the
// threshold. If so, summarise the old history before processing the
// new message. This keeps latency transparent to the user — the wait
// 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 {
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"),
}
}
// ─────────────────────────────────────────────────────────────────────
// 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?;
}
}
let user_content = content.to_string(); // save before TurnOutcome::Final shadows `content`
let user_message_id = chat_history::append_with_metadata(pool, stack.id, &chat_history::Role::User, content, is_synthetic, None, metadata.as_ref()).await?;
// Telnet-style echo: the bubble appears only once the message is persisted.
// Synthetic turns (TIC/notification) never produce a user bubble.
if !is_synthetic {
let attachments = 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 `display` when present.
let echo = metadata.as_ref()
.and_then(|m| m.command.as_ref())
.map(|c| c.display.clone())
.unwrap_or_else(|| user_content.clone());
em.user_message(user_message_id, echo, attachments).await;
}
// 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.
self.resume_pending_tools(stack.id, &config, &token, &tx).await?;
let outcome = self.run_agent_turn(stack.id, &config, &token, &tx, pending_input.as_ref()).await?;
match outcome {
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, truncated, 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 {
self.last_input_tokens.store(t, Ordering::Relaxed);
}
info!(session_id = self.session_id, stack_id = stack.id, ?input_tokens, ?output_tokens, "handle_message done");
if truncated {
warn!(session_id = self.session_id, ?output_tokens, "response truncated (max_tokens)");
em.truncated(output_tokens).await;
}
em.done(message_id, stack.id, content.clone(), input_tokens, output_tokens).await;
// Publish both messages to the event bus now that both are in the DB.
let now = chrono::Utc::now();
self.event_bus.user_message(ChatEvent {
session_id: self.session_id,
stack_id: stack.id,
message_id: user_message_id,
role: ChatEventRole::User,
content: user_content,
is_synthetic,
is_interactive: self.is_interactive,
is_ephemeral: self.is_ephemeral,
tool_calls: vec![],
created_at: now,
});
self.event_bus.assistant_response(ChatEvent {
session_id: self.session_id,
stack_id: stack.id,
message_id,
role: ChatEventRole::Assistant,
content,
is_synthetic: false,
is_interactive: self.is_interactive,
is_ephemeral: self.is_ephemeral,
tool_calls,
created_at: now,
});
Ok(())
}
TurnOutcome::Cancelled => {
info!(session_id = self.session_id, "handle_message cancelled by user");
em.error("Cancelled by user.".to_string()).await;
Err(anyhow::anyhow!("Turn cancelled by user"))
}
TurnOutcome::Exhausted => {
error!(session_id = self.session_id, max_rounds = self.max_tool_rounds, "tool-call loop exhausted without final answer");
em.error(format!("Exceeded {} tool-call rounds without a final answer.", self.max_tool_rounds)).await;
Err(anyhow::anyhow!("tool-call loop exhausted after {} rounds without a final answer", self.max_tool_rounds))
}
}
}
}
@@ -0,0 +1,92 @@
//! 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::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,
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?;
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()).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)
}
}
}
}
@@ -0,0 +1,401 @@
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::{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 tool call by name+args without going through the LLM loop.
/// Used by the REST `resolve` endpoint and by `resume_pending_tools`.
/// Does NOT update the DB — caller is responsible for `complete` / `fail`.
pub async fn execute_tool(&self, name: &str, args: Value) -> anyhow::Result<ToolResult> {
if let Some((srv, mcp_tool)) = crate::mcp::parse_mcp_tool_name(name) {
return self.mcp.call(srv, mcp_tool, args).await;
}
self.tools.dispatch(name, args).await.map(ToolResult::Text)
}
/// 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");
// Resume pending/interrupted tools before running the LLM loop.
let had_pending = self.resume_pending_tools(stack.id, &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,
tool_calls: Vec::new(),
};
break 'seed (outcome, stack);
}
}
}
(self.run_agent_turn(stack.id, &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()).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"
);
self.resume_pending_tools(parent_stack.id, &config, &token, &tx).await?;
current_outcome = self.run_agent_turn(parent_stack.id, &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, .. } => {
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).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");
em.tool_start(
tc.id,
tc.message_id,
tc.name.clone(),
args.clone(),
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()).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.
em.tool_start(
tc.id,
tc.message_id,
tc.name.clone(),
args.clone(),
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
}
// `restart` calls process::exit and never returns — mark done first.
if tc.name == tn::RESTART {
info!(session_id = self.session_id, tool_call_id = tc.id, "restart approved (resume) — marking done then exiting");
chat_llm_tools::complete(pool, tc.id, "Riavvio avviato.", "string").await?;
em.tool_done(tc.id, "Riavvio avviato.".to_string(), "string".to_string()).await;
// Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in
// whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
// instead of 255 — breaking the run.sh restart supervisor).
unsafe { libc::_exit(-1) }
}
// 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". Apply the RunContext working dir exactly
// like the live loop.
let effective_args = self.effective_args(&tc.name, &args).await;
let outcome = match self.execute_tool_call(
stack_id, config, tc.id, &tc.name, &effective_args, token, tx,
).await {
super::dispatch::DispatchResult::Outcome(o) => o,
// 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`: it does not accumulate ToolCallEvents nor re-emit
// FileChanged (only a live turn does). A /stop mid-resume returns Abort.
match self.record_tool_outcome(tc.id, &tc.name, &effective_args, outcome, &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));
}
}
+180
View File
@@ -0,0 +1,180 @@
use std::collections::HashMap;
use std::sync::Arc;
use sqlx::SqlitePool;
use tokio::sync::Mutex;
use crate::approval::ApprovalManager;
use crate::chat_event_bus::ChatEventBus;
use crate::clarification::ClarificationManager;
use crate::compactor::ContextCompactor;
use crate::config::DatetimeConfig;
use crate::db::{chat_sessions, chat_sessions_stack};
use crate::llm::LlmManager;
use crate::mcp::McpManager;
use crate::image_generate::ImageGeneratorManager;
use crate::memory::MemoryManager;
use crate::run_context::{RunContext, RunContextManager};
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
use super::handler::ChatSessionHandler;
pub struct ChatSessionManager {
db: Arc<SqlitePool>,
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>,
mcp: Arc<McpManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
event_bus: Arc<ChatEventBus>,
memory_manager: Arc<MemoryManager>,
image_generator_manager: Arc<ImageGeneratorManager>,
/// 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>,
active: Mutex<HashMap<i64, Arc<ChatSessionHandler>>>,
}
impl ChatSessionManager {
pub fn new(
db: Arc<SqlitePool>,
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>,
mcp: Arc<McpManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
event_bus: Arc<ChatEventBus>,
memory_manager: Arc<MemoryManager>,
image_generator_manager: Arc<ImageGeneratorManager>,
compactor: Option<Arc<ContextCompactor>>,
run_context_manager: Arc<RunContextManager>,
tool_discovery: Arc<ToolDiscovery>,
) -> Self {
Self {
db,
llm_manager,
max_history_messages,
max_tool_rounds,
max_parallel_subagents,
max_tool_result_chars,
datetime_config,
tools,
mcp,
approval,
clarification,
event_bus,
memory_manager,
image_generator_manager,
compactor,
run_context_manager,
tool_discovery,
active: Mutex::new(HashMap::new()),
}
}
pub fn llm_manager(&self) -> Arc<LlmManager> {
Arc::clone(&self.llm_manager)
}
pub fn run_context_manager(&self) -> Arc<RunContextManager> {
Arc::clone(&self.run_context_manager)
}
/// 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>> {
self.active.lock().await.get(&session_id).cloned()
}
pub async fn create_session(
&self,
agent_id: &str,
source: &str,
is_interactive: bool,
is_ephemeral: bool,
run_context: Option<&RunContext>,
) -> anyhow::Result<(i64, i64)> {
let session = chat_sessions::create(&self.db, agent_id, source, is_interactive, is_ephemeral).await?;
// Persist the RunContext at creation time so it is present before any handler
// is constructed (get_or_create_handler reads it once at construction).
if let Some(rc) = run_context {
chat_sessions::set_run_context(&self.db, session.id, Some(&rc.to_db())).await?;
}
let stack = chat_sessions_stack::create(
&self.db, session.id, "main", None, 0, None,
).await?;
Ok((session.id, stack.id))
}
/// Cancel the in-flight turn for `session_id` and clean up any pending
/// approvals and clarifications so their blocking awaits unblock immediately.
/// No-op if no handler is active for the session.
pub async fn cancel_session(&self, session_id: i64) {
let handler = self.active.lock().await.get(&session_id).cloned();
if let Some(h) = handler {
h.cancel();
h.cancel_pending_approvals().await;
h.cancel_pending_questions().await;
}
}
pub async fn get_or_create_handler(
&self,
session_id: i64,
) -> anyhow::Result<Arc<ChatSessionHandler>> {
{
let active = self.active.lock().await;
if let Some(h) = active.get(&session_id) {
return Ok(h.clone());
}
}
let session = chat_sessions::find_by_id(&self.db, session_id)
.await?
.ok_or_else(|| anyhow::anyhow!("session {session_id} not found"))?;
let run_context = session.run_context.as_deref().and_then(RunContext::from_db);
let handler = Arc::new(ChatSessionHandler::new(
session_id,
self.db.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,
session.is_ephemeral,
self.tools.clone(),
self.mcp.clone(),
Arc::clone(&self.approval),
Arc::clone(&self.clarification),
Arc::clone(&self.event_bus),
Arc::clone(&self.memory_manager),
Arc::clone(&self.image_generator_manager),
self.compactor.clone(),
run_context,
Arc::clone(&self.tool_discovery),
));
self.active.lock().await.insert(session_id, handler.clone());
Ok(handler)
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod handler;
pub mod manager;