agent-loop: Skald adapters behind the crate traits (phase 1)
New skald-core::loop_adapters module — implements the agent-loop trait
surface over existing infrastructure, unused by the current loop (wired
in phase 2):
- SqliteHistory: HistoryStore over chat_sessions_stack/chat_history/
chat_llm_tools/chat_summaries, no schema change; CallState maps 1:1 on
the existing status strings; wire call ids synthesized as tc_{id}
- SkaldSelector: ModelSelector over LlmManager with the agent's strength
captured per-turn (D14); DtlMode → ToolRendering mapping (D15)
- SkaldActivationSource + SkaldToolActivator: DTL catalog + persistence
(activated_tools, anchored at the triggering message) behind the
crate's protocol traits; unifies the grants/persistence split
- ApprovalGate: port of run_approval_gate (pre-approved, engine, fs
fast-path, auto-deny, AwaitingHuman + block on human); a closed human
channel maps to the new GateDecision::Suspend in agent-loop
- SkaldToolSet + CoreToolBridge/McpToolBridge: core-api and MCP tools
run inside the crate's kernel (execution bridged, execute_cmd keeps
its teardown; D7 MarkInterrupted for shell)
- agent-loop: re-export async_trait at root; EventSink::new made public
17 adapter tests green (temp-DB integration); full workspace suite green
(pre-existing honcho-client doc-test failure untouched: missing dev-deps).
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
//! `SkaldToolSet` — the crate's `ToolSet` over Skald's tool surface (port of
|
||||
//! `AgentRunConfig::all_tool_defs`, blueprint §10), plus the bridges that let
|
||||
//! core-api tools and MCP tools run inside the crate's kernel (the "double
|
||||
//! Tool trait" seam of phase 1: bridged, not re-exported).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use agent_loop::activation::ToolRendering;
|
||||
use agent_loop::async_trait;
|
||||
use agent_loop::model::ModelInfo;
|
||||
use agent_loop::tool::{
|
||||
MediaRef, RestartHint, Tool as LoopTool, ToolCtx, ToolExecution, ToolFailure,
|
||||
ToolOutput, ToolSet, Visibility,
|
||||
};
|
||||
use core_api::interface_tool::InterfaceTool;
|
||||
use core_api::tool::{ExecutionOutcome as CoreOutcome, ToolExecutionState as CoreState};
|
||||
use core_api::user_fs::UserFs;
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::mcp::McpProvider;
|
||||
use crate::tools::tool_names::CONFIG_GROUP;
|
||||
|
||||
// ── Extension keys ───────────────────────────────────────────────────────────
|
||||
|
||||
/// The calling user's id — tools that address per-user external stores key on
|
||||
/// it. Inserted by the host at TurnParams construction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CallerUserId(pub String);
|
||||
|
||||
/// Reads the `core_api::tool::ToolContext` pieces out of a `ToolCtx`:
|
||||
/// owner pool + fs from the type-map, session id from the conversation.
|
||||
fn core_tool_context(ctx: &ToolCtx) -> Result<core_api::tool::ToolContext, ToolFailure> {
|
||||
let pool = ctx.extensions.get::<SqlitePool>().ok_or_else(|| {
|
||||
ToolFailure::Failed("tool bridge: no SqlitePool in extensions".into())
|
||||
})?;
|
||||
let fs = ctx.extensions.get::<UserFs>().ok_or_else(|| {
|
||||
ToolFailure::Failed("tool bridge: no UserFs in extensions".into())
|
||||
})?;
|
||||
let user_id = ctx
|
||||
.extensions
|
||||
.get::<CallerUserId>()
|
||||
.map(|u| u.0.clone())
|
||||
.unwrap_or_default();
|
||||
let session_id = ctx
|
||||
.conversation
|
||||
.as_str()
|
||||
.strip_prefix("session:")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or_default();
|
||||
Ok(core_api::tool::ToolContext { session_id, user_id, pool, fs })
|
||||
}
|
||||
|
||||
/// Maps a core-api `ToolResult` to the crate's `ToolOutput`.
|
||||
fn map_output(r: core_api::tool::ToolResult) -> ToolOutput {
|
||||
match r {
|
||||
core_api::tool::ToolResult::Text(s) => ToolOutput::Text(s),
|
||||
core_api::tool::ToolResult::Json(v) => ToolOutput::Json(v),
|
||||
core_api::tool::ToolResult::Media { text, media } => ToolOutput::Media {
|
||||
text,
|
||||
refs: media
|
||||
.iter()
|
||||
.map(|m| MediaRef { host_path: m.host_path.clone(), mime: m.mime.clone() })
|
||||
.collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── BridgeExecution ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Wraps a core-api `ToolExecution` as the crate's `ToolExecution` (the two
|
||||
/// state machines are structurally identical).
|
||||
struct BridgeExecution<'a> {
|
||||
inner: Box<dyn core_api::tool::ToolExecution + 'a>,
|
||||
}
|
||||
|
||||
impl ToolExecution for BridgeExecution<'_> {
|
||||
fn state(&self) -> agent_loop::tool::ToolExecutionState {
|
||||
match self.inner.state() {
|
||||
CoreState::Pending | CoreState::AwaitingApproval | CoreState::Running => {
|
||||
agent_loop::tool::ToolExecutionState::Running
|
||||
}
|
||||
CoreState::Completed => agent_loop::tool::ToolExecutionState::Completed,
|
||||
CoreState::Failed => agent_loop::tool::ToolExecutionState::Failed,
|
||||
CoreState::Cancelled | CoreState::Rejected => agent_loop::tool::ToolExecutionState::Cancelled,
|
||||
}
|
||||
}
|
||||
|
||||
fn wait<'b>(&'b self) -> std::pin::Pin<Box<dyn std::future::Future<Output = agent_loop::tool::ExecutionOutcome> + Send + 'b>> {
|
||||
Box::pin(async move {
|
||||
match self.inner.wait().await {
|
||||
CoreOutcome::Completed(r) => agent_loop::tool::ExecutionOutcome::Completed(map_output(r)),
|
||||
CoreOutcome::Failed(e) => agent_loop::tool::ExecutionOutcome::Failed(e),
|
||||
CoreOutcome::Cancelled => agent_loop::tool::ExecutionOutcome::Cancelled,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn stop<'b>(&'b self) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'b>> {
|
||||
self.inner.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── CoreToolBridge ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs a core-api tool (`crate::tools::Tool`) inside the crate's kernel:
|
||||
/// context from the type-map, execution bridged (kill/teardown preserved —
|
||||
/// `execute_cmd`'s reaper keeps working through `stop`).
|
||||
pub struct CoreToolBridge {
|
||||
inner: Arc<dyn crate::tools::Tool>,
|
||||
}
|
||||
|
||||
impl CoreToolBridge {
|
||||
pub fn new(inner: Arc<dyn crate::tools::Tool>) -> Self { Self { inner } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LoopTool for CoreToolBridge {
|
||||
fn name(&self) -> &str { self.inner.name() }
|
||||
|
||||
fn definition(&self) -> Value { self.inner.openai_definition() }
|
||||
|
||||
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
|
||||
// Same path as `start`, driven to completion without a cancel token.
|
||||
let exec = self.start(args, ctx);
|
||||
match exec.wait().await {
|
||||
agent_loop::tool::ExecutionOutcome::Completed(out) => Ok(out),
|
||||
agent_loop::tool::ExecutionOutcome::Failed(e) => Err(ToolFailure::Failed(e)),
|
||||
agent_loop::tool::ExecutionOutcome::Cancelled |
|
||||
agent_loop::tool::ExecutionOutcome::Suspended => {
|
||||
Err(ToolFailure::Failed("tool execution interrupted".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start<'a>(&'a self, args: Value, ctx: &'a ToolCtx) -> Box<dyn ToolExecution + 'a> {
|
||||
match core_tool_context(ctx) {
|
||||
Ok(tool_ctx) => Box::new(BridgeExecution { inner: self.inner.run_with(&tool_ctx, args) }),
|
||||
Err(e) => Box::new(agent_loop::tool::SimpleExecution::new(Box::pin(async move { Err(e) }))),
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_hint(&self) -> RestartHint {
|
||||
// D7: shell commands are not idempotent — never re-run them on restart.
|
||||
if self.inner.name() == "execute_cmd" {
|
||||
RestartHint::MarkInterrupted
|
||||
} else {
|
||||
RestartHint::ReExecute
|
||||
}
|
||||
}
|
||||
|
||||
fn visibility(&self) -> Visibility {
|
||||
if self.inner.root_agent_only() {
|
||||
Visibility::RootOnly
|
||||
} else if self.inner.sub_agents_only() {
|
||||
Visibility::SubAgentsOnly
|
||||
} else if self.inner.interactive_only() {
|
||||
Visibility::InteractiveOnly
|
||||
} else {
|
||||
Visibility::Always
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── McpToolBridge ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs one MCP tool (`mcp__server__tool`) inside the crate's kernel.
|
||||
pub struct McpToolBridge {
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
server: String,
|
||||
tool: String,
|
||||
definition: Value,
|
||||
}
|
||||
|
||||
impl McpToolBridge {
|
||||
pub fn new(mcp: Arc<dyn McpProvider>, server: impl Into<String>, tool: impl Into<String>, definition: Value) -> Self {
|
||||
Self { mcp, server: server.into(), tool: tool.into(), definition }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LoopTool for McpToolBridge {
|
||||
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> {
|
||||
match self.mcp.call(&self.server, &self.tool, args).await {
|
||||
Ok(r) => Ok(map_output(r)),
|
||||
Err(e) => Err(ToolFailure::Failed(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SkaldToolSet ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The per-turn tool set: base built-ins + MCP grants + the lazy `config`
|
||||
/// group + memory/image/interface tools, rendered per the model's
|
||||
/// `ToolRendering` (D15). `defs` is re-read at every round/attempt — grants
|
||||
/// activated at round N are visible at round N+1 for free.
|
||||
pub struct SkaldToolSet {
|
||||
base_defs: Vec<Value>,
|
||||
config_defs: Vec<Value>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
grants: Arc<RwLock<HashSet<String>>>,
|
||||
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
/// Crate-native tools (ActivateToolsTool, aliases) — returned as-is.
|
||||
interface_tools: Vec<InterfaceTool>,
|
||||
/// Core tools available for execution by name (the find() side).
|
||||
core_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
/// Extra crate-native tools for find() (bridge-free).
|
||||
native_tools: Vec<Arc<dyn LoopTool>>,
|
||||
}
|
||||
|
||||
impl SkaldToolSet {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
base_defs: Vec<Value>,
|
||||
config_defs: Vec<Value>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
grants: Arc<RwLock<HashSet<String>>>,
|
||||
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
interface_tools: Vec<InterfaceTool>,
|
||||
core_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_defs,
|
||||
config_defs,
|
||||
mcp,
|
||||
grants,
|
||||
memory_tools,
|
||||
image_tools,
|
||||
interface_tools,
|
||||
core_tools,
|
||||
native_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_native(mut self, tool: Arc<dyn LoopTool>) -> Self {
|
||||
self.native_tools.push(tool);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Tags an OpenAI tool definition as deferred (Anthropic tool search).
|
||||
fn deferred(mut def: Value) -> Value {
|
||||
def["defer_loading"] = Value::Bool(true);
|
||||
def
|
||||
}
|
||||
|
||||
impl ToolSet for SkaldToolSet {
|
||||
fn defs(&self, model: &ModelInfo) -> Vec<Value> {
|
||||
let mut defs = self.base_defs.clone();
|
||||
|
||||
match model.tool_rendering {
|
||||
// Declare EVERY accessible MCP tool + the config group as
|
||||
// `defer_loading:true` — a stable, cache-safe set.
|
||||
ToolRendering::DeferredToolReference => {
|
||||
defs.extend(self.mcp.tools().iter().map(|t| deferred(t.to_openai_definition())));
|
||||
defs.extend(self.config_defs.iter().cloned().map(deferred));
|
||||
}
|
||||
// Activated tools are injected as `system`+`tools` messages by the
|
||||
// assembler — NOT in the top-level array.
|
||||
ToolRendering::SystemToolBlock => {}
|
||||
ToolRendering::Inline => {
|
||||
let granted: HashSet<String> = self.grants.read().map(|g| g.clone()).unwrap_or_default();
|
||||
let servers: Vec<String> = granted
|
||||
.iter()
|
||||
.filter(|n| n.as_str() != CONFIG_GROUP)
|
||||
.cloned()
|
||||
.collect();
|
||||
if !servers.is_empty() {
|
||||
defs.extend(self.mcp.tools_for(&servers).iter().map(|t| t.to_openai_definition()));
|
||||
}
|
||||
if granted.contains(CONFIG_GROUP) {
|
||||
defs.extend(self.config_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.extend(self.native_tools.iter().map(|t| t.definition()));
|
||||
defs
|
||||
}
|
||||
|
||||
fn find(&self, name: &str) -> Option<Arc<dyn LoopTool>> {
|
||||
if let Some(t) = self.native_tools.iter().find(|t| t.name() == name) {
|
||||
return Some(t.clone());
|
||||
}
|
||||
if let Some(t) = self.core_tools.iter().find(|t| t.name() == name) {
|
||||
return Some(Arc::new(CoreToolBridge::new(t.clone())));
|
||||
}
|
||||
if let Some(t) = self.memory_tools.iter().find(|t| t.name() == name) {
|
||||
return Some(Arc::new(CoreToolBridge::new(t.clone())));
|
||||
}
|
||||
if let Some(t) = self.image_tools.iter().find(|t| t.name() == name) {
|
||||
return Some(Arc::new(CoreToolBridge::new(t.clone())));
|
||||
}
|
||||
// MCP names are `mcp__<server>__<tool>`.
|
||||
if let Some((server, tool)) = crate::mcp::parse_mcp_tool_name(name) {
|
||||
let def = self
|
||||
.mcp
|
||||
.tools_for(&[server.to_string()])
|
||||
.into_iter()
|
||||
.find(|t| t.name == tool)
|
||||
.map(|t| t.to_openai_definition());
|
||||
if let Some(def) = def {
|
||||
return Some(Arc::new(McpToolBridge::new(self.mcp.clone(), server, tool, def)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mcp_client::McpTool;
|
||||
|
||||
use crate::tools::ToolResult;
|
||||
|
||||
fn fake_mcp(server: &str, tool_names: &[&str]) -> Arc<dyn McpProvider> {
|
||||
struct Fake(Vec<McpTool>);
|
||||
#[async_trait::async_trait]
|
||||
impl McpProvider for Fake {
|
||||
fn tools(&self) -> Vec<McpTool> { self.0.clone() }
|
||||
fn tools_for(&self, names: &[String]) -> Vec<McpTool> {
|
||||
self.0.iter().filter(|t| names.contains(&t.server_name)).cloned().collect()
|
||||
}
|
||||
fn server_descriptions(&self) -> HashMap<String, Option<String>> { HashMap::new() }
|
||||
fn server_infos(&self) -> Vec<Value> { Vec::new() }
|
||||
fn tool_display_name(&self, _s: &str, _t: &str) -> Option<String> { None }
|
||||
async fn call(&self, _s: &str, _t: &str, _a: Value) -> anyhow::Result<ToolResult> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
Arc::new(Fake(
|
||||
tool_names
|
||||
.iter()
|
||||
.map(|t| McpTool {
|
||||
server_name: server.to_string(),
|
||||
name: t.to_string(),
|
||||
description: String::new(),
|
||||
input_schema: serde_json::json!({"type":"object"}),
|
||||
title: None,
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
task_support: None,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn set(grants: &[&str]) -> Arc<RwLock<HashSet<String>>> {
|
||||
Arc::new(RwLock::new(grants.iter().map(|s| s.to_string()).collect()))
|
||||
}
|
||||
|
||||
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":{}}})],
|
||||
fake_mcp("gmail", &["send"]),
|
||||
grants,
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_renders_only_granted_groups() {
|
||||
let ts = toolset(set(&[]));
|
||||
let defs = ts.defs(&ModelInfo::default());
|
||||
let names: Vec<&str> = defs.iter().filter_map(|d| d["function"]["name"].as_str()).collect();
|
||||
assert_eq!(names, ["read_file"]);
|
||||
|
||||
let ts = toolset(set(&["gmail", CONFIG_GROUP]));
|
||||
let defs = ts.defs(&ModelInfo::default());
|
||||
let names: Vec<&str> = defs.iter().filter_map(|d| d["function"]["name"].as_str()).collect();
|
||||
assert!(names.contains(&"mcp__gmail__send"), "{names:?}");
|
||||
assert!(names.contains(&"cron_list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_declares_everything_tagged() {
|
||||
let ts = toolset(set(&[]));
|
||||
let info = ModelInfo { tool_rendering: ToolRendering::DeferredToolReference, ..Default::default() };
|
||||
let defs = ts.defs(&info);
|
||||
let gmail = defs.iter().find(|d| d["function"]["name"].as_str() == Some("mcp__gmail__send")).unwrap();
|
||||
assert_eq!(gmail["defer_loading"], serde_json::json!(true));
|
||||
let base = defs.iter().find(|d| d["function"]["name"].as_str() == Some("read_file")).unwrap();
|
||||
assert!(base.get("defer_loading").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_tool_block_keeps_array_stable() {
|
||||
let ts = toolset(set(&["gmail"]));
|
||||
let info = ModelInfo { tool_rendering: ToolRendering::SystemToolBlock, ..Default::default() };
|
||||
let defs = ts.defs(&info);
|
||||
let names: Vec<&str> = defs.iter().filter_map(|d| d["function"]["name"].as_str()).collect();
|
||||
assert_eq!(names, ["read_file"], "activated tools must NOT be in the array in Kimi mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bridges_mcp_names() {
|
||||
let ts = toolset(set(&["gmail"]));
|
||||
let t = ts.find("mcp__gmail__send").expect("mcp tool not bridged");
|
||||
assert_eq!(t.definition()["function"]["name"], serde_json::json!("mcp__gmail__send"));
|
||||
assert!(ts.find("mcp__gmail__nope").is_none());
|
||||
assert!(ts.find("unknown_tool").is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user