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.
125 lines
4.7 KiB
Rust
125 lines
4.7 KiB
Rust
//! Dynamic tool loading (DTL) — the wire PROTOCOL lives in the crate
|
|
//! (blueprint D15), the catalog and persistence stay with the host.
|
|
//!
|
|
//! Three rendering modes ([`ToolRendering`]) decide how dynamically-activated
|
|
//! tools reach the model without invalidating the prompt-cache prefix:
|
|
//!
|
|
//! - `Inline`: active tools go in the `tools` array (every activation changes
|
|
//! the array — no cache).
|
|
//! - `DeferredToolReference`: all activatable tools are declared upfront with
|
|
//! `defer_loading: true`; an activation's tool result carries a
|
|
//! `_tool_references` marker the Anthropic client converts to
|
|
//! `tool_reference` blocks.
|
|
//! - `SystemToolBlock`: activated tools never touch the `tools` array; a
|
|
//! `{role:"system", tools:[…]}` message is appended after the activation's
|
|
//! tool-result group (Kimi/Moonshot speaks this natively).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::ids::MessageId;
|
|
use crate::tool::{Tool, ToolCtx, ToolFailure, ToolOutput};
|
|
|
|
/// How dynamically-activated tools are rendered on the wire. On
|
|
/// [`crate::model::ModelInfo`]; read by `ToolSet::defs` and assemblers,
|
|
/// consumed by the shipped clients.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum ToolRendering {
|
|
/// Only the currently-active tools in the `tools` array.
|
|
#[default]
|
|
Inline,
|
|
/// Anthropic: all activatable tools `defer_loading: true` + tool_reference
|
|
/// blocks in activation results.
|
|
DeferredToolReference,
|
|
/// Kimi K3: `{role:"system", tools:[defs]}` appended after the activation
|
|
/// (append-only, cache-safe).
|
|
SystemToolBlock,
|
|
}
|
|
|
|
/// One activation: the defs of the groups activated at a given anchor message.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Activation {
|
|
pub anchor: MessageId,
|
|
/// OpenAI-shaped tool defs of the groups activated at `anchor`.
|
|
pub defs: Vec<Value>,
|
|
}
|
|
|
|
/// Catalog + persistence of activations — implemented by the host. Consulted
|
|
/// by assemblers (injection) and by host `ToolSet`s (array rendering).
|
|
#[async_trait]
|
|
pub trait ActivationSource: Send + Sync {
|
|
/// The activations in force for a frame, ordered by anchor.
|
|
async fn activations(&self, frame: crate::ids::FrameId) -> crate::Result<Vec<Activation>>;
|
|
}
|
|
|
|
/// Backend of the shipped [`ActivateToolsTool`]: validates the groups, mutates
|
|
/// the grants, persists the activation (anchored at the current message via
|
|
/// `ctx`). Returns the confirmation text shown to the model.
|
|
#[async_trait]
|
|
pub trait ToolActivator: Send + Sync {
|
|
async fn activate(&self, groups: Vec<String>, ctx: &ToolCtx) -> Result<String, ToolFailure>;
|
|
}
|
|
|
|
/// The shipped `activate_tools` tool. To the kernel it's a tool like any
|
|
/// 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, 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]
|
|
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": {
|
|
"name": "activate_tools",
|
|
"description": "Load additional tool groups on demand. Activated tools \
|
|
become available from the next step of this conversation.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"groups": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"description": "Names of the tool groups to activate"
|
|
}
|
|
},
|
|
"required": ["groups"]
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
|
|
let groups: Vec<String> = args["groups"]
|
|
.as_array()
|
|
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
|
|
.unwrap_or_default();
|
|
if groups.is_empty() {
|
|
return Err(ToolFailure::Failed("activate_tools: no groups given".into()));
|
|
}
|
|
let text = self.activator.activate(groups, ctx).await?;
|
|
Ok(ToolOutput::Text(text))
|
|
}
|
|
}
|