agent-loop: root turn driven by the library kernel (phase 2)

ChatSessionHandler now runs the root turn on the agent-loop kernel
instead of run_agent_turn; sub-agents follow on the same kernel via
DelegateTool. The old loop stays for resume/recovery until phase 3.

agent-loop:
- DelegateTool + AgentCatalog/AgentProfile (full toolset override,
  per-child selector/assembler, frame-scoped get), StaticCatalog,
  FilteredToolSet; sync flow with sticky child_token; batch via the
  generic fan-out
- manager: start_loop skips the registry (children are not
  double-driving; they ride the parent's token tree); LoopParams gains
  selector/token overrides
- store: get_frame, get_call, set_call_extras; HistoryStore result text
  aligned to raw-stored semantics (projection formats)
- events: ApprovalRequired.request_id, AgentSpawned/Finished parent
  info; AskUserTool with_name + suggested_answers alias + Question.frame

skald-core (loop_adapters + handler):
- SkaldAssembler (byte-parity port of MessageBuilder's projection:
  scratchpad/summary/window, DTL Kimi/Anthropic injection, media, user
  coalescing, reasoning echo) + AgentSystemContext (prompt layers,
  substitutions, MCP list, shared folders, user profile)
- SkaldAgentCatalog (build_sub_agent_config port), SkaldHumanChannel,
  scratchpad/todos tools, execute_task sync/async alias,
  LegacyInterfaceTool, PendingLiveInput
- ApprovalGate: PendingWrite diffs via LoopEvent::Host (memory/disk
  routed like the fs-tools); SkaldWritePreviewHook for executed-write
  diffs; EventTranslator LoopEvent→ServerEvent (display meta, preview,
  FileChanged, AgentStart/Done, root-only Done/Truncated/Cancelled)
- handle_message: builds TurnParams and drives the kernel; resume of
  pending tools runs first (results belong to the previous turn);
  ChatEvent publication stays handler-side; /stop cancels the live loop
- ToolRegistry.get_tool/all_tools; def builders made pub(crate)

Full workspace suite green (179 skald-core, 34 agent-loop, adapters
incl.); two pre-existing doc-test failures fixed along the way.
This commit is contained in:
2026-07-26 12:15:53 +01:00
parent d50abbb0fa
commit 0297fe71bd
35 changed files with 3160 additions and 89 deletions
@@ -0,0 +1,528 @@
//! `SkaldAssembler` — Skald's history projection behind the crate's
//! `ContextAssembler` (port of `MessageBuilder::build`'s message-array half,
//! blueprint §10). Byte-parity with the current builder is the contract:
//! same layers, same tool-result texts, same DTL injections, same media rules.
//!
//! During phase 2 the old `MessageBuilder` still serves the legacy paths
//! (resume/recovery); the two are deleted together in phase 5.
use std::sync::Arc;
use agent_loop::activation::{ActivationSource, ToolRendering};
use agent_loop::context::{AssembleInput, ContextAssembler};
use agent_loop::store::{CallState, HistoryStore, Role};
use core_api::message_meta::{MessageMetadata, attachments_block};
use core_api::tool::MediaRef;
use core_api::user_fs::UserFs;
use serde_json::{Value, json};
use crate::compactor::SUMMARY_PREFIX;
use crate::config::DatetimeConfig;
use crate::loop_adapters::activation::SkaldActivationSource;
use crate::session::handler::media;
use crate::tools::tool_names as tn;
/// Stand-in for a tool-call turn's `reasoning_content` when none was recorded
/// (DeepSeek's thinking mode 400s on replay without it).
const REASONING_ROUNDTRIP_PLACEHOLDER: &str = "(no reasoning recorded for this step)";
/// OS description (type + version), computed once.
fn os_description() -> &'static str {
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
OS.get_or_init(|| os_info::get().to_string())
}
/// System IANA timezone name, computed once.
fn system_timezone() -> Option<&'static str> {
static TZ: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
TZ.get_or_init(|| iana_time_zone::get_timezone().ok()).as_deref()
}
/// Skald's `ContextAssembler`: static system → scratchpad → summary → history
/// (with DTL + media) → dynamic tail (+datetime) → tail reminder.
pub struct SkaldAssembler {
/// Owner pool — scratchpad reads (keyed on `scratchpad_sid`).
pub pool: Arc<sqlx::SqlitePool>,
/// Scratchpad scope (session_id, or the parent's for async sub-tasks).
pub scratchpad_sid: i64,
pub datetime_config: DatetimeConfig,
pub max_history_messages: usize,
pub max_tool_result_chars: Option<usize>,
/// The history window applies only when compaction is disabled.
pub compactor_enabled: bool,
/// The caller's fs view — media containment for inlining. `None` skips
/// media inlining entirely.
pub fs: Option<Arc<UserFs>>,
/// DTL activations (consulted only in non-Inline modes).
pub activation: Option<SkaldActivationSource>,
}
#[agent_loop::async_trait]
impl ContextAssembler for SkaldAssembler {
async fn build(
&self,
store: &Arc<dyn HistoryStore>,
input: &AssembleInput,
) -> agent_loop::Result<Vec<Value>> {
let mut out: Vec<Value> = Vec::new();
// ── 1. Static system message ──────────────────────────────────────────
let static_msg = if input.model.prompt_cache {
json!({
"role": "system",
"content": [{ "type": "text", "text": input.system.base, "cache_control": { "type": "ephemeral" } }]
})
} else {
json!({ "role": "system", "content": input.system.base })
};
out.push(static_msg);
// ── 2. Scratchpad system message (before conversation) ────────────────
let scratch = crate::db::scratchpad::for_session(&self.pool, self.scratchpad_sid).await?;
if !scratch.is_empty() {
let mut s = String::from(
"<scratchpad>\n \
<!-- Temporary notes shared by all agents in this session. Not persisted across sessions. -->\n"
);
for (k, v) in &scratch {
s.push_str(&format!(" <note key=\"{k}\">{v}</note>\n"));
}
s.push_str("</scratchpad>");
out.push(json!({ "role": "system", "content": s }));
}
// ── 3. Compaction summary + surviving history ─────────────────────────
let summary = store.latest_summary(input.frame).await?;
if let Some(s) = &summary {
out.push(json!({
"role": "system",
"content": format!(
"{SUMMARY_PREFIX}\n\n{}\n\n\
[End of context summary — the following messages are the most recent exchanges in full.]",
s.text
)
}));
}
let mut history = match &summary {
Some(s) => store.load_since(input.frame, s.covered_up_to).await?,
None => store.load(input.frame).await?,
};
if !self.compactor_enabled && history.len() > self.max_history_messages {
history.drain(..history.len() - self.max_history_messages);
if matches!(history.first().map(|m| m.role), Some(Role::Assistant)) {
history.drain(..1);
}
}
let current_turn_boundary = history
.iter()
.rposition(|e| matches!(e.role, Role::User | Role::Agent));
// Inline-media turn group: trailing assistant rows are the in-flight
// turn's own rounds; the current turn's user messages sit just before
// them. Older-turn media degrades to the textual path block.
let mut media_turn_start = history.len();
while media_turn_start > 0 && matches!(history[media_turn_start - 1].role, Role::Assistant) {
media_turn_start -= 1;
}
while media_turn_start > 0
&& matches!(history[media_turn_start - 1].role, Role::User | Role::Agent)
{
media_turn_start -= 1;
}
// DTL: tools activated at each assistant message (empty in Inline mode).
let activation_defs: std::collections::HashMap<i64, Vec<Value>> =
match (&self.activation, input.model.tool_rendering) {
(Some(src), ToolRendering::Inline) => {
let _ = src;
Default::default()
}
(Some(src), _) => src
.activations(input.frame)
.await
.unwrap_or_default()
.into_iter()
.map(|a| (a.anchor.get(), a.defs))
.collect(),
(None, _) => Default::default(),
};
// ── 4. Conversation history ───────────────────────────────────────────
for (idx, entry) in history.iter().enumerate() {
let is_previous_turn = current_turn_boundary.is_some_and(|b| idx < b);
match entry.role {
Role::System => {}
Role::User | Role::Agent => {
let metadata: Option<MessageMetadata> = entry
.metadata
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
let (text, media_parts) = match &metadata {
Some(meta)
if !meta.attachments.is_empty()
&& idx >= media_turn_start
&& self.fs.is_some() =>
{
let fs = self.fs.as_deref().expect("guarded by is_some()");
let partition = media::partition(&meta.attachments, &input.model.capabilities, fs).await;
(
format!("{}{}", entry.content, attachments_block(&partition.rest)),
partition.parts,
)
}
Some(meta) if !meta.attachments.is_empty() => (
format!("{}{}", entry.content, attachments_block(&meta.attachments)),
Vec::new(),
),
_ => (entry.content.clone(), Vec::new()),
};
push_user_chunk(&mut out, text, media_parts);
}
Role::Assistant => {
if entry.calls.is_empty() {
let mut msg = json!({ "role": "assistant", "content": entry.content });
if let Some(rc) = entry.reasoning.as_deref().filter(|s| !s.is_empty()) {
msg["reasoning_content"] = rc.into();
msg["reasoning"] = rc.into();
}
out.push(msg);
} else {
let tc_array: Vec<Value> = entry.calls
.iter()
.map(|tc| json!({
"id": tc.provider_id,
"type": "function",
"function": {
"name": tc.name,
"arguments": serde_json::to_string(&tc.arguments)
.unwrap_or_else(|_| "{}".into()),
}
}))
.collect();
let mut msg = json!({
"role": "assistant",
"content": entry.content,
"tool_calls": tc_array,
});
// DeepSeek thinking mode: a tool-calling assistant turn must
// carry a NON-EMPTY reasoning_content on replay.
let rc = entry.reasoning.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(REASONING_ROUNDTRIP_PLACEHOLDER);
msg["reasoning_content"] = rc.into();
msg["reasoning"] = rc.into();
out.push(msg);
for tc in &entry.calls {
let result_content = match tc.state {
CallState::Done => tc.result.clone().unwrap_or_default(),
CallState::Failed => format!(
"Error: {}",
tc.result.as_deref().unwrap_or("unknown error")
),
CallState::Rejected => tc.result.clone()
.unwrap_or_else(|| "User rejected this tool call.".to_string()),
CallState::Cancelled => tc.result.clone()
.unwrap_or_else(|| "Tool call was cancelled by the user.".to_string()),
// 'pending'/'running' left behind by a crash or a lost
// connection: the call really was interrupted mid-flight.
_ => "Error: tool call was interrupted (connection lost before user approval). Please retry the operation.".to_string(),
};
let result_content = self.maybe_hide_tool_result(
result_content,
is_previous_turn,
&tc.name,
&tc.arguments,
);
let mut tool_msg = json!({
"role": "tool",
"tool_call_id": tc.provider_id,
"content": result_content,
});
// Anthropic DTL: an `activate_tools` result becomes a set of
// `tool_reference`s.
if matches!(input.model.tool_rendering, ToolRendering::DeferredToolReference)
&& tc.name == tn::ACTIVATE_TOOLS
&& let Some(adefs) = activation_defs.get(&entry.id.get())
{
let names: Vec<Value> = adefs.iter()
.filter_map(|d| d["function"]["name"].as_str())
.map(|n| Value::String(n.to_string()))
.collect();
if !names.is_empty() {
tool_msg["_tool_references"] = Value::Array(names);
}
}
out.push(tool_msg);
}
// Tool-produced media of the current turn: inline as a
// synthetic `user` message right after the tool-result group.
if idx >= media_turn_start
&& let Some(fs) = self.fs.as_deref()
{
let mut refs: Vec<MediaRef> = Vec::new();
for tc in &entry.calls {
if let Some(mj) = tc.extras["media"].as_str()
&& let Ok(mut v) = serde_json::from_str::<Vec<MediaRef>>(mj)
{
refs.append(&mut v);
}
}
if !refs.is_empty() {
let parts = media::inline_paths(&refs, &input.model.capabilities, fs).await;
if !parts.is_empty() {
out.push(json!({ "role": "user", "content": parts }));
}
}
}
// Kimi K3 DTL: the tools activated at this assistant message,
// as a `system` message carrying a `tools` field, right after
// its tool-result group (append-only → cache-safe).
if matches!(input.model.tool_rendering, ToolRendering::SystemToolBlock)
&& let Some(adefs) = activation_defs.get(&entry.id.get())
&& !adefs.is_empty()
{
out.push(json!({ "role": "system", "tools": adefs }));
}
}
}
}
}
// ── 5. Dynamic tail (extra dynamic + datetime) ────────────────────────
{
let datetime_line = self.datetime_line();
let extra_dynamic = input.system.dynamic_tail.first().map(String::as_str);
let tail = match (extra_dynamic, datetime_line.as_deref()) {
(Some(dyn_ctx), Some(dt)) => Some(format!("{dyn_ctx}\n\n---\n{dt}")),
(Some(dyn_ctx), None) => Some(dyn_ctx.to_string()),
(None, Some(dt)) => Some(dt.to_string()),
(None, None) => None,
};
if let Some(content) = tail {
out.push(json!({ "role": "system", "content": content }));
}
}
// ── 6. Tail reminder ──────────────────────────────────────────────────
if let Some(reminder) = &input.system.tail_reminder {
out.push(json!({ "role": "system", "content": reminder }));
}
Ok(out)
}
}
impl SkaldAssembler {
/// The current date/time + OS + cwd block (empty when disabled).
fn datetime_line(&self) -> Option<String> {
if !self.datetime_config.enabled {
return None;
}
let now_utc = chrono::Utc::now();
let secs = now_utc.timestamp();
let secs = match self.datetime_config.round_minutes {
Some(m) if m > 0 => {
let bucket = (m as i64) * 60;
(secs / bucket) * bucket
}
_ => secs,
};
let tz = self.datetime_config.timezone.as_deref()
.and_then(|s| s.parse::<chrono_tz::Tz>().ok())
.or_else(|| system_timezone().and_then(|s| s.parse::<chrono_tz::Tz>().ok()));
let (formatted, tz_name) = match tz {
Some(tz) => {
use chrono::TimeZone as _;
let f = tz.timestamp_opt(secs, 0)
.single()
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
.unwrap_or_else(|| chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string());
(f, Some(tz.name().to_string()))
}
None => {
let f = chrono::DateTime::from_timestamp(secs, 0)
.map(|utc| utc.with_timezone(&chrono::Local).format("%Y-%m-%dT%H:%M:%S%:z").to_string())
.unwrap_or_else(|| chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string());
(f, None)
}
};
let date_line = match tz_name {
Some(name) => format!("Current date and time: {formatted} ({name})"),
None => format!("Current date and time: {formatted}"),
};
let cwd = "~";
Some(format!(
"{date_line}\nOperating system: {}\nWorking directory: {cwd}\n\
Filesystem tools and execute_cmd resolve relative paths against your home directory.",
os_description()
))
}
/// Replaces an over-limit previous-turn result with an informative 1-liner.
fn maybe_hide_tool_result(
&self,
result: String,
is_previous_turn: bool,
tool_name: &str,
arguments: &Value,
) -> String {
if !is_previous_turn {
return result;
}
let Some(limit) = self.max_tool_result_chars else {
return result;
};
if result.len() <= limit {
return result;
}
summarize_tool_result(tool_name, arguments, &result)
}
}
// ── Free helpers (ported verbatim from message_builder.rs) ─────────────────────
/// Appends one user/agent chunk, coalescing with a preceding `user` message.
fn push_user_chunk(out: &mut Vec<Value>, text: String, media: Vec<Value>) {
fn text_part(t: &str) -> Value {
json!({ "type": "text", "text": t })
}
if let Some(last) = out.last_mut()
&& last["role"] == "user"
{
if !last["content"].is_array() && media.is_empty() {
let prev = last["content"].as_str().unwrap_or("").to_string();
last["content"] = Value::String(format!("{prev}\n\n{text}"));
return;
}
let mut parts = match last["content"].take() {
Value::Array(a) => a,
Value::String(s) => vec![text_part(&s)],
_ => Vec::new(),
};
if let Some(tp) = parts.iter_mut().rev().find(|p| p["type"] == "text") {
let prev = tp["text"].as_str().unwrap_or("").to_string();
tp["text"] = Value::String(format!("{prev}\n\n{text}"));
} else {
parts.insert(0, text_part(&text));
}
parts.extend(media);
last["content"] = Value::Array(parts);
return;
}
if media.is_empty() {
out.push(json!({ "role": "user", "content": text }));
} else {
let mut parts = vec![text_part(&text)];
parts.extend(media);
out.push(json!({ "role": "user", "content": parts }));
}
}
/// Creates an informative 1-line summary of a tool call result.
fn summarize_tool_result(tool_name: &str, arguments: &Value, result: &str) -> String {
let args = arguments;
let char_count = result.len();
let line_count = if result.trim().is_empty() { 0 } else { result.lines().count() };
fn arg_str<'a>(args: &'a serde_json::Value, key: &str) -> &'a str {
args[key].as_str().unwrap_or("?")
}
match tool_name {
tn::EXECUTE_CMD => {
let cmd = args["command"].as_str().unwrap_or("");
let cmd_display = crate::session::handler::preview_truncate(cmd, 77);
let exit_code = result
.lines()
.next()
.and_then(|l| l.strip_prefix("exit: "))
.unwrap_or("?");
format!("[execute_cmd] ran `{cmd_display}` → exit {exit_code}, {line_count} lines output")
}
"read_file" | "read_file_chunk" => {
let path = arg_str(args, "path");
format!("[{tool_name}] read {path} ({char_count} chars)")
}
"write_file" => {
let path = arg_str(args, "path");
format!("[write_file] wrote to {path}")
}
"edit_file" | "patch_file" => {
let path = arg_str(args, "path");
format!("[{tool_name}] edited {path}")
}
"list_dir" | "glob" => {
let path = args["path"].as_str()
.or_else(|| args["pattern"].as_str())
.unwrap_or("?");
format!("[{tool_name}] {path} ({char_count} chars)")
}
"list_items" => {
let kind = arg_str(args, "type");
format!("[list_items] {kind} ({char_count} chars)")
}
"toggle_item" => {
let kind = arg_str(args, "kind");
let id = arg_str(args, "id");
let enabled = args["enabled"].as_bool().unwrap_or(false);
format!("[toggle_item] {kind} '{id}' → {}", if enabled { "enabled" } else { "disabled" })
}
tn::READ_NOTIFICATION => {
let count = serde_json::from_str::<Vec<serde_json::Value>>(result)
.map(|v| v.len())
.unwrap_or(0);
format!("[read_notification] {count} notification(s)")
}
tn::EXECUTE_TASK | tn::EXECUTE_SUBTASK => {
let agent = arg_str(args, "agent_id");
format!("[{tool_name}] → {agent} ({char_count} chars result)")
}
tn::ACTIVATE_TOOLS => {
let groups = args["groups"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
.unwrap_or_else(|| "?".to_string());
format!("[activate_tools] loaded: {groups}")
}
_ if tool_name.starts_with("mcp__") => {
format!("[{tool_name}] ({char_count} chars result)")
}
_ => {
let first_arg = args.as_object()
.and_then(|m| m.iter().next())
.map(|(k, v)| {
let sv = crate::session::handler::preview_truncate(v.as_str().unwrap_or_default(), 40);
format!(" {k}={sv}")
})
.unwrap_or_default();
format!("[{tool_name}]{first_arg} ({char_count} chars result)")
}
}
}
@@ -0,0 +1,273 @@
//! Skald's side of the crate's built-in tools: the `HumanChannel`
//! (clarification manager + interactive `AgentQuestion`), scratchpad/todos
//! tools, and the legacy-name aliases (`execute_task` sync/async composition,
//! `ask_user_clarification`, interface tools).
use std::sync::Arc;
use agent_loop::async_trait;
use agent_loop::delegate::DelegateTool;
use agent_loop::events::{EventSink, LoopEvent};
use agent_loop::human::{HumanChannel, HumanGone, Question};
use agent_loop::tool::{Tool, ToolCtx, ToolFailure, ToolOutput};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::clarification::ClarificationManager;
use core_api::interface_tool::ToolFuture;
// ── SkaldHumanChannel ────────────────────────────────────────────────────────
/// The `ask_user` backend: registers in `ClarificationManager` (so the
/// question lands in the Inbox for EVERY session kind) and, for interactive
/// sessions, also emits `AgentQuestion` inline in the chat (via
/// `LoopEvent::Host`). Port of `dispatch_ask_user_clarification`.
pub struct SkaldHumanChannel {
clarification: Arc<ClarificationManager>,
session_id: i64,
agent_id: String,
source: String,
is_interactive: bool,
context_label: Arc<std::sync::RwLock<Option<String>>>,
}
impl SkaldHumanChannel {
pub fn new(
clarification: Arc<ClarificationManager>,
session_id: i64,
agent_id: impl Into<String>,
source: impl Into<String>,
is_interactive: bool,
context_label: Arc<std::sync::RwLock<Option<String>>>,
) -> Self {
Self {
clarification,
session_id,
agent_id: agent_id.into(),
source: source.into(),
is_interactive,
context_label,
}
}
}
#[async_trait]
impl HumanChannel for SkaldHumanChannel {
async fn ask(&self, q: Question, events: &EventSink) -> Result<String, HumanGone> {
let label = self.context_label.read().ok().and_then(|g| g.clone());
let (request_id, rx) = self
.clarification
.register(
self.session_id,
&self.agent_id,
&self.source,
label.as_deref(),
&q.title,
&q.question,
q.suggested.clone(),
)
.await;
if self.is_interactive {
events.emit(q.frame, None, LoopEvent::Host(json!({
"type": "agent_question",
"request_id": request_id,
"tool_call_id": q.call.get(),
"title": q.title,
"question": q.question,
"suggested_answers": q.suggested,
})));
}
// The answer arrives via WS (resolve_question) or the Inbox REST. A
// session-wide cancel (WS drop) closes the channel → HumanGone → the
// tool suspends and the call stays pending for resume.
rx.await.map_err(|_| HumanGone)
}
}
// ── UpdateScratchpadTool ─────────────────────────────────────────────────────
/// The session-scoped shared blackboard (port of `dispatch_update_scratchpad`).
pub struct UpdateScratchpadTool {
pool: Arc<SqlitePool>,
sid: i64,
}
impl UpdateScratchpadTool {
pub fn new(pool: Arc<SqlitePool>, sid: i64) -> Self { Self { pool, sid } }
}
#[async_trait]
impl Tool for UpdateScratchpadTool {
fn name(&self) -> &str { crate::tools::tool_names::UPDATE_SCRATCHPAD }
fn definition(&self) -> Value {
crate::session::handler::update_scratchpad_tool_def()
}
async fn call(&self, args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
let key = args["key"].as_str().unwrap_or("").to_string();
let value = args["value"].as_str().unwrap_or("").to_string();
crate::db::scratchpad::upsert(&self.pool, self.sid, &key, &value)
.await
.map(|_| ToolOutput::Text(format!("Scratchpad updated: {key}")))
.map_err(|e| ToolFailure::Failed(e.to_string()))
}
}
// ── WriteTodosTool ───────────────────────────────────────────────────────────
/// Stateless checklist echo (port of `dispatch_write_todos`).
pub struct WriteTodosTool;
#[async_trait]
impl Tool for WriteTodosTool {
fn name(&self) -> &str { crate::tools::tool_names::WRITE_TODOS }
fn definition(&self) -> Value {
crate::session::handler::write_todos_tool_def()
}
async fn call(&self, args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
let items = args["todos"].as_array().ok_or_else(|| {
ToolFailure::Failed("`write_todos` requires a `todos` array. Re-send the full list, e.g. [{\"content\":\"...\",\"status\":\"pending\"}].".into())
})?;
if items.is_empty() {
return Err(ToolFailure::Failed("`todos` is empty — send at least one item, or omit the call entirely.".into()));
}
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;
}
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(ToolFailure::Failed("No valid todo items (every `content` was empty).".into()));
}
Ok(ToolOutput::Text(format!(
"Todo list ({total}): {done} done, {active} in progress, {pending} pending\n{body}",
total = lines.len(),
body = lines.join("\n"),
)))
}
}
// ── SkaldAskUserTool ─────────────────────────────────────────────────────────
/// The legacy `ask_user_clarification`: the crate's `AskUserTool` mechanics
/// (AwaitingHuman + Suspend) with Skald's exact legacy definition.
pub struct SkaldAskUserTool {
inner: agent_loop::human::AskUserTool,
}
impl SkaldAskUserTool {
pub fn new(channel: Arc<dyn HumanChannel>, store: Arc<dyn agent_loop::store::HistoryStore>) -> Self {
Self {
inner: agent_loop::human::AskUserTool::new(channel, store)
.with_name(crate::tools::tool_names::ASK_USER_CLARIFICATION),
}
}
}
#[async_trait]
impl Tool for SkaldAskUserTool {
fn name(&self) -> &str { crate::tools::tool_names::ASK_USER_CLARIFICATION }
fn definition(&self) -> Value {
crate::session::handler::ask_user_clarification_tool_def()
}
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
self.inner.call(args, ctx).await
}
}
// ── ExecuteTaskAliasTool ─────────────────────────────────────────────────────
/// The legacy `execute_task`: `mode=sync` (or unspecified) delegates to the
/// crate's `DelegateTool`; `mode=async` rides the legacy interface-tool
/// handler (ChatHub's task injection) until phase 3 wires `CronExecutor`.
pub struct ExecuteTaskAliasTool {
delegate: DelegateTool,
definition: Value,
async_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
}
impl ExecuteTaskAliasTool {
pub fn new(
delegate: DelegateTool,
definition: Value,
async_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
) -> Self {
Self { delegate, definition, async_handler }
}
}
#[async_trait]
impl Tool for ExecuteTaskAliasTool {
fn name(&self) -> &str { crate::tools::tool_names::EXECUTE_TASK }
fn definition(&self) -> Value { self.definition.clone() }
fn concurrency_safe(&self, args: &Value) -> bool {
args["mode"].as_str() != Some("async")
}
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
if args["mode"].as_str() == Some("async") {
let Some(handler) = &self.async_handler else {
return Err(ToolFailure::Failed(
"execute_task: async mode is not available in this session".into(),
));
};
return handler(args)
.await
.map(ToolOutput::Text)
.map_err(|e| ToolFailure::Failed(e.to_string()));
}
self.delegate.call(args, ctx).await
}
}
// ── LegacyInterfaceTool ──────────────────────────────────────────────────────
/// Wraps a ChatHub-provided `InterfaceTool` (definition + handler closure) as
/// a crate-native tool — interface tools keep their exact legacy behavior
/// during the migration.
pub struct LegacyInterfaceTool {
definition: Value,
handler: Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>,
}
impl LegacyInterfaceTool {
pub fn new(it: core_api::interface_tool::InterfaceTool) -> Self {
Self { definition: it.definition, handler: it.handler }
}
}
#[async_trait]
impl Tool for LegacyInterfaceTool {
fn name(&self) -> &str {
self.definition["function"]["name"].as_str().unwrap_or("")
}
fn definition(&self) -> Value { self.definition.clone() }
async fn call(&self, args: Value, _ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
(self.handler)(args)
.await
.map(ToolOutput::Text)
.map_err(|e| ToolFailure::Failed(e.to_string()))
}
}
@@ -0,0 +1,279 @@
//! `SkaldAgentCatalog` — the crate's `AgentCatalog` over `agents/*`
//! (port of `build_sub_agent_config`, blueprint §10): builds the child's
//! profile — its own prompt (never the parent's, B3), derived tool set
//! (root-only strip + sub-agent augmentation + approval visibility), own
//! strength selector (D14), own DTL-scoped assembler and activator.
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use agent_loop::context::ContextAssembler;
use agent_loop::delegate::{AgentCatalog, AgentKind, AgentProfile, AgentSummary, DelegateTool, ToolSelection};
use agent_loop::ids::FrameId;
use agent_loop::model::ModelHint;
use agent_loop::tool::Tool as LoopTool;
use agent_loop::activation::ActivateToolsTool;
use sqlx::SqlitePool;
use crate::approval::ApprovalManager;
use crate::clarification::ClarificationManager;
use crate::config::DatetimeConfig;
use crate::llm::LlmManager;
use crate::loop_adapters::activation::SkaldToolActivator;
use crate::loop_adapters::assembler::SkaldAssembler;
use crate::loop_adapters::builtins::{SkaldAskUserTool, SkaldHumanChannel};
use crate::loop_adapters::history::SqliteHistory;
use crate::loop_adapters::selector::SkaldSelector;
use crate::loop_adapters::system::AgentSystemContext;
use crate::loop_adapters::toolset::SkaldToolSet;
use crate::mcp::McpProvider;
use crate::tools::ToolRegistry;
use crate::tools::tool_names as tn;
/// Everything the catalog needs from the parent turn, captured at wiring time.
pub struct SkaldAgentCatalog {
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
session_id: i64,
source: String,
is_interactive: bool,
context_label: Arc<RwLock<Option<String>>>,
llm_manager: Arc<LlmManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
mcp: Arc<dyn McpProvider>,
registry: Arc<ToolRegistry>,
/// Parent turn's derived def lists (the child's base derives from these).
base_defs: Vec<serde_json::Value>,
config_defs: Arc<Vec<serde_json::Value>>,
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
core_tools: Vec<Arc<dyn crate::tools::Tool>>,
root_only: Vec<String>,
/// The delegate tool, injected post-construction (catalog ↔ delegate cycle).
delegate: RwLock<Option<Arc<DelegateTool>>>,
/// Per-turn assembler knobs shared with children.
datetime_config: DatetimeConfig,
max_history_messages: usize,
max_tool_result_chars: Option<usize>,
compactor_enabled: bool,
fs: Option<Arc<core_api::user_fs::UserFs>>,
project_root: Option<String>,
}
impl SkaldAgentCatalog {
#[allow(clippy::too_many_arguments)]
pub fn new(
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
session_id: i64,
source: String,
is_interactive: bool,
context_label: Arc<RwLock<Option<String>>>,
llm_manager: Arc<LlmManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
mcp: Arc<dyn McpProvider>,
registry: Arc<ToolRegistry>,
base_defs: Vec<serde_json::Value>,
config_defs: Arc<Vec<serde_json::Value>>,
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
root_only: Vec<String>,
datetime_config: DatetimeConfig,
max_history_messages: usize,
max_tool_result_chars: Option<usize>,
compactor_enabled: bool,
fs: Option<Arc<core_api::user_fs::UserFs>>,
project_root: Option<String>,
) -> Self {
let core_tools = registry.all_tools();
Self {
pool,
shared_pool,
user_id,
session_id,
source,
is_interactive,
context_label,
llm_manager,
approval,
clarification,
mcp,
registry,
base_defs,
config_defs,
memory_tools,
image_tools,
core_tools,
root_only,
delegate: RwLock::new(None),
datetime_config,
max_history_messages,
max_tool_result_chars,
compactor_enabled,
fs,
project_root,
}
}
/// Post-construction wiring of the delegate (the catalog ↔ delegate cycle).
pub fn set_delegate(&self, delegate: DelegateTool) {
*self.delegate.write().unwrap() = Some(Arc::new(delegate));
}
}
#[agent_loop::async_trait]
impl AgentCatalog for SkaldAgentCatalog {
async fn get(&self, id: &str, child_frame: FrameId) -> agent_loop::Result<AgentProfile> {
// Only `task` agents are dispatchable (rejects chat/system/unknown).
let meta = crate::agents::load_task_meta(id)
.map_err(|e| anyhow::anyhow!("{e}"))?;
// The child's own strength drives its selector (D14) — never the
// parent's resolved client.
let selector = Arc::new(SkaldSelector::new(self.llm_manager.clone(), meta.strength));
let model = meta.client.as_deref().map(ModelHint::name);
// The child's system context: its own prompt, no per-turn extras.
let context = Arc::new(AgentSystemContext {
agent_id: id.to_string(),
extra_static: None,
extra_dynamic: None,
tail_reminder: None,
substitutions: Default::default(),
pool: self.pool.clone(),
shared_pool: self.shared_pool.clone(),
user_id: self.user_id.clone(),
mcp: self.mcp.clone(),
project_root: self.project_root.clone(),
});
// The child's def list: parent's base minus root-only minus the
// re-derived augmentations (added back natively below), plus
// sub-agents-only tools, through the approval visibility filter.
let mut child_defs: Vec<serde_json::Value> = self
.base_defs
.iter()
.filter(|d| {
let name = d["function"]["name"].as_str().unwrap_or("");
!self.root_only.iter().any(|n| n == name)
&& name != tn::ASK_USER_CLARIFICATION
&& name != tn::EXECUTE_SUBTASK
&& name != tn::EXECUTE_TASK
})
.cloned()
.collect();
child_defs.extend(self.registry.openai_definitions_sub_agents_only());
{
let group_rules = crate::db::approval_rules::list_for_group(&self.shared_pool, None)
.await
.unwrap_or_default();
child_defs.retain(|def| {
let name = def["function"]["name"].as_str().unwrap_or("");
self.approval.is_tool_visible(&group_rules, name)
});
}
// Native child tools: clarification, sub-delegation (depth permitting),
// and the frame-scoped activate_tools with a FRESH grant set.
let child_grants: Arc<RwLock<HashSet<String>>> = Arc::new(RwLock::new(
crate::db::activated_tools::list_refs_stack(&self.pool, child_frame.get())
.await
.unwrap_or_default()
.into_iter()
.collect(),
));
let mut native: Vec<Arc<dyn LoopTool>> = Vec::new();
{
let channel = Arc::new(SkaldHumanChannel::new(
self.clarification.clone(),
self.session_id,
id,
&self.source,
self.is_interactive,
self.context_label.clone(),
));
native.push(Arc::new(SkaldAskUserTool::new(
channel,
Arc::new(SqliteHistory::new(self.pool.clone())),
)));
}
// `execute_subtask` only while the child can still recurse.
let delegate = self.delegate.read().unwrap().clone();
if let Some(d) = delegate {
native.push(Arc::new(d.as_ref().clone().with_name(tn::EXECUTE_SUBTASK)));
}
native.push(Arc::new(ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
self.pool.clone(),
self.mcp.clone(),
child_grants.clone(),
self.session_id,
Some(child_frame.get()),
)))));
let toolset: Arc<dyn agent_loop::tool::ToolSet> = Arc::new(
SkaldToolSet::new(
child_defs,
self.config_defs.clone(),
self.mcp.clone(),
child_grants,
self.memory_tools.clone(),
self.image_tools.clone(),
Vec::new(),
self.core_tools.clone(),
)
.with_native_all(native),
);
let assembler: Arc<dyn ContextAssembler> = Arc::new(SkaldAssembler {
pool: self.pool.clone(),
scratchpad_sid: self.session_id,
datetime_config: self.datetime_config.clone(),
max_history_messages: self.max_history_messages,
max_tool_result_chars: self.max_tool_result_chars,
compactor_enabled: self.compactor_enabled,
fs: self.fs.clone(),
activation: Some(crate::loop_adapters::activation::SkaldActivationSource::new(
self.pool.clone(),
self.mcp.clone(),
self.config_defs.clone(),
self.session_id,
Some(child_frame.get()),
)),
});
Ok(AgentProfile {
id: id.to_string(),
kind: AgentKind::Task,
context,
tools: ToolSelection::inherit(),
model,
selector: Some(selector),
assembler: Some(assembler),
toolset: Some(toolset),
})
}
async fn list(&self, kind: AgentKind) -> Vec<AgentSummary> {
if kind != AgentKind::Task {
return Vec::new();
}
crate::agents::discover()
.unwrap_or_default()
.into_iter()
.filter(|a| matches!(a.agent_type, crate::agents::AgentType::Task))
.map(|a| AgentSummary { id: a.id, kind, description: a.description })
.collect()
}
async fn on_child_closed(&self, frame: FrameId) {
// Stack-scoped activations are ephemeral — deleted on frame exit.
if let Err(e) = crate::db::activated_tools::delete_for_stack(&self.pool, frame.get()).await {
tracing::warn!(frame = %frame, error = %e, "catalog: failed to delete stack activations");
}
}
}
+151 -15
View File
@@ -12,16 +12,19 @@
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::sync::{Arc, Mutex};
use agent_loop::events::{EventSink, LoopEvent};
use agent_loop::gate::{Gate, GateDecision, PendingCall};
use agent_loop::store::{CallState, HistoryStore};
use core_api::user_fs::SharedFs;
use sqlx::SqlitePool;
use tokio::sync::RwLock;
use crate::approval::{ApprovalManager, GateResult};
use crate::run_context::RunContext;
use crate::session::handler::ApprovalDecision;
use crate::tools::{ToolRegistry, is_file_read_tool, is_file_write_tool};
use crate::tools::{ToolRegistry, is_file_read_tool, is_file_write_tool, tool_names as tn};
/// Everything the gate needs that the current loop keeps on the handler.
/// Shared by reference so phase-2 wiring shares the same cells.
@@ -35,7 +38,12 @@ pub struct ApprovalGate {
run_context: Arc<RwLock<Option<RunContext>>>,
pre_approved: Arc<Mutex<HashSet<i64>>>,
auto_deny: Arc<AtomicBool>,
context_label: Arc<RwLock<Option<String>>>,
context_label: Arc<std::sync::RwLock<Option<String>>>,
/// For the `PendingWrite` diff: owner pool (user-memory), shared pool
/// (shared-memory), and the caller's fs view (host paths).
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
fs: Option<SharedFs>,
}
impl ApprovalGate {
@@ -50,7 +58,10 @@ impl ApprovalGate {
run_context: Arc<RwLock<Option<RunContext>>>,
pre_approved: Arc<Mutex<HashSet<i64>>>,
auto_deny: Arc<AtomicBool>,
context_label: Arc<RwLock<Option<String>>>,
context_label: Arc<std::sync::RwLock<Option<String>>>,
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
fs: Option<SharedFs>,
) -> Self {
Self {
approval,
@@ -63,8 +74,130 @@ impl ApprovalGate {
pre_approved,
auto_deny,
context_label,
pool,
shared_pool,
fs,
}
}
/// Reads the current content of a file for the `PendingWrite` diff, routed
/// exactly like the fs-tools (memory notes → the right pool, everything
/// else → the caller's host workspace, containment-checked).
async fn read_current_content(&self, path: &str) -> Option<String> {
use crate::tools::fs::{MemScope, classify_memory, resolve_host_path};
if let Some(m) = classify_memory(path) {
let pool = match m.scope {
MemScope::User => &self.pool,
MemScope::Shared => &self.shared_pool,
};
return crate::db::memory_docs::get(pool, &m.rel)
.await.ok().flatten().map(|d| d.content);
}
let fs = self.fs.as_ref()?;
let abs = resolve_host_path(&fs.load(), path).ok()?;
tokio::fs::read_to_string(&abs).await.ok()
}
/// Computes what a file would look like after the tool runs, without
/// writing it. `None` if indeterminable (e.g. edit on a missing file).
async fn compute_new_content(&self, name: &str, args: &serde_json::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,
}
}
/// Emits the approval event for the tool kind: `PendingWrite` (via
/// `LoopEvent::Host`) for file-write tools and `execute_cmd`,
/// `ApprovalRequired` otherwise (port of `emit_approval_event`).
async fn emit_approval_event(
&self,
events: &EventSink,
call: &PendingCall,
request_id: i64,
) {
let name = call.name.as_str();
if is_file_write_tool(name) {
let path = call.args["path"].as_str().unwrap_or("").to_string();
let (old_content, new_content) = tokio::join!(
self.read_current_content(&path),
self.compute_new_content(name, &call.args),
);
if let Some(new_content) = new_content {
events.emit(call.frame, call.parent_frame, LoopEvent::Host(serde_json::json!({
"type": "pending_write",
"request_id": request_id,
"tool_call_id": call.id.get(),
"path": path,
"old_content": old_content,
"new_content": new_content,
})));
return;
}
} else if name == tn::EXECUTE_CMD {
let cmd = call.args["command"].as_str().unwrap_or("");
events.emit(call.frame, call.parent_frame, LoopEvent::Host(serde_json::json!({
"type": "pending_write",
"request_id": request_id,
"tool_call_id": call.id.get(),
"path": "$ execute_cmd",
"old_content": serde_json::Value::Null,
"new_content": format!("$ {cmd}"),
})));
return;
}
events.emit(call.frame, call.parent_frame, LoopEvent::ApprovalRequired {
id: call.id,
name: call.name.clone(),
args: call.args.clone(),
request_id,
});
}
}
#[agent_loop::async_trait]
@@ -95,7 +228,7 @@ impl Gate for ApprovalGate {
// (never overrides a Deny).
if matches!(gate, GateResult::Require) {
let path = call.args["path"].as_str().unwrap_or("");
let guard = self.run_context.read().map(|g| g.clone()).unwrap_or_default();
let guard = self.run_context.read().await.clone();
let dflt = RunContext::default();
let rc = guard.as_ref().unwrap_or(&dflt);
let pre_allowed = if is_file_read_tool(&call.name) {
@@ -144,12 +277,7 @@ impl Gate for ApprovalGate {
category,
)
.await;
events.emit(call.frame, None, LoopEvent::ApprovalRequired {
id: call.id,
name: call.name.clone(),
args: call.args.clone(),
});
let _ = request_id;
self.emit_approval_event(events, call, request_id).await;
match approve_rx.await {
Ok(ApprovalDecision::Approved) => GateDecision::Allow,
@@ -225,10 +353,13 @@ mod tests {
1,
"web",
None,
Arc::new(RwLock::new(None)),
Arc::new(tokio::sync::RwLock::new(None)),
Arc::new(Mutex::new(HashSet::new())),
Arc::new(AtomicBool::new(false)),
Arc::new(RwLock::new(None)),
Arc::new(std::sync::RwLock::new(None)),
pool.clone(),
pool.clone(),
None,
);
let (bus, _) = tokio::sync::broadcast::channel(16);
let events = EventSink::new(ConversationId::new("session:1"), bus);
@@ -237,6 +368,7 @@ mod tests {
name: "some_tool".into(),
args: json!({}),
frame: FrameId(frame.id),
parent_frame: None,
agent: "assistant".into(),
extensions: Extensions::new(),
};
@@ -287,10 +419,13 @@ mod tests {
1,
"cron", // background source: auto-deny
None,
Arc::new(RwLock::new(None)),
Arc::new(tokio::sync::RwLock::new(None)),
Arc::new(Mutex::new(HashSet::new())),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(None)),
Arc::new(std::sync::RwLock::new(None)),
pool.clone(),
pool.clone(),
None,
);
let (bus, _) = tokio::sync::broadcast::channel(16);
let events = EventSink::new(ConversationId::new("session:1"), bus);
@@ -299,6 +434,7 @@ mod tests {
name: "some_tool".into(),
args: json!({}),
frame: FrameId(frame.id),
parent_frame: None,
agent: "assistant".into(),
extensions: Extensions::new(),
};
@@ -184,6 +184,30 @@ impl HistoryStore for SqliteHistory {
Ok(())
}
async fn get_frame(&self, frame: FrameId) -> agent_loop::Result<Option<FrameRecord>> {
let row = sqlx::query_as::<_, (i64, i64, String, Option<String>, i64, Option<i64>, Option<String>)>(
"SELECT id, session_id, agent_id, agent_prompt, depth, parent_tool_call_id, terminated_at
FROM chat_sessions_stack
WHERE id = ?",
)
.bind(frame.get())
.fetch_optional(&*self.pool)
.await?;
Ok(row.map(|(id, sid, agent, prompt, depth, parent_call, terminated)| FrameRecord {
id: FrameId(id),
conversation: ConversationId::new(format!("session:{sid}")),
parent: None,
spec: FrameSpec {
agent,
prompt,
depth: depth as u32,
parent_call: parent_call.map(ToolCallId),
meta: Value::Null,
},
active: terminated.is_none(),
}))
}
async fn active_frames(&self, conv: &ConversationId) -> agent_loop::Result<Vec<FrameRecord>> {
let session_id = Self::session_id(conv)?;
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, i64, Option<i64>)>(
@@ -319,6 +343,24 @@ impl HistoryStore for SqliteHistory {
Ok(())
}
async fn get_call(&self, id: ToolCallId) -> agent_loop::Result<Option<StoredCall>> {
Ok(chat_llm_tools::get(&self.pool, id.get()).await?.map(Self::stored_call))
}
async fn set_call_extras(&self, id: ToolCallId, extras: Value) -> agent_loop::Result<()> {
// Map the known extras onto the dedicated columns (preview, media);
// unknown keys are dropped (the table has no generic blob).
if extras.get("preview_old").is_some() || extras.get("preview_new").is_some() {
let old = extras["preview_old"].as_str();
let new = extras["preview_new"].as_str();
chat_llm_tools::set_preview(&self.pool, id.get(), old, new).await?;
}
if let Some(media) = extras["media"].as_str() {
chat_llm_tools::set_media(&self.pool, id.get(), media).await?;
}
Ok(())
}
async fn calls_in_state(&self, frame: FrameId, states: &[CallState]) -> agent_loop::Result<Vec<StoredCall>> {
// All calls of the frame, filtered in Rust: a frame's call set is
// bounded, and a static query keeps sqlx's dynamic-SQL audit happy.
@@ -0,0 +1,61 @@
//! Skald's `LoopHooks`: the file-write diff preview bracket (pre: capture the
//! old content; post: capture the new one and persist via `set_call_extras`).
//! Port of the `execute_tool_call` preview bracketing (blueprint §10).
use std::collections::HashMap;
use std::sync::Mutex;
use agent_loop::events::PendingToolCall;
use agent_loop::hooks::{HookCtx, LoopHooks};
use agent_loop::store::CallOutcome;
use serde_json::json;
use crate::loop_adapters::preview::{PreviewContext, cap_preview, read_current_content};
use crate::tools::is_file_write_tool;
/// Captures before/after snapshots around file-write tools so the diff
/// renders inline and survives a reload.
pub struct SkaldWritePreviewHook {
ctx: PreviewContext,
/// old-content captured in `pre_tool_call`, consumed in `post_tool_call`.
pending: Mutex<HashMap<i64, Option<String>>>,
}
impl SkaldWritePreviewHook {
pub fn new(ctx: PreviewContext) -> Self {
Self { ctx, pending: Mutex::new(HashMap::new()) }
}
}
#[agent_loop::async_trait]
impl LoopHooks for SkaldWritePreviewHook {
async fn pre_tool_call(&self, call: &mut PendingToolCall, _ctx: &HookCtx) -> agent_loop::hooks::HookVerdict {
if is_file_write_tool(&call.name)
&& let Some(path) = call.arguments["path"].as_str()
{
let old = cap_preview(read_current_content(&self.ctx, path).await);
self.pending.lock().unwrap().insert(call.id.get(), old);
}
agent_loop::hooks::HookVerdict::Allow
}
async fn post_tool_call(&self, call: &PendingToolCall, outcome: &CallOutcome, ctx: &HookCtx) {
let Some(old) = self.pending.lock().unwrap().remove(&call.id.get()) else {
return;
};
let Some(path) = call.arguments["path"].as_str() else {
return;
};
// `new` is captured only on success — a failed/cancelled write shows
// no diff (the file may not exist in its intended form).
let new = if matches!(outcome, CallOutcome::Completed(_)) {
cap_preview(read_current_content(&self.ctx, path).await)
} else {
None
};
let _ = ctx
.store
.set_call_extras(call.id, json!({ "preview_old": old, "preview_new": new }))
.await;
}
}
@@ -0,0 +1,38 @@
//! `PendingUserInput` → the crate's `LiveInput` (D10 pull-based live input).
use std::sync::Arc;
use agent_loop::manager::LiveInput;
use agent_loop::store::NewMessage;
use crate::session::handler::PendingUserInput;
/// Drains the source's inbox into the running turn: one `NewMessage` per
/// queued user message, attachments/command metadata preserved.
pub struct PendingLiveInput {
inner: Arc<dyn PendingUserInput>,
}
impl PendingLiveInput {
pub fn new(inner: Arc<dyn PendingUserInput>) -> Self { Self { inner } }
}
#[agent_loop::async_trait]
impl LiveInput for PendingLiveInput {
async fn drain(&self) -> Vec<NewMessage> {
self.inner
.drain_user()
.await
.into_iter()
.map(|m| {
let mut msg = NewMessage::user(m.content);
if let Some(meta) = m.metadata
&& let Ok(v) = serde_json::to_value(meta)
{
msg.metadata = Some(v);
}
msg
})
.collect()
}
}
@@ -16,7 +16,16 @@
//! `activated_tools` table and the MCP provider (D15).
pub mod activation;
pub mod assembler;
pub mod builtins;
pub mod catalog;
pub mod gate;
pub mod history;
pub mod hooks;
pub mod live_input;
pub mod preview;
pub mod selector;
pub mod system;
pub mod toolset;
pub mod translate;
pub mod wiring;
@@ -0,0 +1,100 @@
//! File-write diff preview, shared by the approval gate (pre-approval diff)
//! and the write-preview hook (executed-write diff). Routes memory-vs-disk
//! exactly like the fs-tools.
use std::sync::Arc;
use core_api::user_fs::SharedFs;
use sqlx::SqlitePool;
use crate::tools::fs::{MemScope, classify_memory, resolve_host_path};
/// Max bytes captured per side of a file-write diff preview. Beyond this the
/// side is dropped (`None`) so a huge file never bloats a row or a WS payload.
pub const MAX_PREVIEW_BYTES: usize = 256 * 1024;
/// Drops a captured snapshot over the size cap (a truncated snapshot would
/// render a misleading diff).
pub fn cap_preview(s: Option<String>) -> Option<String> {
s.filter(|c| c.len() <= MAX_PREVIEW_BYTES)
}
/// The pieces a preview read needs: owner pool (user-memory), shared pool
/// (shared-memory), and the caller's fs view (host paths).
#[derive(Clone)]
pub struct PreviewContext {
pub pool: Arc<SqlitePool>,
pub shared_pool: Arc<SqlitePool>,
pub fs: Option<SharedFs>,
}
/// Reads the current content of a file for a diff, routed exactly like the
/// fs-tools. A resolve failure or a missing note/file yields `None`
/// (rendered as "new file").
pub async fn read_current_content(ctx: &PreviewContext, path: &str) -> Option<String> {
if let Some(m) = classify_memory(path) {
let pool = match m.scope {
MemScope::User => &ctx.pool,
MemScope::Shared => &ctx.shared_pool,
};
return crate::db::memory_docs::get(pool, &m.rel)
.await.ok().flatten().map(|d| d.content);
}
let fs = ctx.fs.as_ref()?;
let abs = resolve_host_path(&fs.load(), path).ok()?;
tokio::fs::read_to_string(&abs).await.ok()
}
/// Computes what a file would look like after the tool runs, without writing
/// it. `None` if indeterminable (e.g. edit on a missing file).
pub async fn compute_new_content(ctx: &PreviewContext, name: &str, args: &serde_json::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 = read_current_content(ctx, 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 = read_current_content(ctx, 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 = read_current_content(ctx, 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,186 @@
//! `AgentSystemContext` — Skald's agent prompt as a `SystemContextSource`
//! (the static half of the old `MessageBuilder::build`, blueprint §10):
//! AGENT.md + `inject_memory` files + skills index + `extra_system` +
//! `__MCP_LIST__` / `__SHARED_FOLDERS__` / `__USER_PROFILE__` / custom
//! substitutions. The dynamic tail (Honcho memory, per-turn overrides) rides
//! as `dynamic_tail`; the datetime line and scratchpad stay assembler-side.
use std::collections::HashMap;
use std::sync::Arc;
use agent_loop::context::{SystemContext, SystemContextSource, TurnInfo};
use sqlx::SqlitePool;
use crate::mcp::McpProvider;
/// 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";
/// The static system content of one agent, resolved per turn.
pub struct AgentSystemContext {
pub agent_id: String,
/// Static extra context (interface formatting rules, e.g. Telegram HTML).
pub extra_static: Option<String>,
/// Dynamic extra context (Honcho memory merged with per-turn overrides),
/// emitted as the dynamic tail.
pub extra_dynamic: Option<String>,
pub tail_reminder: Option<String>,
pub substitutions: HashMap<String, String>,
/// Owner pool (`user-memory/` notes).
pub pool: Arc<SqlitePool>,
/// Shared pool (`shared-memory/`, shared folders, user profile).
pub shared_pool: Arc<SqlitePool>,
pub user_id: String,
pub mcp: Arc<dyn McpProvider>,
/// Project root for `__PROJECT_ROOT__` expansion in `inject_memory`.
pub project_root: Option<String>,
}
#[agent_loop::async_trait]
impl SystemContextSource for AgentSystemContext {
async fn system_context(&self, _turn: &TurnInfo) -> agent_loop::Result<SystemContext> {
let mut static_content = crate::agents::load_prompt(&self.agent_id)?;
let meta = crate::agents::load_meta(&self.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 {
let (content, display) = self.load_inject_memory(mem_path).await;
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 unless the agent opts out. 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) = &self.extra_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());
}
if static_content.contains("__SHARED_FOLDERS__") {
static_content = static_content.replace(
"__SHARED_FOLDERS__",
&crate::session::handler::message_builder::render_shared_folders_section(
&self.shared_pool,
&self.user_id,
)
.await?,
);
}
if static_content.contains("__USER_PROFILE__") {
static_content = static_content.replace(
"__USER_PROFILE__",
&crate::session::handler::message_builder::render_user_profile_section(
&self.shared_pool,
&self.user_id,
)
.await?,
);
}
for (key, value) in &self.substitutions {
let sentinel = format!("__{key}__");
if static_content.contains(sentinel.as_str()) {
static_content = static_content.replace(sentinel.as_str(), value);
}
}
Ok(SystemContext {
base: static_content,
extra_static: Vec::new(),
dynamic_tail: self.extra_dynamic.clone().into_iter().collect(),
tail_reminder: self.tail_reminder.clone(),
})
}
}
impl AgentSystemContext {
/// Loads an `inject_memory` entry, returning `(content, display_path)`.
/// Virtual memory paths read from SQLite; everything else is a disk read.
async fn load_inject_memory(&self, mem_path: &str) -> (Option<String>, String) {
use crate::tools::fs::{MemScope, classify_memory};
if let Some(m) = classify_memory(mem_path) {
let pool = match m.scope {
MemScope::User => &self.pool,
MemScope::Shared => &self.shared_pool,
};
let content = crate::db::memory_docs::get(pool, &m.rel)
.await.ok().flatten().map(|d| d.content);
return (content, mem_path.to_string());
}
let (abs, display) = self.resolve_memory_path(mem_path);
(tokio::fs::read_to_string(&abs).await.ok(), display)
}
fn resolve_memory_path(&self, mem_path: &str) -> (std::path::PathBuf, String) {
let display = if mem_path.contains("__PROJECT_ROOT__") {
match &self.project_root {
Some(root) => mem_path.replace("__PROJECT_ROOT__", root),
None => {
tracing::warn!(
mem_path,
"inject_memory entry references __PROJECT_ROOT__ but this session has no project root; skipping"
);
return (std::path::PathBuf::from(mem_path), mem_path.to_string());
}
}
} else {
mem_path.to_string()
};
let abs = crate::tools::fs::resolve(&display)
.unwrap_or_else(|_| std::path::PathBuf::from(&display));
(abs, display)
}
/// The **static** catalogue of loadable MCP servers (identical regardless
/// of which are active — cache-prefix stability).
fn render_mcp_list(&self) -> 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 mut out = String::from(
"## MCP servers\n\nConnectors you can load with `activate_tools([\"name\"])`. \
Once loaded, a server's tools are callable as `mcp__<name>__<tool>`:\n\n",
);
out.push_str("| Server | Description |\n|--------|-------------|\n");
for name in &all_servers {
let desc = descriptions.get(name)
.and_then(|d| d.as_deref())
.unwrap_or("");
out.push_str(&format!("| `{name}` | {desc} |\n"));
}
out
}
}
+25 -3
View File
@@ -201,7 +201,7 @@ impl LoopTool for McpToolBridge {
/// activated at round N are visible at round N+1 for free.
pub struct SkaldToolSet {
base_defs: Vec<Value>,
config_defs: Vec<Value>,
config_defs: Arc<Vec<Value>>,
mcp: Arc<dyn McpProvider>,
grants: Arc<RwLock<HashSet<String>>>,
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
@@ -212,13 +212,15 @@ pub struct SkaldToolSet {
core_tools: Vec<Arc<dyn crate::tools::Tool>>,
/// Extra crate-native tools for find() (bridge-free).
native_tools: Vec<Arc<dyn LoopTool>>,
/// Records tools offered to the LLM each round (Security-groups UI).
discovery: Option<Arc<crate::tool_discovery::ToolDiscovery>>,
}
impl SkaldToolSet {
#[allow(clippy::too_many_arguments)]
pub fn new(
base_defs: Vec<Value>,
config_defs: Vec<Value>,
config_defs: Arc<Vec<Value>>,
mcp: Arc<dyn McpProvider>,
grants: Arc<RwLock<HashSet<String>>>,
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
@@ -236,13 +238,24 @@ impl SkaldToolSet {
interface_tools,
core_tools,
native_tools: Vec::new(),
discovery: None,
}
}
pub fn with_discovery(mut self, discovery: Arc<crate::tool_discovery::ToolDiscovery>) -> Self {
self.discovery = Some(discovery);
self
}
pub fn with_native(mut self, tool: Arc<dyn LoopTool>) -> Self {
self.native_tools.push(tool);
self
}
pub fn with_native_all(mut self, tools: Vec<Arc<dyn LoopTool>>) -> Self {
self.native_tools.extend(tools);
self
}
}
/// Tags an OpenAI tool definition as deferred (Anthropic tool search).
@@ -285,6 +298,15 @@ impl ToolSet for SkaldToolSet {
defs.extend(self.image_tools.iter().map(|t| t.openai_definition()));
defs.extend(self.interface_tools.iter().map(|t| t.definition.clone()));
defs.extend(self.native_tools.iter().map(|t| t.definition()));
// Dedup by name (first wins): the host's base/interface defs already
// carry the built-ins (scratchpad/todos/ask_user/activate_tools), and
// the native aliases provide the same names for find() — the wire must
// never carry duplicates (OpenAI-compat APIs 400 on them).
let mut seen = std::collections::HashSet::new();
defs.retain(|d| seen.insert(d["function"]["name"].as_str().unwrap_or("").to_string()));
if let Some(discovery) = &self.discovery {
discovery.observe(&defs);
}
defs
}
@@ -365,7 +387,7 @@ mod tests {
fn toolset(grants: Arc<RwLock<HashSet<String>>>) -> SkaldToolSet {
SkaldToolSet::new(
vec![serde_json::json!({"type":"function","function":{"name":"read_file","parameters":{}}})],
vec![serde_json::json!({"type":"function","function":{"name":"cron_list","parameters":{}}})],
Arc::new(vec![serde_json::json!({"type":"function","function":{"name":"cron_list","parameters":{}}})]),
fake_mcp("gmail", &["send"]),
grants,
vec![],
@@ -0,0 +1,307 @@
//! The `LoopEvent → ServerEvent` translator (blueprint §10): ONE subscriber of
//! the loop manager's bus, forwarding to the session's WS channel with the
//! host enrichments the frontend expects (display meta, diff previews, file
//! changes). Byte-parity with the old `TurnEmitter` sequence is the contract.
use std::sync::Arc;
use agent_loop::events::{DeltaKind, Event, LoopEvent};
use agent_loop::store::{CallOutcome, HistoryStore};
use core_api::message_meta::MessageMetadata;
use serde_json::Value;
use tokio::sync::mpsc;
use crate::events::{ServerEvent, TokenDeltaKind};
use crate::mcp::McpProvider;
use crate::tools::{ToolRegistry, is_file_write_tool};
/// Forwards one conversation's loop events to the session's WS `tx`.
pub struct EventTranslator {
tx: mpsc::Sender<ServerEvent>,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
store: Arc<dyn HistoryStore>,
shared: Arc<std::sync::Mutex<TranslateShared>>,
}
/// Turn state the wiring reads back after join (ChatEvent publication).
#[derive(Default)]
pub struct TranslateShared {
/// The user message id that opened the turn.
pub user_message_id: Option<i64>,
/// Accumulated tool calls of the turn (done/failed only — mirrors the old
/// `all_tool_calls` accumulate rules).
pub tool_calls: Vec<core_api::bus::ToolCallEvent>,
}
impl EventTranslator {
pub fn new(
tx: mpsc::Sender<ServerEvent>,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
store: Arc<dyn HistoryStore>,
) -> (Self, Arc<std::sync::Mutex<TranslateShared>>) {
let shared = Arc::new(std::sync::Mutex::new(TranslateShared::default()));
(Self { tx, tools, mcp, store, shared: shared.clone() }, shared)
}
/// Subscribe and forward until `stop` is cancelled (the turn's end).
pub fn spawn(self, mut rx: tokio::sync::broadcast::Receiver<Event<LoopEvent>>, stop: tokio_util::sync::CancellationToken) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::select! {
_ = stop.cancelled() => break,
ev = rx.recv() => {
match ev {
Ok(ev) => self.forward(ev).await,
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "event translator lagged; some events were dropped");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
}
})
}
async fn emit(&self, ev: ServerEvent) {
self.tx.send(ev).await.ok();
}
pub async fn forward(&self, ev: Event<LoopEvent>) {
let is_root = ev.parent_frame.is_none();
match ev.inner {
LoopEvent::TurnStarted | LoopEvent::RoundStarted { .. } | LoopEvent::AsyncResultReady { .. } => {}
LoopEvent::UserMessage { message_id, content, synthetic, metadata } => {
// The turn-opening user message (root, non-synthetic) is
// recorded for the wiring's ChatEvent publication.
if is_root && !synthetic {
let mut g = self.shared.lock().unwrap();
if g.user_message_id.is_none() {
g.user_message_id = Some(message_id.get());
}
}
if synthetic {
return;
}
let meta: Option<MessageMetadata> = metadata
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
let attachments = meta.as_ref().map(|m| m.attachments.clone()).unwrap_or_default();
// A custom slash command persists its expanded template (for
// LLM replay) but the bubble shows the typed command.
let echo = meta
.and_then(|m| m.command.map(|c| c.display))
.unwrap_or(content);
self.emit(ServerEvent::UserMessage { message_id: message_id.get(), content: echo, attachments }).await;
}
LoopEvent::TokenDelta { kind, text } => {
let kind = match kind {
DeltaKind::Content => TokenDeltaKind::Content,
DeltaKind::Reasoning => TokenDeltaKind::Reasoning,
};
self.emit(ServerEvent::TokenDelta { kind, delta: text }).await;
}
LoopEvent::Thinking { message_id, content, usage, reasoning } => {
self.emit(ServerEvent::Thinking {
message_id: message_id.get(),
content,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
reasoning_content: reasoning,
}).await;
}
LoopEvent::Done { message_id, content, usage, reasoning } => {
if !is_root {
return; // a child's completion rides AgentFinished
}
self.emit(ServerEvent::Done {
message_id: message_id.get(),
stack_id: ev.frame.get(),
content,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
reasoning_content: reasoning,
}).await;
}
LoopEvent::Truncated { output_tokens } => {
if is_root {
self.emit(ServerEvent::Truncated { output_tokens }).await;
}
}
LoopEvent::ToolCallStarted { id, message_id, name, args } => {
let (display_name, icon) = self.ui_meta(&name, &args);
let label_short = self.tools.describe_call(&name, &args, core_api::tool::ToolDescriptionLength::Short);
let label_full = self.tools.describe_call(&name, &args, core_api::tool::ToolDescriptionLength::Full);
let path = self.tools.target_path(&name, &args);
self.emit(ServerEvent::ToolStart {
tool_call_id: id.get(),
message_id: message_id.get(),
name,
arguments: args,
display_name,
icon,
label_short,
label_full,
path,
}).await;
}
LoopEvent::ToolCallFinished { id, outcome } => match outcome {
CallOutcome::Completed(out) => {
let stored = self.store.get_call(id).await.ok().flatten();
if let Some(c) = stored.as_ref() {
self.shared.lock().unwrap().tool_calls.push(core_api::bus::ToolCallEvent {
name: c.name.clone(),
arguments: Some(serde_json::to_string(&c.arguments).unwrap_or_default()),
result: Some(out.to_wire()),
status: "done".to_string(),
});
}
let (preview_old, preview_new) = stored
.as_ref()
.map(|c| (
c.extras["preview_old"].as_str().map(str::to_string),
c.extras["preview_new"].as_str().map(str::to_string),
))
.unwrap_or((None, None));
self.emit(ServerEvent::ToolDone {
tool_call_id: id.get(),
result: out.to_wire(),
result_type: out.kind().to_string(),
preview_old,
preview_new,
}).await;
// A successful file-write asks clients holding the file to reload.
if let Some(c) = stored
&& is_file_write_tool(&c.name)
&& let Some(p) = c.arguments["path"].as_str()
{
self.emit(ServerEvent::FileChanged { path: crate::approval::normalize_path(p) }).await;
}
}
CallOutcome::Failed(error) => {
let stored = self.store.get_call(id).await.ok().flatten();
if let Some(c) = stored.as_ref() {
self.shared.lock().unwrap().tool_calls.push(core_api::bus::ToolCallEvent {
name: c.name.clone(),
arguments: Some(serde_json::to_string(&c.arguments).unwrap_or_default()),
result: Some(error.clone()),
status: "failed".to_string(),
});
}
self.emit(ServerEvent::ToolError { tool_call_id: id.get(), error }).await;
}
CallOutcome::Cancelled => {
self.emit(ServerEvent::ToolCancelled { tool_call_id: id.get() }).await;
}
CallOutcome::Rejected { reason } => {
self.emit(ServerEvent::ToolRejected { tool_call_id: id.get(), reason }).await;
}
},
LoopEvent::ApprovalRequired { id, name, args, request_id } => {
self.emit(ServerEvent::ApprovalRequired {
request_id,
tool_call_id: id.get(),
tool_name: name,
arguments: args,
}).await;
}
LoopEvent::AgentSpawned { frame, agent, depth, prompt_preview, parent_call, parent_agent } => {
self.emit(ServerEvent::AgentStart {
stack_id: frame.get(),
parent_tool_call_id: parent_call.get(),
agent_id: agent,
parent_agent_id: parent_agent,
depth: depth as i64,
prompt_preview,
}).await;
}
LoopEvent::AgentFinished { frame, agent, result_preview, parent_agent } => {
self.emit(ServerEvent::AgentDone {
stack_id: frame.get(),
agent_id: agent,
parent_agent_id: parent_agent,
result_preview,
}).await;
}
LoopEvent::ModelFallback { from, to, reason } => {
self.emit(ServerEvent::ModelFallback { from, to, reason: first_line(&reason) }).await;
}
LoopEvent::LlmFailed { tried, last_error } => {
self.emit(ServerEvent::LlmFailed { tried, last_error }).await;
}
LoopEvent::Compacted { .. } => {}
LoopEvent::Error(message) => {
self.emit(ServerEvent::Error { message }).await;
}
LoopEvent::Cancelled => {
if is_root {
self.emit(ServerEvent::Error { message: "Cancelled by user.".to_string() }).await;
}
}
LoopEvent::Host(v) => self.forward_host(v).await,
}
}
/// Host-escaped events (blueprint §4.9): `pending_write` from the
/// approval gate, `agent_question` from the human channel.
async fn forward_host(&self, v: Value) {
match v["type"].as_str() {
Some("pending_write") => {
self.emit(ServerEvent::PendingWrite {
request_id: v["request_id"].as_i64().unwrap_or_default(),
tool_call_id: v["tool_call_id"].as_i64().unwrap_or_default(),
path: v["path"].as_str().unwrap_or_default().to_string(),
old_content: v["old_content"].as_str().map(str::to_string),
new_content: v["new_content"].as_str().unwrap_or_default().to_string(),
}).await;
}
Some("agent_question") => {
self.emit(ServerEvent::AgentQuestion {
request_id: v["request_id"].as_i64().unwrap_or_default(),
tool_call_id: v["tool_call_id"].as_i64().unwrap_or_default(),
title: v["title"].as_str().unwrap_or_default().to_string(),
question: v["question"].as_str().unwrap_or_default().to_string(),
suggested_answers: v["suggested_answers"]
.as_array()
.map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect())
.unwrap_or_default(),
}).await;
}
_ => {}
}
}
/// `(display_name, icon)` for a tool card, with the MCP friendly-name
/// override (mirrors `tool_ui_meta`).
fn ui_meta(&self, name: &str, args: &Value) -> (String, String) {
let mut meta = self.tools.display_meta(name, args);
if let Some((server, tool)) = crate::mcp::parse_mcp_tool_name(name)
&& let Some(friendly) = self.mcp.tool_display_name(server, tool)
{
meta.display_name = friendly;
}
(meta.display_name, meta.icon)
}
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or(s).to_string()
}