agent-loop: root turn driven by the library kernel (phase 2)
ChatSessionHandler now runs the root turn on the agent-loop kernel instead of run_agent_turn; sub-agents follow on the same kernel via DelegateTool. The old loop stays for resume/recovery until phase 3. agent-loop: - DelegateTool + AgentCatalog/AgentProfile (full toolset override, per-child selector/assembler, frame-scoped get), StaticCatalog, FilteredToolSet; sync flow with sticky child_token; batch via the generic fan-out - manager: start_loop skips the registry (children are not double-driving; they ride the parent's token tree); LoopParams gains selector/token overrides - store: get_frame, get_call, set_call_extras; HistoryStore result text aligned to raw-stored semantics (projection formats) - events: ApprovalRequired.request_id, AgentSpawned/Finished parent info; AskUserTool with_name + suggested_answers alias + Question.frame skald-core (loop_adapters + handler): - SkaldAssembler (byte-parity port of MessageBuilder's projection: scratchpad/summary/window, DTL Kimi/Anthropic injection, media, user coalescing, reasoning echo) + AgentSystemContext (prompt layers, substitutions, MCP list, shared folders, user profile) - SkaldAgentCatalog (build_sub_agent_config port), SkaldHumanChannel, scratchpad/todos tools, execute_task sync/async alias, LegacyInterfaceTool, PendingLiveInput - ApprovalGate: PendingWrite diffs via LoopEvent::Host (memory/disk routed like the fs-tools); SkaldWritePreviewHook for executed-write diffs; EventTranslator LoopEvent→ServerEvent (display meta, preview, FileChanged, AgentStart/Done, root-only Done/Truncated/Cancelled) - handle_message: builds TurnParams and drives the kernel; resume of pending tools runs first (results belong to the previous turn); ChatEvent publication stays handler-side; /stop cancels the live loop - ToolRegistry.get_tool/all_tools; def builders made pub(crate) Full workspace suite green (179 skald-core, 34 agent-loop, adapters incl.); two pre-existing doc-test failures fixed along the way.
This commit is contained in:
@@ -66,10 +66,19 @@ pub trait ToolActivator: Send + Sync {
|
||||
/// other — the defs re-read at the next round makes the new grants visible.
|
||||
pub struct ActivateToolsTool {
|
||||
activator: Arc<dyn ToolActivator>,
|
||||
definition_override: Option<Value>,
|
||||
}
|
||||
|
||||
impl ActivateToolsTool {
|
||||
pub fn new(activator: Arc<dyn ToolActivator>) -> Self { Self { activator } }
|
||||
pub fn new(activator: Arc<dyn ToolActivator>) -> Self {
|
||||
Self { activator, definition_override: None }
|
||||
}
|
||||
|
||||
/// Override the advertised definition (legacy parity).
|
||||
pub fn with_definition(mut self, def: Value) -> Self {
|
||||
self.definition_override = Some(def);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -77,6 +86,9 @@ impl Tool for ActivateToolsTool {
|
||||
fn name(&self) -> &str { "activate_tools" }
|
||||
|
||||
fn definition(&self) -> Value {
|
||||
if let Some(def) = &self.definition_override {
|
||||
return def.clone();
|
||||
}
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
|
||||
@@ -275,6 +275,9 @@ fn project_message(out: &mut Vec<Value>, msg: &StoredMessage, result_limit: Opti
|
||||
before a result was recorded]"
|
||||
.to_string()
|
||||
}
|
||||
crate::store::CallState::Failed => {
|
||||
format!("Error: {}", call.result.as_deref().unwrap_or("unknown error"))
|
||||
}
|
||||
_ => call.result.clone().unwrap_or_default(),
|
||||
};
|
||||
if let Some(limit) = result_limit
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
//! Sub-agents as a tool (blueprint §7, D2): the kernel never intercepts
|
||||
//! anything — `delegate` is a tool like any other, dispatched through the
|
||||
//! normal gate/hooks/execution path. A sync child is just a slow tool call the
|
||||
//! parent awaits; a homogeneous batch of sync delegates fans out through the
|
||||
//! kernel's generic concurrency (`concurrency_safe`).
|
||||
//!
|
||||
//! The crate ships the SYNC flow. Async delegation rides the host's
|
||||
//! `AsyncExecutor` (phase-3 concern: Skald wires its durable cron executor
|
||||
//! there); calling it here fails with a clear error.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::async_trait;
|
||||
use crate::context::SystemContextSource;
|
||||
use crate::events::{EventSink, LoopEvent};
|
||||
use crate::ids::FrameId;
|
||||
use crate::manager::{LoopManager, LoopParams, TurnMeta};
|
||||
use crate::model::{ModelHint, ModelSelector};
|
||||
use crate::store::{FrameSpec, HistoryStore, NewMessage};
|
||||
use crate::tool::{SharedToolSet, Tool, ToolCtx, ToolFailure, ToolOutput, ToolSet};
|
||||
|
||||
// ── AgentCatalog ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The agent's kind (from the host's meta). Only `Task` agents are
|
||||
/// dispatchable via `delegate`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentKind {
|
||||
Chat,
|
||||
Task,
|
||||
System,
|
||||
}
|
||||
|
||||
/// A dispatchable agent.
|
||||
#[derive(Clone)]
|
||||
pub struct AgentProfile {
|
||||
pub id: String,
|
||||
pub kind: AgentKind,
|
||||
/// The child's system context (its own prompt — never the parent's, B3).
|
||||
pub context: Arc<dyn SystemContextSource>,
|
||||
/// How the child's tool set derives from the parent's (ignored when
|
||||
/// `toolset` is set).
|
||||
pub tools: ToolSelection,
|
||||
/// Full tool-set override (hosts whose children need a fresh registry
|
||||
/// rather than a filtered view of the parent's — e.g. fresh grant sets).
|
||||
pub toolset: Option<Arc<dyn ToolSet>>,
|
||||
/// Model pin (bypasses AUTO). Strength is resolved by the host's selector.
|
||||
pub model: Option<ModelHint>,
|
||||
/// Per-child selector override (e.g. a different required strength, D14).
|
||||
pub selector: Option<Arc<dyn ModelSelector>>,
|
||||
/// Per-child assembler override (e.g. scoped DTL activation).
|
||||
pub assembler: Option<Arc<dyn crate::context::ContextAssembler>>,
|
||||
}
|
||||
|
||||
/// How a child's tool set derives from the parent's: strip `remove` by name,
|
||||
/// then append `add`.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ToolSelection {
|
||||
pub remove: Vec<String>,
|
||||
pub add: Vec<Arc<dyn Tool>>,
|
||||
}
|
||||
|
||||
impl ToolSelection {
|
||||
pub fn inherit() -> Self { Self::default() }
|
||||
pub fn minus(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
Self { remove: names.into_iter().map(Into::into).collect(), add: Vec::new() }
|
||||
}
|
||||
pub fn plus(tools: Vec<Arc<dyn Tool>>) -> Self {
|
||||
Self { remove: Vec::new(), add: tools }
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary for catalog listings (a future `list_agents` tool).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentSummary {
|
||||
pub id: String,
|
||||
pub kind: AgentKind,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AgentCatalog: Send + Sync {
|
||||
/// Load a dispatchable profile, built for `child_frame` (already opened by
|
||||
/// the DelegateTool — frame-scoped pieces like grants/activation anchor to
|
||||
/// it). MUST reject non-`Task` kinds and unknown ids.
|
||||
async fn get(&self, id: &str, child_frame: FrameId) -> crate::Result<AgentProfile>;
|
||||
async fn list(&self, kind: AgentKind) -> Vec<AgentSummary>;
|
||||
/// Frame-exit hook (host cleanup, e.g. deleting stack-scoped activations).
|
||||
async fn on_child_closed(&self, _frame: crate::ids::FrameId) {}
|
||||
}
|
||||
|
||||
// ── FilteredToolSet ──────────────────────────────────────────────────────────
|
||||
|
||||
/// The child's tool set: parent's minus `remove`, plus `add`.
|
||||
pub struct FilteredToolSet {
|
||||
inner: Arc<dyn ToolSet>,
|
||||
remove: Vec<String>,
|
||||
add: Vec<Arc<dyn Tool>>,
|
||||
}
|
||||
|
||||
impl ToolSet for FilteredToolSet {
|
||||
fn defs(&self, model: &crate::model::ModelInfo) -> Vec<Value> {
|
||||
let mut defs: Vec<Value> = self
|
||||
.inner
|
||||
.defs(model)
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
let name = d["function"]["name"].as_str().unwrap_or("");
|
||||
!self.remove.iter().any(|r| r == name)
|
||||
})
|
||||
.collect();
|
||||
defs.extend(self.add.iter().map(|t| t.definition()));
|
||||
defs
|
||||
}
|
||||
|
||||
fn find(&self, name: &str) -> Option<Arc<dyn Tool>> {
|
||||
if let Some(t) = self.add.iter().find(|t| t.name() == name) {
|
||||
return Some(t.clone());
|
||||
}
|
||||
if self.remove.iter().any(|r| r == name) {
|
||||
return None;
|
||||
}
|
||||
self.inner.find(name)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DelegateTool ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The shipped `delegate` tool. The parent loop simply awaits a slow tool —
|
||||
/// nesting is reconstructed by subscribers from the `parent_frame` event tags.
|
||||
#[derive(Clone)]
|
||||
pub struct DelegateTool {
|
||||
manager: Arc<LoopManager>,
|
||||
catalog: Arc<dyn AgentCatalog>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
max_depth: u32,
|
||||
name: String,
|
||||
definition_override: Option<Value>,
|
||||
}
|
||||
|
||||
impl DelegateTool {
|
||||
pub fn new(
|
||||
manager: Arc<LoopManager>,
|
||||
catalog: Arc<dyn AgentCatalog>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
max_depth: u32,
|
||||
) -> Self {
|
||||
Self { manager, catalog, store, max_depth, name: "delegate".to_string(), definition_override: None }
|
||||
}
|
||||
|
||||
/// Register under a different wire name (Skald's legacy aliases
|
||||
/// `execute_task` / `execute_subtask`, blueprint D11).
|
||||
pub fn with_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.name = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the advertised definition (legacy aliases keep their exact
|
||||
/// legacy schema byte-for-byte).
|
||||
pub fn with_definition(mut self, def: Value) -> Self {
|
||||
self.definition_override = Some(def);
|
||||
self
|
||||
}
|
||||
|
||||
/// The schema: `agent_id` + `prompt` required; `title`, `description`,
|
||||
/// `mode` ("sync" — async rides the host executor), `client` accepted for
|
||||
/// legacy compatibility.
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": { "type": "string", "description": "Id of the task agent to delegate to" },
|
||||
"prompt": { "type": "string", "description": "The full brief for the sub-agent" },
|
||||
"title": { "type": "string", "description": "Optional short title for the task" },
|
||||
"description": { "type": "string", "description": "Optional longer description" },
|
||||
"mode": { "type": "string", "enum": ["sync", "async"],
|
||||
"description": "sync: wait for the result. async: host-scheduled (if wired)" },
|
||||
"client": { "type": "string", "description": "Optional model override" }
|
||||
},
|
||||
"required": ["agent_id", "prompt"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_sync(&self, agent_id: &str, prompt: &str, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
|
||||
if agent_id == ctx.agent {
|
||||
return Err(ToolFailure::Failed(format!(
|
||||
"delegate: an agent cannot call itself (`{agent_id}`)"
|
||||
)));
|
||||
}
|
||||
|
||||
// Depth check (max recursion, from the parent frame).
|
||||
let parent_frame = self
|
||||
.store
|
||||
.get_frame(ctx.frame)
|
||||
.await
|
||||
.map_err(|e| ToolFailure::Failed(format!("delegate: frame lookup failed: {e}")))?
|
||||
.ok_or_else(|| ToolFailure::Failed("delegate: parent frame not found".into()))?;
|
||||
let new_depth = parent_frame.spec.depth + 1;
|
||||
if new_depth > self.max_depth {
|
||||
return Err(ToolFailure::Failed(format!(
|
||||
"delegate: maximum agent depth ({}) exceeded — refusing to recurse further",
|
||||
self.max_depth
|
||||
)));
|
||||
}
|
||||
|
||||
let child_frame = self
|
||||
.store
|
||||
.open_frame(&ctx.conversation, Some(ctx.frame), FrameSpec {
|
||||
agent: agent_id.to_string(),
|
||||
prompt: Some(prompt.to_string()),
|
||||
depth: new_depth,
|
||||
parent_call: Some(ctx.call_id),
|
||||
meta: Value::Null,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolFailure::Failed(format!("delegate: open frame failed: {e}")))?;
|
||||
|
||||
// Profile AFTER the frame exists (frame-scoped pieces anchor to it).
|
||||
// On rejection the frame is closed so nothing dangles.
|
||||
let profile = match self.catalog.get(agent_id, child_frame).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = self.store.close_frame(child_frame).await;
|
||||
return Err(ToolFailure::Failed(format!("delegate: {e}")));
|
||||
}
|
||||
};
|
||||
if profile.kind != AgentKind::Task {
|
||||
let _ = self.store.close_frame(child_frame).await;
|
||||
return Err(ToolFailure::Failed(format!(
|
||||
"delegate: agent `{agent_id}` is not dispatchable (only task agents are)"
|
||||
)));
|
||||
}
|
||||
|
||||
self.store
|
||||
.append(child_frame, NewMessage::agent(prompt))
|
||||
.await
|
||||
.map_err(|e| ToolFailure::Failed(format!("delegate: append failed: {e}")))?;
|
||||
|
||||
let events = EventSink::from_extensions(&ctx.extensions);
|
||||
if let Some(ev) = &events {
|
||||
ev.emit(child_frame, Some(ctx.frame), LoopEvent::AgentSpawned {
|
||||
frame: child_frame,
|
||||
agent: agent_id.to_string(),
|
||||
depth: new_depth,
|
||||
prompt_preview: preview_truncate(prompt, 500),
|
||||
parent_call: ctx.call_id,
|
||||
parent_agent: ctx.agent.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// The child's tool set: the profile's full override, or the parent's
|
||||
// filtered per its ToolSelection.
|
||||
let child_tools: Arc<dyn ToolSet> = match profile.toolset.clone() {
|
||||
Some(ts) => ts,
|
||||
None => {
|
||||
let parent_tools = ctx
|
||||
.extensions
|
||||
.get::<SharedToolSet>()
|
||||
.ok_or_else(|| ToolFailure::Failed("delegate: no ToolSet in extensions".into()))?;
|
||||
Arc::new(FilteredToolSet {
|
||||
inner: parent_tools.0.clone(),
|
||||
remove: profile.tools.remove.clone(),
|
||||
add: profile.tools.add.clone(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let child = self
|
||||
.manager
|
||||
.start_loop(LoopParams {
|
||||
conversation: ctx.conversation.clone(),
|
||||
frame: child_frame,
|
||||
parent_frame: Some(ctx.frame),
|
||||
agent: agent_id.to_string(),
|
||||
system: profile.context,
|
||||
tools: child_tools,
|
||||
model_hint: profile.model.unwrap_or_default(),
|
||||
selector: profile.selector,
|
||||
// Sticky /stop: the child rides the parent's cancellation tree.
|
||||
token: Some(ctx.cancel.child_token()),
|
||||
live_input: None,
|
||||
extensions: ctx.extensions.clone(),
|
||||
meta: TurnMeta::default(),
|
||||
assembler: profile.assembler,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolFailure::Failed(format!("delegate: start loop failed: {e}")))?;
|
||||
|
||||
let outcome = child.join().await;
|
||||
|
||||
self.catalog.on_child_closed(child_frame).await;
|
||||
let _ = self.store.close_frame(child_frame).await;
|
||||
|
||||
let result_preview = |s: &str| preview_truncate(s, 500);
|
||||
let emit_done = |text: &str| {
|
||||
if let Some(ev) = &events {
|
||||
ev.emit(child_frame, Some(ctx.frame), LoopEvent::AgentFinished {
|
||||
frame: child_frame,
|
||||
agent: agent_id.to_string(),
|
||||
result_preview: result_preview(text),
|
||||
parent_agent: ctx.agent.clone(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Ok(crate::kernel::TurnOutcome::Final { content, .. }) => {
|
||||
emit_done(&content);
|
||||
Ok(ToolOutput::Text(content))
|
||||
}
|
||||
Ok(crate::kernel::TurnOutcome::Cancelled) => {
|
||||
emit_done("⚠️ Cancelled.");
|
||||
Ok(ToolOutput::Text(format!("Sub-agent `{agent_id}` was cancelled.")))
|
||||
}
|
||||
Ok(crate::kernel::TurnOutcome::Exhausted) => {
|
||||
emit_done("⚠️ Exhausted tool-call rounds.");
|
||||
Ok(ToolOutput::Text(format!(
|
||||
"Sub-agent `{agent_id}` exceeded the tool-call round budget without producing a final answer."
|
||||
)))
|
||||
}
|
||||
Err(e) => {
|
||||
emit_done(&format!("⚠️ Error: {e}"));
|
||||
Err(ToolFailure::Failed(format!("Sub-agent `{agent_id}` failed: {e}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for DelegateTool {
|
||||
fn name(&self) -> &str { &self.name }
|
||||
|
||||
fn definition(&self) -> Value {
|
||||
if let Some(def) = &self.definition_override {
|
||||
return def.clone();
|
||||
}
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": "Delegate a task to a sub-agent and wait for its result. \
|
||||
Use for focused, well-scoped work that benefits from a clean context.",
|
||||
"parameters": self.schema(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Sync delegates batch: a homogeneous fan-out runs them concurrently
|
||||
/// (the kernel allocates ids in order first — results never mix).
|
||||
fn concurrency_safe(&self, args: &Value) -> bool {
|
||||
args["mode"].as_str() != Some("async")
|
||||
}
|
||||
|
||||
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
|
||||
let agent_id = args["agent_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| ToolFailure::Failed("delegate: missing required argument `agent_id`".into()))?;
|
||||
let prompt = args["prompt"]
|
||||
.as_str()
|
||||
.ok_or_else(|| ToolFailure::Failed("delegate: missing required argument `prompt`".into()))?;
|
||||
|
||||
match args["mode"].as_str() {
|
||||
Some("async") => Err(ToolFailure::Failed(
|
||||
"delegate: async mode rides the host's AsyncExecutor, which is not wired on this path"
|
||||
.to_string(),
|
||||
)),
|
||||
_ => self.run_sync(agent_id, prompt, ctx).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate to `max` chars with an ellipsis (previews).
|
||||
pub fn preview_truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let cut: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
format!("{cut}…")
|
||||
}
|
||||
|
||||
/// A static catalog for tests and simple hosts.
|
||||
pub struct StaticCatalog {
|
||||
profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
impl StaticCatalog {
|
||||
pub fn new() -> Self { Self { profiles: Vec::new() } }
|
||||
|
||||
pub fn with(mut self, profile: AgentProfile) -> Self {
|
||||
self.profiles.push(profile);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StaticCatalog {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentCatalog for StaticCatalog {
|
||||
async fn get(&self, id: &str, _child_frame: FrameId) -> crate::Result<AgentProfile> {
|
||||
self.profiles
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown agent `{id}`"))
|
||||
}
|
||||
|
||||
async fn list(&self, kind: AgentKind) -> Vec<AgentSummary> {
|
||||
self.profiles
|
||||
.iter()
|
||||
.filter(|p| p.kind == kind)
|
||||
.map(|p| AgentSummary { id: p.id.clone(), kind: p.kind, description: String::new() })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,8 @@ pub enum LoopEvent {
|
||||
id: ToolCallId,
|
||||
name: String,
|
||||
args: Value,
|
||||
/// The approval request id in the host's registry (for UI resolution).
|
||||
request_id: i64,
|
||||
},
|
||||
// ── sub-agents (emitted by child loops; parent_frame in the tag) ──
|
||||
AgentSpawned {
|
||||
@@ -72,11 +74,15 @@ pub enum LoopEvent {
|
||||
agent: String,
|
||||
depth: u32,
|
||||
prompt_preview: String,
|
||||
/// The parent frame's tool call that spawned this agent.
|
||||
parent_call: ToolCallId,
|
||||
parent_agent: String,
|
||||
},
|
||||
AgentFinished {
|
||||
frame: FrameId,
|
||||
agent: String,
|
||||
result_preview: String,
|
||||
parent_agent: String,
|
||||
},
|
||||
AsyncResultReady {
|
||||
task: TaskId,
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct PendingCall {
|
||||
pub name: String,
|
||||
pub args: Value,
|
||||
pub frame: FrameId,
|
||||
pub parent_frame: Option<FrameId>,
|
||||
pub agent: String,
|
||||
/// Host free-form (source, permission group, …).
|
||||
pub extensions: Extensions,
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct Question {
|
||||
pub suggested: Vec<String>,
|
||||
/// The tool call asking (for UI correlation).
|
||||
pub call: ToolCallId,
|
||||
/// The frame asking (for event tagging).
|
||||
pub frame: crate::ids::FrameId,
|
||||
}
|
||||
|
||||
/// The human channel closed while waiting (WS down, user gone).
|
||||
@@ -46,23 +48,30 @@ pub trait HumanChannel: Send + Sync {
|
||||
pub struct AskUserTool {
|
||||
channel: Arc<dyn HumanChannel>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl AskUserTool {
|
||||
pub fn new(channel: Arc<dyn HumanChannel>, store: Arc<dyn HistoryStore>) -> Self {
|
||||
Self { channel, store }
|
||||
Self { channel, store, name: "ask_user".to_string() }
|
||||
}
|
||||
|
||||
/// Register under a legacy name (Skald's `ask_user_clarification`, D11).
|
||||
pub fn with_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.name = name.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for AskUserTool {
|
||||
fn name(&self) -> &str { "ask_user" }
|
||||
fn name(&self) -> &str { &self.name }
|
||||
|
||||
fn definition(&self) -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ask_user",
|
||||
"name": self.name,
|
||||
"description": "Ask the user a clarifying question and wait for the answer.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -70,7 +79,9 @@ impl Tool for AskUserTool {
|
||||
"title": { "type": "string", "description": "Short title of the question" },
|
||||
"question": { "type": "string", "description": "The question to ask" },
|
||||
"suggested": { "type": "array", "items": { "type": "string" },
|
||||
"description": "Optional suggested answers" }
|
||||
"description": "Optional suggested answers" },
|
||||
"suggested_answers": { "type": "array", "items": { "type": "string" },
|
||||
"description": "Optional suggested answers (legacy alias of `suggested`)" }
|
||||
},
|
||||
"required": ["question"]
|
||||
}
|
||||
@@ -79,14 +90,17 @@ impl Tool for AskUserTool {
|
||||
}
|
||||
|
||||
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
|
||||
let suggested = args["suggested"]
|
||||
.as_array()
|
||||
.or_else(|| args["suggested_answers"].as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
let q = Question {
|
||||
title: args["title"].as_str().unwrap_or("Question").to_string(),
|
||||
question: args["question"].as_str().unwrap_or("").to_string(),
|
||||
suggested: args["suggested"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default(),
|
||||
suggested,
|
||||
call: ctx.call_id,
|
||||
frame: ctx.frame,
|
||||
};
|
||||
// Durability FIRST: the call must survive a crash as AwaitingHuman.
|
||||
self.store
|
||||
|
||||
@@ -83,18 +83,23 @@ pub(crate) async fn run(
|
||||
events: events.clone(),
|
||||
};
|
||||
|
||||
// ToolCtx extensions: host extensions + the event sink, so shipped tools
|
||||
// (ask_user, activate_tools) can emit out-of-band.
|
||||
// ToolCtx extensions: host extensions + the event sink + the turn's tool
|
||||
// set, so shipped tools (ask_user, activate_tools, delegate) reach what
|
||||
// they need.
|
||||
let tool_extensions = || {
|
||||
let mut ext = params.extensions.clone();
|
||||
ext.insert(Arc::new(events.clone()));
|
||||
ext.insert(Arc::new(crate::tool::SharedToolSet(params.tools.clone())));
|
||||
ext
|
||||
};
|
||||
|
||||
events.emit(frame, parent, LoopEvent::TurnStarted);
|
||||
|
||||
// Per-loop selector override (sub-agents with their own strength, D14).
|
||||
let selector: &Arc<dyn ModelSelector> = params.selector.as_ref().unwrap_or(&deps.models);
|
||||
|
||||
// First selection of the turn.
|
||||
let mut handle: ModelHandle = match deps.models.select(¶ms.model_hint, &[]).await {
|
||||
let mut handle: ModelHandle = match selector.select(¶ms.model_hint, &[]).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
events.emit(frame, parent, LoopEvent::Error(format!("model selection failed: {e}")));
|
||||
@@ -171,11 +176,11 @@ pub(crate) async fn run(
|
||||
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
deps.models.report_success(&handle.id).await;
|
||||
selector.report_success(&handle.id).await;
|
||||
break resp;
|
||||
}
|
||||
Err(e) => {
|
||||
deps.models.report_failure(&handle.id, &e.to_string()).await;
|
||||
selector.report_failure(&handle.id, &e.to_string()).await;
|
||||
let retriable = handle.model.is_retriable(&e);
|
||||
warn!(model = %handle.id, error = %e, retriable, "llm call failed");
|
||||
if !retriable || tried.len() >= deps.retry.max_attempts {
|
||||
@@ -185,7 +190,7 @@ pub(crate) async fn run(
|
||||
});
|
||||
return Err(anyhow!("llm call failed on {}: {e}", handle.id));
|
||||
}
|
||||
match deps.models.select(¶ms.model_hint, &tried).await {
|
||||
match selector.select(¶ms.model_hint, &tried).await {
|
||||
Ok(next) => {
|
||||
events.emit(frame, parent, LoopEvent::ModelFallback {
|
||||
from: handle.id.clone(),
|
||||
@@ -527,6 +532,7 @@ async fn pre_execution(
|
||||
name: ptc.name.clone(),
|
||||
args: ptc.arguments.clone(),
|
||||
frame: params.frame,
|
||||
parent_frame: params.parent_frame,
|
||||
agent: params.agent.clone(),
|
||||
extensions: params.extensions.clone(),
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
pub mod activation;
|
||||
pub mod context;
|
||||
pub mod delegate;
|
||||
pub mod events;
|
||||
pub mod gate;
|
||||
pub mod hooks;
|
||||
@@ -46,6 +47,10 @@ pub mod prelude {
|
||||
AssembleInput, ContextAssembler, LinearAssembler, StaticSystemContext, SystemContext,
|
||||
SystemContextSource, TurnInfo,
|
||||
};
|
||||
pub use crate::delegate::{
|
||||
AgentCatalog, AgentKind, AgentProfile, AgentSummary, DelegateTool, FilteredToolSet,
|
||||
StaticCatalog, ToolSelection,
|
||||
};
|
||||
pub use crate::events::{DeltaKind, Event, EventSink, LoopEvent};
|
||||
pub use crate::gate::{AllowAll, DenyList, Gate, GateDecision, PendingCall};
|
||||
pub use crate::hooks::{HookCtx, HookVerdict, LoopHooks};
|
||||
|
||||
@@ -76,6 +76,12 @@ pub struct LoopParams {
|
||||
pub system: Arc<dyn SystemContextSource>,
|
||||
pub tools: Arc<dyn ToolSet>,
|
||||
pub model_hint: ModelHint,
|
||||
/// Per-loop selector override (e.g. a sub-agent with its own strength,
|
||||
/// blueprint D14). `None` = the manager's selector.
|
||||
pub selector: Option<Arc<dyn crate::model::ModelSelector>>,
|
||||
/// Parent-linked cancellation (DelegateTool passes `ctx.cancel.child_token()`):
|
||||
/// `None` = a fresh scope. Cancellation stays sticky down the tree.
|
||||
pub token: Option<CancellationToken>,
|
||||
pub live_input: Option<Arc<dyn LiveInput>>,
|
||||
pub extensions: Extensions,
|
||||
pub meta: TurnMeta,
|
||||
@@ -206,6 +212,8 @@ impl LoopManager {
|
||||
system: params.system,
|
||||
tools: params.tools,
|
||||
model_hint: params.model_hint,
|
||||
selector: None,
|
||||
token: None,
|
||||
live_input: params.live_input,
|
||||
extensions: params.extensions,
|
||||
meta: params.meta,
|
||||
@@ -215,14 +223,26 @@ impl LoopManager {
|
||||
|
||||
// ── raw loops (DelegateTool, recovery, background runners) ──
|
||||
|
||||
/// Spawn a raw loop. Unlike `start_turn` this does NOT enforce the
|
||||
/// one-loop-per-conversation rule and does NOT register in the live
|
||||
/// registry: child loops (sub-agents, including concurrent batches) run
|
||||
/// on the same conversation as their parent and are cancelled through
|
||||
/// the parent's token tree (`child_token()`), not the registry.
|
||||
pub async fn start_loop(&self, params: LoopParams) -> Result<TurnHandle, StartError> {
|
||||
{
|
||||
let registry = self.registry.lock().unwrap();
|
||||
if registry.contains_key(¶ms.conversation) {
|
||||
return Err(StartError::AlreadyRunning);
|
||||
}
|
||||
}
|
||||
self.spawn(params)
|
||||
self.spawn_detached(params)
|
||||
}
|
||||
|
||||
fn spawn_detached(&self, params: LoopParams) -> Result<TurnHandle, StartError> {
|
||||
let conv = params.conversation.clone();
|
||||
let frame = params.frame;
|
||||
let token = params.token.clone().unwrap_or_default();
|
||||
let events = self.sink(conv.clone());
|
||||
|
||||
let deps = self.deps.clone();
|
||||
let turn_token = token.clone();
|
||||
let join = tokio::spawn(async move { crate::kernel::run(deps, params, turn_token, events).await });
|
||||
|
||||
Ok(TurnHandle { conversation: conv, frame, cancel: token, join })
|
||||
}
|
||||
|
||||
fn spawn(&self, params: LoopParams) -> Result<TurnHandle, StartError> {
|
||||
|
||||
@@ -75,13 +75,16 @@ impl CallOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text persisted as the call's result (what the model will read back).
|
||||
/// Text persisted as the call's result. Kept RAW (the assembler formats
|
||||
/// for the model: `Failed` results get their "Error:" prefix at
|
||||
/// projection time, not here) so hosts with an existing schema (Skald's
|
||||
/// `chat_llm_tools.result`) round-trip byte-identically.
|
||||
pub fn result_text(&self) -> String {
|
||||
match self {
|
||||
Self::Completed(out) => out.to_wire(),
|
||||
Self::Failed(e) => format!("Error: {e}"),
|
||||
Self::Cancelled => "Tool call cancelled by user.".to_string(),
|
||||
Self::Rejected { reason } => format!("Tool call rejected: {reason}"),
|
||||
Self::Failed(e) => e.clone(),
|
||||
Self::Cancelled => "Cancelled by user.".to_string(),
|
||||
Self::Rejected { reason } => reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +256,8 @@ pub trait HistoryStore: Send + Sync {
|
||||
spec: FrameSpec,
|
||||
) -> crate::Result<FrameId>;
|
||||
async fn close_frame(&self, frame: FrameId) -> crate::Result<()>;
|
||||
/// One frame by id (DelegateTool depth checks, recovery).
|
||||
async fn get_frame(&self, frame: FrameId) -> crate::Result<Option<FrameRecord>>;
|
||||
/// All active frames of a conversation (recovery: batch detection, cascade).
|
||||
async fn active_frames(&self, conv: &ConversationId) -> crate::Result<Vec<FrameRecord>>;
|
||||
async fn deepest_active(&self, conv: &ConversationId) -> crate::Result<Option<FrameRecord>>;
|
||||
@@ -273,6 +278,11 @@ pub trait HistoryStore: Send + Sync {
|
||||
async fn resolve_call(&self, id: ToolCallId, outcome: &CallOutcome) -> crate::Result<()>;
|
||||
/// Only `Running → AwaitingHuman`.
|
||||
async fn set_call_state(&self, id: ToolCallId, state: CallState) -> crate::Result<()>;
|
||||
/// One call by id (translators enriching finish events, recovery).
|
||||
async fn get_call(&self, id: ToolCallId) -> crate::Result<Option<StoredCall>>;
|
||||
/// Merge host free-form extras into a call (Skald: diff preview, media).
|
||||
/// Keys not understood by the store are ignored.
|
||||
async fn set_call_extras(&self, id: ToolCallId, extras: Value) -> crate::Result<()>;
|
||||
async fn calls_in_state(&self, frame: FrameId, states: &[CallState]) -> crate::Result<Vec<StoredCall>>;
|
||||
|
||||
// ── summaries ──
|
||||
|
||||
@@ -69,6 +69,11 @@ impl HistoryStore for InMemoryStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_frame(&self, frame: FrameId) -> crate::Result<Option<FrameRecord>> {
|
||||
let i = self.inner.lock().unwrap();
|
||||
Ok(i.frames.get(&frame).cloned())
|
||||
}
|
||||
|
||||
async fn active_frames(&self, conv: &ConversationId) -> crate::Result<Vec<FrameRecord>> {
|
||||
let i = self.inner.lock().unwrap();
|
||||
Ok(i.frames.values().filter(|f| f.active && &f.conversation == conv).cloned().collect())
|
||||
@@ -185,6 +190,25 @@ impl HistoryStore for InMemoryStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_call(&self, id: ToolCallId) -> crate::Result<Option<StoredCall>> {
|
||||
let i = self.inner.lock().unwrap();
|
||||
Ok(i.calls.values().flatten().find(|c| c.id == id).cloned())
|
||||
}
|
||||
|
||||
async fn set_call_extras(&self, id: ToolCallId, extras: serde_json::Value) -> crate::Result<()> {
|
||||
let mut i = self.inner.lock().unwrap();
|
||||
update_call(&mut i, id, |c| {
|
||||
if let (Some(dst), Some(src)) = (c.extras.as_object_mut(), extras.as_object()) {
|
||||
for (k, v) in src {
|
||||
dst.insert(k.clone(), v.clone());
|
||||
}
|
||||
} else {
|
||||
c.extras = extras.clone();
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn calls_in_state(&self, frame: FrameId, states: &[CallState]) -> crate::Result<Vec<StoredCall>> {
|
||||
let i = self.inner.lock().unwrap();
|
||||
Ok(i.messages
|
||||
|
||||
@@ -207,6 +207,12 @@ pub trait ToolSet: Send + Sync {
|
||||
fn find(&self, name: &str) -> Option<Arc<dyn Tool>>;
|
||||
}
|
||||
|
||||
/// Wrapper so `Arc<dyn ToolSet>` can ride in [`Extensions`] (type-map keys
|
||||
/// must be `Sized`). The kernel inserts one into every `ToolCtx`; shipped
|
||||
/// tools that spawn child loops (delegate) inherit from it.
|
||||
#[derive(Clone)]
|
||||
pub struct SharedToolSet(pub Arc<dyn ToolSet>);
|
||||
|
||||
/// A trivial `ToolSet` from a list of tools (testing, simple hosts).
|
||||
pub struct ToolRegistry {
|
||||
tools: Vec<Arc<dyn Tool>>,
|
||||
|
||||
@@ -15,6 +15,7 @@ use agent_loop::store_memory::InMemoryStore;
|
||||
use agent_loop::testing::{self, FakeModel, Step};
|
||||
use agent_loop::tool::{Tool, ToolCtx, ToolFailure, ToolOutput, ToolRegistry};
|
||||
use agent_loop::context::StaticSystemContext;
|
||||
use agent_loop::delegate::{AgentCatalog, AgentKind, AgentProfile, DelegateTool, ToolSelection};
|
||||
use agent_loop::events::LoopEvent;
|
||||
use serde_json::{Value, json};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -496,3 +497,140 @@ async fn second_loop_on_same_conversation_rejected() {
|
||||
handle.cancel.cancel();
|
||||
let _ = handle.join().await;
|
||||
}
|
||||
|
||||
// ── delegate (sub-agents as a tool) ──
|
||||
|
||||
struct TestCatalog {
|
||||
context: Arc<StaticSystemContext>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentCatalog for TestCatalog {
|
||||
async fn get(&self, id: &str, _child_frame: agent_loop::ids::FrameId) -> agent_loop::Result<AgentProfile> {
|
||||
Ok(AgentProfile {
|
||||
id: id.into(),
|
||||
kind: AgentKind::Task,
|
||||
context: self.context.clone(),
|
||||
tools: ToolSelection::inherit(),
|
||||
toolset: None,
|
||||
model: None,
|
||||
selector: None,
|
||||
assembler: None,
|
||||
})
|
||||
}
|
||||
async fn list(&self, _kind: AgentKind) -> Vec<agent_loop::delegate::AgentSummary> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_delegate_runs_child_loop_and_returns_result() {
|
||||
let script = vec![
|
||||
Step::tool_calls("delegating", vec![testing::call("c1", "delegate", json!({"agent_id":"researcher","prompt":"find X"}))]),
|
||||
Step::message("research says: X=42"),
|
||||
Step::message("final answer with X=42"),
|
||||
];
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let manager = Arc::new(
|
||||
LoopManager::builder()
|
||||
.models(Arc::new(agent_loop::model::SingleModel::new(FakeModel::new("m", script))))
|
||||
.store(store.clone())
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let catalog: Arc<dyn AgentCatalog> = Arc::new(TestCatalog {
|
||||
context: Arc::new(StaticSystemContext::new("You are a researcher.")),
|
||||
});
|
||||
let delegate: Arc<dyn Tool> = Arc::new(DelegateTool::new(manager.clone(), catalog, manager.store(), 5));
|
||||
|
||||
let conv = ConversationId::new("d1");
|
||||
let frame = manager.open_root(&conv, FrameSpec::root("assistant")).await.unwrap();
|
||||
let p = TurnParams {
|
||||
frame,
|
||||
agent: "assistant".into(),
|
||||
system: Arc::new(StaticSystemContext::new("root")),
|
||||
tools: ToolRegistry::new().with_arc(delegate).into_toolset(),
|
||||
model_hint: ModelHint::default(),
|
||||
live_input: None,
|
||||
extensions: Default::default(),
|
||||
meta: TurnMeta::default(),
|
||||
assembler: None,
|
||||
};
|
||||
|
||||
let handle = manager.start_turn(conv.clone(), NewMessage::user("what is X?"), p).await.unwrap();
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(5), handle.join())
|
||||
.await
|
||||
.expect("delegate turn hung")
|
||||
.unwrap();
|
||||
let TurnOutcome::Final { content, .. } = outcome else { panic!("expected Final, got {outcome:?}") };
|
||||
assert_eq!(content, "final answer with X=42");
|
||||
|
||||
// The parent's delegate call resolved Done with the CHILD's answer as result.
|
||||
let done = store.calls_in_state(frame, &[CallState::Done]).await.unwrap();
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!(done[0].result.as_deref(), Some("research says: X=42"));
|
||||
|
||||
// The child frame exists, closed, with its Agent prompt + assistant answer.
|
||||
let frames = store.active_frames(&conv).await.unwrap();
|
||||
assert!(frames.iter().all(|f| f.spec.depth == 0), "child frame must be closed");
|
||||
let history_all = store.load(frame).await.unwrap();
|
||||
assert!(history_all.iter().any(|m| m.role == agent_loop::store::Role::Assistant && m.content == "final answer with X=42"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegate_batch_fans_out_concurrently() {
|
||||
let script = vec![
|
||||
Step::tool_calls("", vec![
|
||||
testing::call("c1", "delegate", json!({"agent_id":"a1","prompt":"job one"})),
|
||||
testing::call("c2", "delegate", json!({"agent_id":"a2","prompt":"job two"})),
|
||||
]),
|
||||
Step::message("result one"),
|
||||
Step::message("result two"),
|
||||
Step::message("both done"),
|
||||
];
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let manager = Arc::new(
|
||||
LoopManager::builder()
|
||||
.models(Arc::new(agent_loop::model::SingleModel::new(FakeModel::new("m", script))))
|
||||
.store(store.clone())
|
||||
.max_parallel_calls(2)
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let catalog: Arc<dyn AgentCatalog> = Arc::new(TestCatalog {
|
||||
context: Arc::new(StaticSystemContext::new("worker")),
|
||||
});
|
||||
let delegate: Arc<dyn Tool> = Arc::new(DelegateTool::new(manager.clone(), catalog, manager.store(), 5));
|
||||
|
||||
let conv = ConversationId::new("d2");
|
||||
let frame = manager.open_root(&conv, FrameSpec::root("assistant")).await.unwrap();
|
||||
let p = TurnParams {
|
||||
frame,
|
||||
agent: "assistant".into(),
|
||||
system: Arc::new(StaticSystemContext::new("root")),
|
||||
tools: ToolRegistry::new().with_arc(delegate).into_toolset(),
|
||||
model_hint: ModelHint::default(),
|
||||
live_input: None,
|
||||
extensions: Default::default(),
|
||||
meta: TurnMeta::default(),
|
||||
assembler: None,
|
||||
};
|
||||
|
||||
let handle = manager.start_turn(conv, NewMessage::user("do both"), p).await.unwrap();
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(5), handle.join())
|
||||
.await
|
||||
.expect("delegate batch hung")
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, TurnOutcome::Final { .. }), "got {outcome:?}");
|
||||
|
||||
// Both delegate calls resolved Done, each carrying one of the child results.
|
||||
let done = store.calls_in_state(frame, &[CallState::Done]).await.unwrap();
|
||||
assert_eq!(done.len(), 2);
|
||||
let results: HashSet<String> = done.iter().filter_map(|c| c.result.clone()).collect();
|
||||
assert_eq!(
|
||||
results,
|
||||
["result one".to_string(), "result two".to_string()].into_iter().collect()
|
||||
);
|
||||
}
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
Reference in New Issue
Block a user