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:
@@ -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(¬e);
|
||||
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 ¤t_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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod handler;
|
||||
pub mod manager;
|
||||
Reference in New Issue
Block a user