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:
2026-07-26 12:15:53 +01:00
parent d50abbb0fa
commit 0297fe71bd
35 changed files with 3160 additions and 89 deletions
+13 -1
View File
@@ -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": {
+3
View File
@@ -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
+417
View File
@@ -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()
}
}
+6
View File
@@ -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,
+1
View File
@@ -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,
+22 -8
View File
@@ -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
+12 -6
View File
@@ -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(&params.model_hint, &[]).await {
let mut handle: ModelHandle = match selector.select(&params.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(&params.model_hint, &tried).await {
match selector.select(&params.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(),
};
+5
View File
@@ -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};
+27 -7
View File
@@ -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(&params.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> {
+14 -4
View File
@@ -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 ──
+24
View File
@@ -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
+6
View File
@@ -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>>,
+138
View File
@@ -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;