Files
Skald-Circle/crates/skald-core/src/loop_adapters/builtins.rs
T
dguiducci 0297fe71bd 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.
2026-07-26 12:15:53 +01:00

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()))
}
}