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.
274 lines
10 KiB
Rust
274 lines
10 KiB
Rust
//! 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()))
|
|
}
|
|
}
|