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:
@@ -123,7 +123,10 @@ pub struct EventSink {
|
||||
}
|
||||
|
||||
impl EventSink {
|
||||
pub(crate) fn new(conversation: ConversationId, tx: broadcast::Sender<Event<LoopEvent>>) -> Self {
|
||||
/// Wrap a bus sender for one conversation. Public so hosts can build
|
||||
/// sinks in their own tests and adapters; the kernel builds them via the
|
||||
/// manager.
|
||||
pub fn new(conversation: ConversationId, tx: broadcast::Sender<Event<LoopEvent>>) -> Self {
|
||||
Self { conversation, tx }
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ pub struct PendingCall {
|
||||
pub enum GateDecision {
|
||||
Allow,
|
||||
Reject { reason: String },
|
||||
/// The gate was waiting for a human and the channel closed: the turn ends
|
||||
/// and the call STAYS `AwaitingHuman` (the gate marked it before
|
||||
/// suspending) — the same semantics as `ToolFailure::Suspend`.
|
||||
Suspend,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -348,6 +348,7 @@ async fn run_sequential(
|
||||
continue;
|
||||
}
|
||||
PreExecution::TurnCancelled => return Ok(Some(TurnOutcome::Cancelled)),
|
||||
PreExecution::Suspended => return Ok(Some(TurnOutcome::Cancelled)),
|
||||
};
|
||||
|
||||
let ctx = ToolCtx {
|
||||
@@ -468,6 +469,7 @@ async fn phase2_one<'a>(
|
||||
}
|
||||
Ok(PreExecution::Resolved(outcome)) => Phase2::Done(outcome),
|
||||
Ok(PreExecution::TurnCancelled) => Phase2::Done(CallOutcome::Cancelled),
|
||||
Ok(PreExecution::Suspended) => Phase2::Suspended,
|
||||
Err(e) => Phase2::Done(CallOutcome::Failed(format!("pre-execution error: {e}"))),
|
||||
};
|
||||
(idx, phase)
|
||||
@@ -507,6 +509,9 @@ enum PreExecution {
|
||||
Run(Arc<dyn crate::tool::Tool>),
|
||||
Resolved(CallOutcome),
|
||||
TurnCancelled,
|
||||
/// The gate suspended awaiting a human: the call STAYS `AwaitingHuman`
|
||||
/// (never resolved) and the turn ends.
|
||||
Suspended,
|
||||
}
|
||||
|
||||
/// Gate + hooks.pre + tool lookup — shared by sequential and fan-out paths.
|
||||
@@ -530,8 +535,12 @@ async fn pre_execution(
|
||||
_ = token.cancelled() => return Ok(PreExecution::TurnCancelled),
|
||||
d = deps.gate.check(&pending, events) => d,
|
||||
};
|
||||
if let GateDecision::Reject { reason } = decision {
|
||||
return Ok(PreExecution::Resolved(CallOutcome::Rejected { reason }));
|
||||
match decision {
|
||||
GateDecision::Reject { reason } => {
|
||||
return Ok(PreExecution::Resolved(CallOutcome::Rejected { reason }));
|
||||
}
|
||||
GateDecision::Suspend => return Ok(PreExecution::Suspended),
|
||||
GateDecision::Allow => {}
|
||||
}
|
||||
|
||||
let mut ptc_mut = ptc.clone();
|
||||
|
||||
@@ -27,6 +27,10 @@ pub mod store_memory;
|
||||
pub mod testing;
|
||||
pub mod tool;
|
||||
|
||||
/// Re-exported so implementors of the crate's async traits can write
|
||||
/// `#[agent_loop::async_trait]` without a direct dependency.
|
||||
pub use async_trait::async_trait;
|
||||
|
||||
/// Application name sent as the `X-Title` header by the shipped clients
|
||||
/// (OpenRouter rankings). Clients accept an override.
|
||||
pub const APP_NAME: &str = "Skald";
|
||||
|
||||
@@ -81,3 +81,8 @@ honcho-client = { path = "../honcho-client" }
|
||||
agent-loop = { path = "../agent-loop" }
|
||||
core-api = { path = "../core-api" }
|
||||
mcp-client = { path = "../mcp-client" }
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests that build reqwest clients (rustls-no-provider) need a process-wide
|
||||
# crypto provider, installed in main() in production.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod inbox;
|
||||
pub mod latex;
|
||||
pub mod llm;
|
||||
pub mod location;
|
||||
pub mod loop_adapters;
|
||||
pub mod memory;
|
||||
pub mod mcp;
|
||||
pub mod notification;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! DTL activation adapters (blueprint D15): the crate owns the wire protocol,
|
||||
//! Skald owns the catalog (MCP servers + the reserved `config` group) and the
|
||||
//! persistence (`activated_tools`, anchored at the triggering message).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use agent_loop::activation::{Activation, ActivationSource, ToolActivator};
|
||||
use agent_loop::ids::{FrameId, MessageId};
|
||||
use agent_loop::tool::{ToolCtx, ToolFailure};
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::{activated_tools, chat_llm_tools};
|
||||
use crate::mcp::McpProvider;
|
||||
use crate::tools::tool_names::CONFIG_GROUP;
|
||||
|
||||
// ── ActivationSource ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Reads the durable activations of one scope (root session or sub-agent
|
||||
/// frame) and resolves them to OpenAI tool defs for the assembler's DTL
|
||||
/// injection. Port of `MessageBuilder::resolve_activation_defs`.
|
||||
pub struct SkaldActivationSource {
|
||||
pool: Arc<SqlitePool>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
config_defs: Arc<Vec<Value>>,
|
||||
session_id: i64,
|
||||
/// `None` = root (session scope); `Some(stack_id)` = sub-agent frame.
|
||||
stack: Option<i64>,
|
||||
}
|
||||
|
||||
impl SkaldActivationSource {
|
||||
pub fn new(
|
||||
pool: Arc<SqlitePool>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
config_defs: Arc<Vec<Value>>,
|
||||
session_id: i64,
|
||||
stack: Option<i64>,
|
||||
) -> Self {
|
||||
Self { pool, mcp, config_defs, session_id, stack }
|
||||
}
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl ActivationSource for SkaldActivationSource {
|
||||
async fn activations(&self, _frame: FrameId) -> agent_loop::Result<Vec<Activation>> {
|
||||
let rows = activated_tools::list_active_at(&self.pool, self.session_id, self.stack, i64::MAX).await?;
|
||||
|
||||
// Group by anchor, dedup tool names per anchor (a server may reappear).
|
||||
let mut out: Vec<Activation> = Vec::new();
|
||||
for row in rows {
|
||||
let defs: Vec<Value> = if row.kind == "builtin" && row.ref_ == CONFIG_GROUP {
|
||||
self.config_defs.as_ref().clone()
|
||||
} else {
|
||||
self.mcp
|
||||
.tools_for(std::slice::from_ref(&row.ref_))
|
||||
.iter()
|
||||
.map(|t| t.to_openai_definition())
|
||||
.collect()
|
||||
};
|
||||
let anchor = MessageId(row.message_id);
|
||||
match out.iter_mut().find(|a| a.anchor == anchor) {
|
||||
Some(existing) => {
|
||||
for d in defs {
|
||||
let name = d["function"]["name"].as_str().unwrap_or("");
|
||||
if !existing.defs.iter().any(|e| e["function"]["name"].as_str() == Some(name)) {
|
||||
existing.defs.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => out.push(Activation { anchor, defs }),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ToolActivator ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Backend of the crate's shipped `activate_tools` tool: validates the groups
|
||||
/// against the catalog, updates the in-memory grant set **immediately** (next
|
||||
/// round sees the tools), and persists the activation anchored at the
|
||||
/// triggering assistant message (derived from the call's `chat_llm_tools`
|
||||
/// row). Unifies what today lives split between `tools/activate_tools.rs`
|
||||
/// (grants) and `llm_loop.rs` (persistence).
|
||||
pub struct SkaldToolActivator {
|
||||
pool: Arc<SqlitePool>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
grants: Arc<RwLock<HashSet<String>>>,
|
||||
session_id: i64,
|
||||
stack: Option<i64>,
|
||||
}
|
||||
|
||||
impl SkaldToolActivator {
|
||||
pub fn new(
|
||||
pool: Arc<SqlitePool>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
grants: Arc<RwLock<HashSet<String>>>,
|
||||
session_id: i64,
|
||||
stack: Option<i64>,
|
||||
) -> Self {
|
||||
Self { pool, mcp, grants, session_id, stack }
|
||||
}
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl ToolActivator for SkaldToolActivator {
|
||||
async fn activate(&self, groups: Vec<String>, ctx: &ToolCtx) -> Result<String, ToolFailure> {
|
||||
if groups.is_empty() {
|
||||
return Err(ToolFailure::Failed("activate_tools: `groups` is empty".into()));
|
||||
}
|
||||
|
||||
let available: HashSet<String> = self.mcp.tools().iter().map(|t| t.server_name.clone()).collect();
|
||||
|
||||
// Immediate in-memory effect (the defs re-read at the next round picks
|
||||
// the new grants up for free).
|
||||
{
|
||||
let mut set = self.grants.write().map_err(|_| ToolFailure::Failed("activate_tools: lock poisoned".into()))?;
|
||||
for g in &groups {
|
||||
set.insert(g.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Durable effect, anchored at the triggering assistant message. The
|
||||
// anchor is derived from the call row — the crate's ToolCtx carries
|
||||
// the call id, the message id is one lookup away.
|
||||
let call = chat_llm_tools::get(&self.pool, ctx.call_id.get())
|
||||
.await
|
||||
.map_err(|e| ToolFailure::Failed(format!("activate_tools: anchor lookup failed: {e}")))?
|
||||
.ok_or_else(|| ToolFailure::Failed("activate_tools: call row not found".into()))?;
|
||||
for g in &groups {
|
||||
let kind = if g == CONFIG_GROUP { "builtin" } else { "mcp" };
|
||||
activated_tools::grant(&self.pool, self.session_id, self.stack, call.message_id, kind, g)
|
||||
.await
|
||||
.map_err(|e| ToolFailure::Failed(format!("activate_tools: grant failed: {e}")))?;
|
||||
}
|
||||
|
||||
let activated: Vec<String> = groups
|
||||
.iter()
|
||||
.map(|n| {
|
||||
if n == CONFIG_GROUP || available.contains(n) {
|
||||
format!("{n} ✓")
|
||||
} else {
|
||||
format!("{n} (registered but not yet running — tools will appear after reconnect)")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let scope = match self.stack {
|
||||
None => "session".to_string(),
|
||||
Some(s) => format!("stack {s}"),
|
||||
};
|
||||
Ok(format!(
|
||||
"Tool groups activated for this {scope}: {}. \
|
||||
Their tools are available from the next tool-call round.",
|
||||
activated.join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use agent_loop::store::HistoryStore;
|
||||
use agent_loop::tool::ToolOutput;
|
||||
use mcp_client::McpTool;
|
||||
|
||||
use crate::db::{chat_history, chat_sessions_stack};
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
use crate::tools::ToolResult;
|
||||
|
||||
struct FakeMcp {
|
||||
tools: Vec<McpTool>,
|
||||
}
|
||||
|
||||
impl FakeMcp {
|
||||
fn with_server(name: &str, tool_names: &[&str]) -> Self {
|
||||
Self {
|
||||
tools: tool_names
|
||||
.iter()
|
||||
.map(|t| McpTool {
|
||||
server_name: name.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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpProvider for FakeMcp {
|
||||
fn tools(&self) -> Vec<McpTool> { self.tools.clone() }
|
||||
fn tools_for(&self, names: &[String]) -> Vec<McpTool> {
|
||||
self.tools.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, _server: &str, _tool: &str) -> Option<String> { None }
|
||||
async fn call(&self, _server: &str, _tool: &str, _args: Value) -> anyhow::Result<ToolResult> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn temp_db_path(tag: &str) -> String {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn cleanup(path: &str) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path}{suffix}"));
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
pool: Arc<SqlitePool>,
|
||||
frame: FrameId,
|
||||
msg: MessageId,
|
||||
call: agent_loop::ids::ToolCallId,
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn fixture(tag: &str) -> Fixture {
|
||||
let path = temp_db_path(tag);
|
||||
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
|
||||
sqlx::query("INSERT INTO chat_sessions (id) VALUES (1)").execute(&*pool).await.unwrap();
|
||||
let frame_row = chat_sessions_stack::create(&pool, 1, "assistant", None, 0, None).await.unwrap();
|
||||
let msg = chat_history::append(&pool, frame_row.id, &chat_history::Role::Assistant, "activating", false, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let call = chat_llm_tools::append(&pool, msg, "activate_tools", "{}").await.unwrap();
|
||||
Fixture {
|
||||
pool,
|
||||
frame: FrameId(frame_row.id),
|
||||
msg: MessageId(msg),
|
||||
call: agent_loop::ids::ToolCallId(call),
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activate_grants_in_memory_and_persists_anchored() {
|
||||
let f = fixture("act-grant").await;
|
||||
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("gmail", &["send", "read"]));
|
||||
let grants = Arc::new(RwLock::new(HashSet::new()));
|
||||
let activator = SkaldToolActivator::new(f.pool.clone(), mcp, grants.clone(), 1, None);
|
||||
|
||||
let ctx = ToolCtx {
|
||||
conversation: agent_loop::ids::ConversationId::new("session:1"),
|
||||
frame: f.frame,
|
||||
agent: "assistant".into(),
|
||||
call_id: f.call,
|
||||
cancel: tokio_util::sync::CancellationToken::new(),
|
||||
extensions: Default::default(),
|
||||
};
|
||||
let text = activator.activate(vec!["gmail".into(), CONFIG_GROUP.into()], &ctx).await.unwrap();
|
||||
assert!(text.contains("gmail ✓"));
|
||||
|
||||
// In-memory effect.
|
||||
assert!(grants.read().unwrap().contains("gmail"));
|
||||
assert!(grants.read().unwrap().contains(CONFIG_GROUP));
|
||||
|
||||
// Durable effect, anchored at the assistant message.
|
||||
let refs = activated_tools::list_refs_session(&f.pool, 1).await.unwrap();
|
||||
assert_eq!(refs.len(), 2);
|
||||
let acts = activated_tools::list_active_at(&f.pool, 1, None, i64::MAX).await.unwrap();
|
||||
assert!(acts.iter().all(|a| a.message_id == f.msg.get()));
|
||||
|
||||
f.pool.close().await;
|
||||
cleanup(&f.path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activation_source_resolves_defs_per_anchor() {
|
||||
let f = fixture("act-src").await;
|
||||
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("gmail", &["send", "read"]));
|
||||
activated_tools::grant(&f.pool, 1, None, f.msg.get(), "mcp", "gmail").await.unwrap();
|
||||
activated_tools::grant(&f.pool, 1, None, f.msg.get(), "builtin", CONFIG_GROUP).await.unwrap();
|
||||
|
||||
let config_defs = Arc::new(vec![serde_json::json!({
|
||||
"type":"function","function":{"name":"cron_list","parameters":{"type":"object"}}
|
||||
})]);
|
||||
let src = SkaldActivationSource::new(f.pool.clone(), mcp, config_defs, 1, None);
|
||||
let acts = src.activations(f.frame).await.unwrap();
|
||||
|
||||
assert_eq!(acts.len(), 1, "same anchor → one merged entry");
|
||||
let names: Vec<&str> = acts[0]
|
||||
.defs
|
||||
.iter()
|
||||
.filter_map(|d| d["function"]["name"].as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&"send") || names.iter().any(|n| n.contains("send")), "{names:?}");
|
||||
assert!(names.contains(&"cron_list"), "{names:?}");
|
||||
|
||||
// The SqliteHistory + LinearAssembler path agrees on the anchor type.
|
||||
let store = SqliteHistory::new(f.pool.clone());
|
||||
let history = store.load(f.frame).await.unwrap();
|
||||
assert_eq!(history[0].id, f.msg);
|
||||
let _ = ToolOutput::Text("unused".into());
|
||||
|
||||
f.pool.close().await;
|
||||
cleanup(&f.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! `ApprovalGate` — Skald's approval flow behind the crate's `Gate` trait
|
||||
//! (port of `handler/gate.rs::run_approval_gate`, blueprint §10):
|
||||
//!
|
||||
//! 1. `pre_approved` short-circuit (post-restart manual resolve);
|
||||
//! 2. the approval engine decides (explicit Allow/Deny rules win);
|
||||
//! 3. the RunContext fast-path relaxes `Require` to `Allow` for pre-authorized
|
||||
//! fs paths (never overrides a Deny);
|
||||
//! 4. `Require` → auto-deny, or mark `AwaitingHuman` + register + emit
|
||||
//! `ApprovalRequired` + block on the human decision; a closed channel maps
|
||||
//! to `GateDecision::Suspend` (the call stays `AwaitingHuman`, the turn
|
||||
//! ends) — the old `GateOutcome::ChannelClosed`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use agent_loop::events::{EventSink, LoopEvent};
|
||||
use agent_loop::gate::{Gate, GateDecision, PendingCall};
|
||||
use agent_loop::store::{CallState, HistoryStore};
|
||||
|
||||
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};
|
||||
|
||||
/// Everything the gate needs that the current loop keeps on the handler.
|
||||
/// Shared by reference so phase-2 wiring shares the same cells.
|
||||
pub struct ApprovalGate {
|
||||
approval: Arc<ApprovalManager>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
session_id: i64,
|
||||
source: String,
|
||||
group_id: Option<String>,
|
||||
run_context: Arc<RwLock<Option<RunContext>>>,
|
||||
pre_approved: Arc<Mutex<HashSet<i64>>>,
|
||||
auto_deny: Arc<AtomicBool>,
|
||||
context_label: Arc<RwLock<Option<String>>>,
|
||||
}
|
||||
|
||||
impl ApprovalGate {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
approval: Arc<ApprovalManager>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
session_id: i64,
|
||||
source: impl Into<String>,
|
||||
group_id: Option<String>,
|
||||
run_context: Arc<RwLock<Option<RunContext>>>,
|
||||
pre_approved: Arc<Mutex<HashSet<i64>>>,
|
||||
auto_deny: Arc<AtomicBool>,
|
||||
context_label: Arc<RwLock<Option<String>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
approval,
|
||||
store,
|
||||
tools,
|
||||
session_id,
|
||||
source: source.into(),
|
||||
group_id,
|
||||
run_context,
|
||||
pre_approved,
|
||||
auto_deny,
|
||||
context_label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl Gate for ApprovalGate {
|
||||
async fn check(&self, call: &PendingCall, events: &EventSink) -> GateDecision {
|
||||
// Post-restart manual resolve: already approved via a resolve endpoint.
|
||||
if self.pre_approved.lock().unwrap().remove(&call.id.get()) {
|
||||
return GateDecision::Allow;
|
||||
}
|
||||
|
||||
let category = self.tools.category_of(&call.name);
|
||||
|
||||
// The approval engine decides first: an explicit Deny/Allow rule wins.
|
||||
let mut gate = self
|
||||
.approval
|
||||
.check(
|
||||
self.session_id,
|
||||
category,
|
||||
&call.agent,
|
||||
&self.source,
|
||||
&call.name,
|
||||
&call.args,
|
||||
self.group_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// RunContext fast-path: relax `Require` for pre-authorized fs paths
|
||||
// (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 dflt = RunContext::default();
|
||||
let rc = guard.as_ref().unwrap_or(&dflt);
|
||||
let pre_allowed = if is_file_read_tool(&call.name) {
|
||||
rc.is_read_allowed(path)
|
||||
} else if is_file_write_tool(&call.name) {
|
||||
rc.is_write_allowed(path)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if pre_allowed {
|
||||
gate = GateResult::Allow;
|
||||
}
|
||||
}
|
||||
|
||||
match gate {
|
||||
GateResult::Allow => GateDecision::Allow,
|
||||
GateResult::Deny => GateDecision::Reject {
|
||||
reason: "Tool call denied by approval policy.".to_string(),
|
||||
},
|
||||
GateResult::Require => {
|
||||
if self.auto_deny.load(Ordering::Relaxed) {
|
||||
return GateDecision::Reject {
|
||||
reason: "Tool call auto-denied: this session does not support approval requests."
|
||||
.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
// Durability FIRST: the call must survive a crash as pending.
|
||||
if let Err(e) = self.store.set_call_state(call.id, CallState::AwaitingHuman).await {
|
||||
return GateDecision::Reject {
|
||||
reason: format!("approval: failed to mark call pending: {e}"),
|
||||
};
|
||||
}
|
||||
|
||||
let label = self.context_label.read().ok().and_then(|g| g.clone());
|
||||
let (request_id, approve_rx) = self
|
||||
.approval
|
||||
.register(
|
||||
self.session_id,
|
||||
call.id.get(),
|
||||
&call.name,
|
||||
call.args.clone(),
|
||||
&call.agent,
|
||||
&self.source,
|
||||
label.as_deref(),
|
||||
category,
|
||||
)
|
||||
.await;
|
||||
events.emit(call.frame, None, LoopEvent::ApprovalRequired {
|
||||
id: call.id,
|
||||
name: call.name.clone(),
|
||||
args: call.args.clone(),
|
||||
});
|
||||
let _ = request_id;
|
||||
|
||||
match approve_rx.await {
|
||||
Ok(ApprovalDecision::Approved) => GateDecision::Allow,
|
||||
Ok(ApprovalDecision::Rejected { note }) => GateDecision::Reject {
|
||||
reason: ApprovalDecision::rejection_message(¬e),
|
||||
},
|
||||
Err(_) => GateDecision::Suspend,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agent_loop::events::EventSink;
|
||||
use agent_loop::ids::{ConversationId, FrameId, ToolCallId};
|
||||
use agent_loop::tool::Extensions;
|
||||
use serde_json::json;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::approval::{NewApprovalRule, RuleAction};
|
||||
use crate::db::{chat_history, chat_llm_tools, chat_sessions_stack};
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
|
||||
fn temp_db_path(tag: &str) -> String {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn cleanup(path: &str) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path}{suffix}"));
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
gate: ApprovalGate,
|
||||
events: EventSink,
|
||||
pool: Arc<SqlitePool>,
|
||||
call: PendingCall,
|
||||
path: String,
|
||||
approval: Arc<ApprovalManager>,
|
||||
}
|
||||
|
||||
async fn fixture(tag: &str) -> Fixture {
|
||||
let path = temp_db_path(tag);
|
||||
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
|
||||
// The `default` permission group is a FK target for approval_rules.group_id.
|
||||
sqlx::query("INSERT INTO tool_permission_groups (id, name) VALUES ('default', 'Default')")
|
||||
.execute(&*pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO chat_sessions (id) VALUES (1)").execute(&*pool).await.unwrap();
|
||||
let frame = chat_sessions_stack::create(&pool, 1, "assistant", None, 0, None).await.unwrap();
|
||||
let msg = chat_history::append(&pool, frame.id, &chat_history::Role::Assistant, "a", false, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let call_id = chat_llm_tools::append(&pool, msg, "some_tool", "{}").await.unwrap();
|
||||
|
||||
let (tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let approval = Arc::new(ApprovalManager::new(pool.clone(), tx));
|
||||
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
let gate = ApprovalGate::new(
|
||||
approval.clone(),
|
||||
store,
|
||||
tools,
|
||||
1,
|
||||
"web",
|
||||
None,
|
||||
Arc::new(RwLock::new(None)),
|
||||
Arc::new(Mutex::new(HashSet::new())),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
Arc::new(RwLock::new(None)),
|
||||
);
|
||||
let (bus, _) = tokio::sync::broadcast::channel(16);
|
||||
let events = EventSink::new(ConversationId::new("session:1"), bus);
|
||||
let call = PendingCall {
|
||||
id: ToolCallId(call_id),
|
||||
name: "some_tool".into(),
|
||||
args: json!({}),
|
||||
frame: FrameId(frame.id),
|
||||
agent: "assistant".into(),
|
||||
extensions: Extensions::new(),
|
||||
};
|
||||
Fixture { gate, events, pool, call, path, approval }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_deny_rule_rejects() {
|
||||
let f = fixture("gate-deny").await;
|
||||
f.approval
|
||||
.add_rule(NewApprovalRule {
|
||||
agent_id: None,
|
||||
source: None,
|
||||
tool_pattern: "some_tool".into(),
|
||||
path_pattern: None,
|
||||
action: RuleAction::Deny,
|
||||
note: None,
|
||||
priority: Some(1),
|
||||
group_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let d = f.gate.check(&f.call, &f.events).await;
|
||||
assert!(matches!(d, GateDecision::Reject { .. }));
|
||||
|
||||
f.pool.close().await;
|
||||
cleanup(&f.path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_deny_rejects_require() {
|
||||
let path = temp_db_path("gate-autodeny");
|
||||
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
|
||||
sqlx::query("INSERT INTO chat_sessions (id) VALUES (1)").execute(&*pool).await.unwrap();
|
||||
let frame = chat_sessions_stack::create(&pool, 1, "assistant", None, 0, None).await.unwrap();
|
||||
let msg = chat_history::append(&pool, frame.id, &chat_history::Role::Assistant, "a", false, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let call_id = chat_llm_tools::append(&pool, msg, "some_tool", "{}").await.unwrap();
|
||||
|
||||
let (tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let approval = Arc::new(ApprovalManager::new(pool.clone(), tx));
|
||||
let gate = ApprovalGate::new(
|
||||
approval,
|
||||
Arc::new(SqliteHistory::new(pool.clone())),
|
||||
Arc::new(ToolRegistry::new()),
|
||||
1,
|
||||
"cron", // background source: auto-deny
|
||||
None,
|
||||
Arc::new(RwLock::new(None)),
|
||||
Arc::new(Mutex::new(HashSet::new())),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(None)),
|
||||
);
|
||||
let (bus, _) = tokio::sync::broadcast::channel(16);
|
||||
let events = EventSink::new(ConversationId::new("session:1"), bus);
|
||||
let call = PendingCall {
|
||||
id: ToolCallId(call_id),
|
||||
name: "some_tool".into(),
|
||||
args: json!({}),
|
||||
frame: FrameId(frame.id),
|
||||
agent: "assistant".into(),
|
||||
extensions: Extensions::new(),
|
||||
};
|
||||
|
||||
// No rules at all → the seeded-less default is Require; auto-deny rejects.
|
||||
let d = gate.check(&call, &events).await;
|
||||
assert!(matches!(d, GateDecision::Reject { .. }));
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn human_approval_allows_and_marks_pending_first() {
|
||||
let f = fixture("gate-human").await;
|
||||
let approval = f.approval.clone();
|
||||
let gate = Arc::new(f.gate);
|
||||
let events = f.events.clone();
|
||||
let call = f.call.clone();
|
||||
|
||||
let check = tokio::spawn(async move { gate.check(&call, &events).await });
|
||||
|
||||
// Wait for the request to register, then approve it.
|
||||
let request_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let pending = approval.list_pending().await;
|
||||
if let Some(p) = pending.first() {
|
||||
break p.request_id;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The call is durably pending while the human decides.
|
||||
let row = chat_llm_tools::get(&f.pool, f.call.id.get()).await.unwrap().unwrap();
|
||||
assert_eq!(row.status, "pending");
|
||||
|
||||
approval.resolve(request_id, ApprovalDecision::Approved).await;
|
||||
let d = check.await.unwrap();
|
||||
assert!(matches!(d, GateDecision::Allow));
|
||||
|
||||
f.pool.close().await;
|
||||
cleanup(&f.path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn human_rejection_rejects_with_note() {
|
||||
let f = fixture("gate-reject").await;
|
||||
let approval = f.approval.clone();
|
||||
let gate = Arc::new(f.gate);
|
||||
let events = f.events.clone();
|
||||
let call = f.call.clone();
|
||||
|
||||
let check = tokio::spawn(async move { gate.check(&call, &events).await });
|
||||
|
||||
let request_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let pending = approval.list_pending().await;
|
||||
if let Some(p) = pending.first() {
|
||||
break p.request_id;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
approval
|
||||
.resolve(request_id, ApprovalDecision::Rejected { note: "too risky".into() })
|
||||
.await;
|
||||
let d = check.await.unwrap();
|
||||
match d {
|
||||
GateDecision::Reject { reason } => assert!(reason.contains("too risky")),
|
||||
other => panic!("expected Reject, got {other:?}"),
|
||||
}
|
||||
|
||||
f.pool.close().await;
|
||||
cleanup(&f.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
//! `SqliteHistory` — `HistoryStore` over the EXISTING Skald tables (no
|
||||
//! migration, blueprint §0/§10):
|
||||
//!
|
||||
//! | crate concept | Skald table |
|
||||
//! |---|---|
|
||||
//! | conversation `"session:{id}"` | `chat_sessions.id` (the id rides in the `ConversationId` string) |
|
||||
//! | frame | `chat_sessions_stack` (`terminated_at IS NULL` = active) |
|
||||
//! | message | `chat_history` (`status='failed'` = failed orphan) |
|
||||
//! | tool call | `chat_llm_tools` (status strings map 1:1 on `CallState`) |
|
||||
//! | summary | `chat_summaries` (`covers_up_to_message_id`) |
|
||||
//!
|
||||
//! The store is built on an **owner pool** (one per user, §11): all ids are
|
||||
//! pool-local, so the adapter needs no user scoping. The wire tool-call id is
|
||||
//! synthesized as `tc_{row_id}`, exactly like the current message builder.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::model::Usage;
|
||||
use agent_loop::store::{
|
||||
CallOutcome, CallState, FrameRecord, FrameSpec, HistoryStore, NewCall, NewMessage, NewSummary,
|
||||
Role, StoredCall, StoredMessage, StoredSummary,
|
||||
};
|
||||
use agent_loop::tool::ToolOutput;
|
||||
use agent_loop::ids::{ConversationId, FrameId, MessageId, SummaryId, ToolCallId};
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::{chat_history, chat_llm_tools, chat_sessions_stack, chat_summaries};
|
||||
|
||||
/// `HistoryStore` on a Skald owner pool.
|
||||
pub struct SqliteHistory {
|
||||
pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl SqliteHistory {
|
||||
pub fn new(pool: Arc<SqlitePool>) -> Self { Self { pool } }
|
||||
|
||||
/// Parse `"session:{id}"` (the adapter's conversation encoding).
|
||||
fn session_id(conv: &ConversationId) -> anyhow::Result<i64> {
|
||||
conv.as_str()
|
||||
.strip_prefix("session:")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.ok_or_else(|| anyhow::anyhow!("SqliteHistory: conversation id must be \"session:<i64>\", got '{conv}'"))
|
||||
}
|
||||
|
||||
fn map_role(role: Role) -> anyhow::Result<chat_history::Role> {
|
||||
match role {
|
||||
Role::User => Ok(chat_history::Role::User),
|
||||
Role::Assistant => Ok(chat_history::Role::Assistant),
|
||||
Role::Agent => Ok(chat_history::Role::Agent),
|
||||
// chat_history has no system role: system context is BUILT, never
|
||||
// stored. Failing loudly beats silently mis-filing a message.
|
||||
Role::System => anyhow::bail!(
|
||||
"SqliteHistory: Role::System is not persistable — system context is not stored"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn unmap_role(role: &chat_history::Role) -> Role {
|
||||
match role {
|
||||
chat_history::Role::User => Role::User,
|
||||
chat_history::Role::Assistant => Role::Assistant,
|
||||
chat_history::Role::Agent => Role::Agent,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_state(state: CallState) -> &'static str {
|
||||
match state {
|
||||
CallState::Running => "running",
|
||||
CallState::AwaitingHuman => "pending",
|
||||
CallState::Done => "done",
|
||||
CallState::Failed => "failed",
|
||||
CallState::Cancelled => "cancelled",
|
||||
CallState::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
|
||||
fn unmap_state(status: &str) -> CallState {
|
||||
match status {
|
||||
"pending" => CallState::AwaitingHuman,
|
||||
"done" => CallState::Done,
|
||||
"failed" => CallState::Failed,
|
||||
"cancelled" => CallState::Cancelled,
|
||||
"rejected" => CallState::Rejected,
|
||||
_ => CallState::Running,
|
||||
}
|
||||
}
|
||||
|
||||
fn stored_call(c: chat_llm_tools::LlmToolCall) -> StoredCall {
|
||||
let arguments: Value = c
|
||||
.arguments
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(Value::Object(Default::default()));
|
||||
// preview/media ride in `extras` (host free-form), mirroring how the
|
||||
// current loop reads them back for the history projection.
|
||||
let extras = serde_json::json!({
|
||||
"preview_old": c.preview_old,
|
||||
"preview_new": c.preview_new,
|
||||
"media": c.media,
|
||||
});
|
||||
StoredCall {
|
||||
id: ToolCallId(c.id),
|
||||
message_id: MessageId(c.message_id),
|
||||
provider_id: format!("tc_{}", c.id),
|
||||
name: c.name,
|
||||
arguments,
|
||||
state: Self::unmap_state(&c.status),
|
||||
result: c.result,
|
||||
result_kind: c.result_type,
|
||||
extras,
|
||||
}
|
||||
}
|
||||
|
||||
fn stored_message(m: chat_history::ChatMessage, calls: Vec<StoredCall>) -> StoredMessage {
|
||||
StoredMessage {
|
||||
id: MessageId(m.id),
|
||||
role: Self::unmap_role(&m.role),
|
||||
content: m.content,
|
||||
reasoning: m.reasoning_content,
|
||||
synthetic: m.is_synthetic,
|
||||
failed: m.status == "failed",
|
||||
metadata: m.metadata.map(|meta| {
|
||||
serde_json::to_value(meta).unwrap_or(Value::Null)
|
||||
}),
|
||||
usage: Usage {
|
||||
input_tokens: m.input_tokens.map(|n| n as u32),
|
||||
output_tokens: m.output_tokens.map(|n| n as u32),
|
||||
cache_read: None,
|
||||
cache_write: None,
|
||||
cost_usd: m.cost,
|
||||
truncated: false,
|
||||
},
|
||||
calls,
|
||||
}
|
||||
}
|
||||
|
||||
async fn with_calls(&self, msgs: Vec<chat_history::ChatMessage>) -> anyhow::Result<Vec<StoredMessage>> {
|
||||
let mut out = Vec::with_capacity(msgs.len());
|
||||
for m in msgs {
|
||||
let calls = chat_llm_tools::for_message(&self.pool, m.id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Self::stored_call)
|
||||
.collect();
|
||||
out.push(Self::stored_message(m, calls));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl HistoryStore for SqliteHistory {
|
||||
// ── frames ──
|
||||
|
||||
async fn open_frame(
|
||||
&self,
|
||||
conv: &ConversationId,
|
||||
parent: Option<FrameId>,
|
||||
spec: FrameSpec,
|
||||
) -> agent_loop::Result<FrameId> {
|
||||
let session_id = Self::session_id(conv)?;
|
||||
// Root frame: reuse the session's existing root stack row when present
|
||||
// (sessions are provisioned with one), create it otherwise.
|
||||
if parent.is_none()
|
||||
&& let Some(root) = chat_sessions_stack::main_for_session(&self.pool, session_id).await?
|
||||
{
|
||||
return Ok(FrameId(root.id));
|
||||
}
|
||||
let frame = chat_sessions_stack::create(
|
||||
&self.pool,
|
||||
session_id,
|
||||
&spec.agent,
|
||||
spec.prompt.as_deref(),
|
||||
spec.depth as i64,
|
||||
spec.parent_call.map(|c| c.get()),
|
||||
)
|
||||
.await?;
|
||||
Ok(FrameId(frame.id))
|
||||
}
|
||||
|
||||
async fn close_frame(&self, frame: FrameId) -> agent_loop::Result<()> {
|
||||
chat_sessions_stack::terminate(&self.pool, frame.get()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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>)>(
|
||||
"SELECT id, session_id, agent_id, agent_prompt, depth, parent_tool_call_id
|
||||
FROM chat_sessions_stack
|
||||
WHERE session_id = ? AND terminated_at IS NULL
|
||||
ORDER BY depth ASC",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, sid, agent, prompt, depth, parent_call)| FrameRecord {
|
||||
id: FrameId(id),
|
||||
conversation: ConversationId::new(format!("session:{sid}")),
|
||||
// The parent frame id is not stored directly (only the parent
|
||||
// tool call); recovery walks the call when it needs the link.
|
||||
parent: None,
|
||||
spec: FrameSpec {
|
||||
agent,
|
||||
prompt,
|
||||
depth: depth as u32,
|
||||
parent_call: parent_call.map(ToolCallId),
|
||||
meta: Value::Null,
|
||||
},
|
||||
active: true,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn deepest_active(&self, conv: &ConversationId) -> agent_loop::Result<Option<FrameRecord>> {
|
||||
Ok(self
|
||||
.active_frames(conv)
|
||||
.await?
|
||||
.into_iter()
|
||||
.max_by_key(|f| f.spec.depth))
|
||||
}
|
||||
|
||||
// ── messages ──
|
||||
|
||||
async fn append(&self, frame: FrameId, msg: NewMessage) -> agent_loop::Result<MessageId> {
|
||||
let role = Self::map_role(msg.role)?;
|
||||
// chat_history.metadata is a typed MessageMetadata column; the crate's
|
||||
// free-form Value only round-trips when it parses back as one.
|
||||
let metadata = msg
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<core_api::message_meta::MessageMetadata>(v.clone()).ok());
|
||||
let id = chat_history::append_with_metadata(
|
||||
&self.pool,
|
||||
frame.get(),
|
||||
&role,
|
||||
&msg.content,
|
||||
msg.synthetic,
|
||||
msg.reasoning.as_deref(),
|
||||
metadata.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(MessageId(id))
|
||||
}
|
||||
|
||||
async fn set_usage(&self, msg: MessageId, usage: &Usage) -> agent_loop::Result<()> {
|
||||
if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
|
||||
chat_history::set_usage(&self.pool, msg.get(), i, o, 0, usage.cost_usd).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load(&self, frame: FrameId) -> agent_loop::Result<Vec<StoredMessage>> {
|
||||
let msgs = chat_history::for_stack(&self.pool, frame.get()).await?;
|
||||
self.with_calls(msgs).await
|
||||
}
|
||||
|
||||
async fn load_since(&self, frame: FrameId, after: MessageId) -> agent_loop::Result<Vec<StoredMessage>> {
|
||||
let msgs = chat_history::for_stack_since(&self.pool, frame.get(), after.get()).await?;
|
||||
self.with_calls(msgs).await
|
||||
}
|
||||
|
||||
async fn last(&self, frame: FrameId) -> agent_loop::Result<Option<StoredMessage>> {
|
||||
let Some(m) = chat_history::last_message_for_stack(&self.pool, frame.get()).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.with_calls(vec![m]).await?.into_iter().next())
|
||||
}
|
||||
|
||||
async fn mark_failed(&self, msg: MessageId) -> agent_loop::Result<()> {
|
||||
chat_history::mark_failed(&self.pool, msg.get()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── tool calls ──
|
||||
|
||||
async fn append_call(&self, msg: MessageId, call: NewCall) -> agent_loop::Result<ToolCallId> {
|
||||
let args = serde_json::to_string(&call.arguments)?;
|
||||
let id = chat_llm_tools::append(&self.pool, msg.get(), &call.name, &args).await?;
|
||||
Ok(ToolCallId(id))
|
||||
}
|
||||
|
||||
async fn resolve_call(&self, id: ToolCallId, outcome: &CallOutcome) -> agent_loop::Result<()> {
|
||||
let pool = &self.pool;
|
||||
match outcome {
|
||||
CallOutcome::Completed(out) => {
|
||||
chat_llm_tools::complete(pool, id.get(), &out.to_wire(), out.kind()).await?;
|
||||
if let ToolOutput::Media { refs, .. } = out {
|
||||
let media_json = serde_json::to_string(refs)?;
|
||||
chat_llm_tools::set_media(pool, id.get(), &media_json).await?;
|
||||
}
|
||||
}
|
||||
CallOutcome::Failed(e) => {
|
||||
chat_llm_tools::fail(pool, id.get(), e).await?;
|
||||
}
|
||||
CallOutcome::Cancelled => {
|
||||
chat_llm_tools::cancel(pool, id.get(), &outcome.result_text()).await?;
|
||||
}
|
||||
CallOutcome::Rejected { reason } => {
|
||||
chat_llm_tools::reject(pool, id.get(), reason).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_call_state(&self, id: ToolCallId, state: CallState) -> agent_loop::Result<()> {
|
||||
anyhow::ensure!(
|
||||
!state.is_terminal(),
|
||||
"set_call_state is only for Running → AwaitingHuman, not terminal {state:?}"
|
||||
);
|
||||
sqlx::query("UPDATE chat_llm_tools SET status = ? WHERE id = ?")
|
||||
.bind(Self::map_state(state))
|
||||
.bind(id.get())
|
||||
.execute(&*self.pool)
|
||||
.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.
|
||||
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String)>(
|
||||
"SELECT t.id, t.message_id, t.name, t.arguments, t.result, t.result_type, t.status
|
||||
FROM chat_llm_tools t
|
||||
JOIN chat_history h ON t.message_id = h.id
|
||||
WHERE h.session_stack_id = ?
|
||||
ORDER BY t.id ASC",
|
||||
)
|
||||
.bind(frame.get())
|
||||
.fetch_all(&*self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, message_id, name, arguments, result, result_type, status)| {
|
||||
Self::stored_call(chat_llm_tools::LlmToolCall {
|
||||
id,
|
||||
message_id,
|
||||
name,
|
||||
arguments,
|
||||
result,
|
||||
result_type,
|
||||
status,
|
||||
preview_old: None,
|
||||
preview_new: None,
|
||||
media: None,
|
||||
})
|
||||
})
|
||||
.filter(|c| states.contains(&c.state))
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── summaries ──
|
||||
|
||||
async fn save_summary(&self, frame: FrameId, s: NewSummary) -> agent_loop::Result<SummaryId> {
|
||||
let id = chat_summaries::save(&self.pool, frame.get(), &s.text, s.covered_up_to.get()).await?;
|
||||
Ok(SummaryId(id))
|
||||
}
|
||||
|
||||
async fn latest_summary(&self, frame: FrameId) -> agent_loop::Result<Option<StoredSummary>> {
|
||||
let Some(s) = chat_summaries::latest_for_stack(&self.pool, frame.get()).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(StoredSummary {
|
||||
id: SummaryId(s.id),
|
||||
text: s.content,
|
||||
covered_up_to: MessageId(s.covers_up_to_message_id),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_db_path(tag: &str) -> String {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn cleanup(path: &str) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path}{suffix}"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup(tag: &str) -> (Arc<SqlitePool>, SqliteHistory, ConversationId, String) {
|
||||
let path = temp_db_path(tag);
|
||||
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
|
||||
sqlx::query("INSERT INTO chat_sessions (id) VALUES (1)")
|
||||
.execute(&*pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// The session's root frame (created at provisioning time in production).
|
||||
chat_sessions_stack::create(&pool, 1, "assistant", None, 0, None).await.unwrap();
|
||||
let store = SqliteHistory::new(pool.clone());
|
||||
(pool, store, ConversationId::new("session:1"), path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frames_open_reuse_root_and_close() {
|
||||
let (pool, store, conv, path) = setup("hist-frames").await;
|
||||
|
||||
// Root: reuses the provisioned root frame.
|
||||
let root = store.open_frame(&conv, None, FrameSpec::root("assistant")).await.unwrap();
|
||||
// Child: creates a new frame at depth 1.
|
||||
let child = store
|
||||
.open_frame(&conv, Some(root), FrameSpec {
|
||||
agent: "task".into(),
|
||||
prompt: Some("do a thing".into()),
|
||||
depth: 1,
|
||||
parent_call: None,
|
||||
meta: Value::Null,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(root, child);
|
||||
|
||||
let active = store.active_frames(&conv).await.unwrap();
|
||||
assert_eq!(active.len(), 2);
|
||||
assert_eq!(store.deepest_active(&conv).await.unwrap().unwrap().id, child);
|
||||
|
||||
store.close_frame(child).await.unwrap();
|
||||
assert!(store.deepest_active(&conv).await.unwrap().unwrap().spec.depth == 0);
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn messages_calls_and_states_round_trip() {
|
||||
let (pool, store, conv, path) = setup("hist-msgs").await;
|
||||
let frame = store.open_frame(&conv, None, FrameSpec::root("assistant")).await.unwrap();
|
||||
|
||||
store.append(frame, NewMessage::user("hi")).await.unwrap();
|
||||
let asst = store.append(frame, NewMessage::assistant("calling", Some("thinking…".into()))).await.unwrap();
|
||||
let call = store
|
||||
.append_call(asst, NewCall::new("read_file", serde_json::json!({"path": "a.txt"})))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Running → AwaitingHuman (the only legal set_call_state).
|
||||
store.set_call_state(call, CallState::AwaitingHuman).await.unwrap();
|
||||
assert!(store.set_call_state(call, CallState::Done).await.is_err());
|
||||
|
||||
store
|
||||
.resolve_call(call, &CallOutcome::Completed(ToolOutput::Text("file contents".into())))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let history = store.load(frame).await.unwrap();
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[1].reasoning.as_deref(), Some("thinking…"));
|
||||
assert_eq!(history[1].calls.len(), 1);
|
||||
let c = &history[1].calls[0];
|
||||
assert_eq!(c.state, CallState::Done);
|
||||
assert_eq!(c.result.as_deref(), Some("file contents"));
|
||||
assert_eq!(c.provider_id, format!("tc_{}", c.id.get()));
|
||||
assert_eq!(c.arguments["path"], serde_json::json!("a.txt"));
|
||||
|
||||
let done = store.calls_in_state(frame, &[CallState::Done]).await.unwrap();
|
||||
assert_eq!(done.len(), 1);
|
||||
|
||||
// Orphan marking drops the message from the projection.
|
||||
store.mark_failed(history[0].id).await.unwrap();
|
||||
assert_eq!(store.load(frame).await.unwrap().len(), 1);
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summaries_round_trip() {
|
||||
let (pool, store, conv, path) = setup("hist-sum").await;
|
||||
let frame = store.open_frame(&conv, None, FrameSpec::root("assistant")).await.unwrap();
|
||||
|
||||
let m1 = store.append(frame, NewMessage::user("old")).await.unwrap();
|
||||
store.append(frame, NewMessage::assistant("answer", None)).await.unwrap();
|
||||
let m3 = store.append(frame, NewMessage::user("new")).await.unwrap();
|
||||
|
||||
store
|
||||
.save_summary(frame, NewSummary { text: "covered".into(), covered_up_to: m1 })
|
||||
.await
|
||||
.unwrap();
|
||||
let latest = store.latest_summary(frame).await.unwrap().unwrap();
|
||||
assert_eq!(latest.text, "covered");
|
||||
assert_eq!(latest.covered_up_to, m1);
|
||||
|
||||
let since = store.load_since(frame, latest.covered_up_to).await.unwrap();
|
||||
assert_eq!(since.len(), 2);
|
||||
assert_eq!(since[1].id, m3);
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_role_is_rejected() {
|
||||
let (pool, store, conv, path) = setup("hist-sys").await;
|
||||
let frame = store.open_frame(&conv, None, FrameSpec::root("assistant")).await.unwrap();
|
||||
let msg = NewMessage {
|
||||
role: Role::System,
|
||||
content: "nope".into(),
|
||||
synthetic: true,
|
||||
reasoning: None,
|
||||
metadata: None,
|
||||
};
|
||||
assert!(store.append(frame, msg).await.is_err());
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Skald-side adapters implementing the `agent-loop` trait surface over the
|
||||
//! existing infrastructure (blueprint §14 phase 1). **Unused by the current
|
||||
//! loop** — they compile and are unit-tested here, and get wired in phase 2.
|
||||
//!
|
||||
//! - [`history::SqliteHistory`] — `HistoryStore` over the existing
|
||||
//! `chat_sessions_stack` / `chat_history` / `chat_llm_tools` / `chat_summaries`
|
||||
//! tables (no migration, §0).
|
||||
//! - [`selector::SkaldSelector`] — `ModelSelector` over `LlmManager`, with the
|
||||
//! agent's strength captured per-turn (D14).
|
||||
//! - [`gate::ApprovalGate`] — `Gate` over `ApprovalManager` + the RunContext
|
||||
//! fast-path + auto-deny + pre-approved (port of `handler/gate.rs`).
|
||||
//! - [`toolset::SkaldToolSet`] — `ToolSet` over base/config defs + MCP grants +
|
||||
//! memory/image/interface tools, with DTL rendering (port of
|
||||
//! `AgentRunConfig::all_tool_defs`), plus the core-api→agent-loop tool bridge.
|
||||
//! - [`activation`] — `ActivationSource` + `ToolActivator` over the
|
||||
//! `activated_tools` table and the MCP provider (D15).
|
||||
|
||||
pub mod activation;
|
||||
pub mod gate;
|
||||
pub mod history;
|
||||
pub mod selector;
|
||||
pub mod toolset;
|
||||
@@ -0,0 +1,171 @@
|
||||
//! `SkaldSelector` — `ModelSelector` over `LlmManager` (blueprint §10, D14).
|
||||
//!
|
||||
//! The agent's required **strength is captured at construction, per-turn** —
|
||||
//! the crate never sees it: `hint` carries only an explicit pin, and the AUTO
|
||||
//! path delegates to `LlmManager`'s strength tiering + priority ordering.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::activation::ToolRendering;
|
||||
use agent_loop::async_trait;
|
||||
use agent_loop::model::{ModelHandle, ModelHint, ModelInfo, ModelSelector};
|
||||
use agent_loop::ids::ModelId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm::{DtlMode, LlmEntry, LlmManager, LlmStrength};
|
||||
|
||||
/// Maps Skald's per-model DTL mode to the crate's wire protocol (D15).
|
||||
pub fn tool_rendering_of(dtl: DtlMode) -> ToolRendering {
|
||||
match dtl {
|
||||
DtlMode::None => ToolRendering::Inline,
|
||||
DtlMode::AnthropicToolReference => ToolRendering::DeferredToolReference,
|
||||
DtlMode::KimiSystemTools => ToolRendering::SystemToolBlock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the crate-side metadata for a resolved entry. `extras` stays empty:
|
||||
/// the model's `extra_params` are already baked into the client at build time
|
||||
/// (they would otherwise be merged into every request body a second time).
|
||||
pub fn model_info_of(entry: &LlmEntry) -> ModelInfo {
|
||||
ModelInfo {
|
||||
prompt_cache: entry.prompt_cache,
|
||||
capabilities: entry.capabilities.clone(),
|
||||
tool_rendering: tool_rendering_of(entry.dtl),
|
||||
extras: Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selector handed to the loop manager for one turn: the manager's
|
||||
/// strength tiering + health + priority, behind the crate's seam.
|
||||
pub struct SkaldSelector {
|
||||
manager: Arc<LlmManager>,
|
||||
strength: Option<LlmStrength>,
|
||||
}
|
||||
|
||||
impl SkaldSelector {
|
||||
pub fn new(manager: Arc<LlmManager>, strength: Option<LlmStrength>) -> Self {
|
||||
Self { manager, strength }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelSelector for SkaldSelector {
|
||||
async fn select(&self, hint: &ModelHint, exclude: &[ModelId]) -> agent_loop::Result<ModelHandle> {
|
||||
let (name, entry) = if exclude.is_empty() {
|
||||
// First selection of the round: pin (hint.name) or AUTO by strength.
|
||||
self.manager.resolve(hint.name.as_deref(), self.strength).await?
|
||||
} else {
|
||||
// Fallback: next healthy model in tier/priority order, skipping the
|
||||
// ones already tried. The pin is intentionally dropped (it failed).
|
||||
let excluded: Vec<&str> = exclude.iter().map(String::as_str).collect();
|
||||
self.manager.select_excluding(&excluded, self.strength).await?
|
||||
};
|
||||
Ok(ModelHandle {
|
||||
id: name,
|
||||
model: entry.client.clone(),
|
||||
info: model_info_of(&entry),
|
||||
})
|
||||
}
|
||||
|
||||
async fn report_success(&self, id: &ModelId) {
|
||||
self.manager.mark_success(id).await;
|
||||
}
|
||||
|
||||
async fn report_failure(&self, id: &ModelId, err: &str) {
|
||||
self.manager.mark_failure(id, err).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
fn temp_db_path(tag: &str) -> String {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn cleanup(path: &str) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path}{suffix}"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn manager_with_two_models(tag: &str) -> (Arc<LlmManager>, Arc<SqlitePool>, String) {
|
||||
// Building reqwest clients (rustls-no-provider) needs the process-wide
|
||||
// crypto provider main() installs in production. Idempotent.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let path = temp_db_path(tag);
|
||||
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
|
||||
sqlx::query("INSERT INTO llm_providers (id, name, type, api_key) VALUES (1, 'test', 'open_ai', 'sk-test')")
|
||||
.execute(&*pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// weak: low strength, better priority; strong: high strength.
|
||||
sqlx::query("INSERT INTO llm_models (provider_id, model_id, name, strength, priority) VALUES
|
||||
(1, 'weak-id', 'weak-model', 'low', 10),
|
||||
(1, 'strong-id', 'strong-model', 'high', 20)")
|
||||
.execute(&*pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let bus = Arc::new(core_api::system_bus::SystemEventBus::new());
|
||||
let mut registry = crate::provider::ProviderRegistry::new(bus);
|
||||
registry.register_builtin(crate::llm::providers::openai::OpenAiProvider);
|
||||
let manager = LlmManager::new(pool.clone(), Arc::new(registry), false).await.unwrap();
|
||||
(manager, pool, path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pin_resolves_exact_model() {
|
||||
let (manager, pool, path) = manager_with_two_models("sel-pin").await;
|
||||
let sel = SkaldSelector::new(manager, None);
|
||||
|
||||
let h = sel.select(&ModelHint::name("weak-model"), &[]).await.unwrap();
|
||||
assert_eq!(h.id, "weak-model");
|
||||
|
||||
assert!(sel.select(&ModelHint::name("nope"), &[]).await.is_err());
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_prefers_exact_strength_then_fallback_excludes() {
|
||||
let (manager, pool, path) = manager_with_two_models("sel-auto").await;
|
||||
let sel = SkaldSelector::new(manager, Some(LlmStrength::High));
|
||||
|
||||
// AUTO with strength High: the exact-tier model wins despite worse priority.
|
||||
let h = sel.select(&ModelHint::default(), &[]).await.unwrap();
|
||||
assert_eq!(h.id, "strong-model");
|
||||
|
||||
// Fallback excludes it: the remaining one is served.
|
||||
let h2 = sel.select(&ModelHint::default(), &["strong-model".to_string()]).await.unwrap();
|
||||
assert_eq!(h2.id, "weak-model");
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_reporting_degrades_and_recovers() {
|
||||
let (manager, pool, path) = manager_with_two_models("sel-health").await;
|
||||
let sel = SkaldSelector::new(manager, None);
|
||||
|
||||
for _ in 0..5 {
|
||||
sel.report_failure(&"weak-model".to_string(), "boom").await;
|
||||
}
|
||||
sel.report_success(&"weak-model".to_string()).await;
|
||||
// Still resolvable after recovery.
|
||||
let h = sel.select(&ModelHint::name("weak-model"), &[]).await.unwrap();
|
||||
assert_eq!(h.id, "weak-model");
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -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