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.
180 lines
5.1 KiB
Rust
180 lines
5.1 KiB
Rust
//! The loop event taxonomy and the broadcast bus.
|
|
//!
|
|
//! Every event is wrapped in [`Event`], tagged with the emitting conversation,
|
|
//! frame and parent frame — subscribers (a UI translator, a logger) reconstruct
|
|
//! nesting from the tags. Transport: `tokio::sync::broadcast` (multi-subscriber,
|
|
//! lag-tolerant).
|
|
|
|
use serde_json::Value;
|
|
use tokio::sync::broadcast;
|
|
|
|
use crate::ids::{ConversationId, FrameId, MessageId, ModelId, TaskId, ToolCallId};
|
|
use crate::model::{ToolCall, Usage};
|
|
use crate::store::CallOutcome;
|
|
|
|
/// Whether a [`LoopEvent::TokenDelta`] carries visible answer text or reasoning.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DeltaKind {
|
|
Content,
|
|
Reasoning,
|
|
}
|
|
|
|
/// Events emitted by a running loop. Every variant is wrapped in [`Event`]
|
|
/// before hitting the bus, so conversation/frame tags are never optional.
|
|
#[derive(Debug, Clone)]
|
|
pub enum LoopEvent {
|
|
// ── turn ──
|
|
TurnStarted,
|
|
RoundStarted {
|
|
round: usize,
|
|
},
|
|
UserMessage {
|
|
message_id: MessageId,
|
|
content: String,
|
|
synthetic: bool,
|
|
metadata: Option<Value>,
|
|
},
|
|
TokenDelta {
|
|
kind: DeltaKind,
|
|
text: String,
|
|
},
|
|
Thinking {
|
|
message_id: MessageId,
|
|
content: String,
|
|
usage: Usage,
|
|
reasoning: Option<String>,
|
|
},
|
|
Done {
|
|
message_id: MessageId,
|
|
content: String,
|
|
usage: Usage,
|
|
reasoning: Option<String>,
|
|
},
|
|
// ── tools ──
|
|
ToolCallStarted {
|
|
id: ToolCallId,
|
|
message_id: MessageId,
|
|
name: String,
|
|
args: Value,
|
|
},
|
|
ToolCallFinished {
|
|
id: ToolCallId,
|
|
outcome: CallOutcome,
|
|
},
|
|
ApprovalRequired {
|
|
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 {
|
|
frame: FrameId,
|
|
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,
|
|
},
|
|
// ── infrastructure ──
|
|
ModelFallback {
|
|
from: ModelId,
|
|
to: ModelId,
|
|
reason: String,
|
|
},
|
|
LlmFailed {
|
|
tried: Vec<ModelId>,
|
|
last_error: String,
|
|
},
|
|
Compacted {
|
|
frame: FrameId,
|
|
covered_up_to: MessageId,
|
|
},
|
|
Truncated {
|
|
output_tokens: Option<u32>,
|
|
},
|
|
Error(String),
|
|
Cancelled,
|
|
/// Escape hatch for host-specific events (Skald: PendingWrite with diff,
|
|
/// SecurityGroupSelected, …). Other subscribers ignore it.
|
|
Host(Value),
|
|
}
|
|
|
|
/// An event tagged with its emitting scope.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Event<E> {
|
|
pub conversation: ConversationId,
|
|
pub frame: FrameId,
|
|
pub parent_frame: Option<FrameId>,
|
|
pub inner: E,
|
|
}
|
|
|
|
/// Thin wrapper over the manager's broadcast sender, handed to the kernel,
|
|
/// gates, tools and hooks for out-of-band emission. Cheap to clone.
|
|
#[derive(Clone)]
|
|
pub struct EventSink {
|
|
pub(crate) conversation: ConversationId,
|
|
pub(crate) tx: broadcast::Sender<Event<LoopEvent>>,
|
|
}
|
|
|
|
impl EventSink {
|
|
/// 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 }
|
|
}
|
|
|
|
/// Emit an event for a frame. Best-effort: with no subscribers the send
|
|
/// fails silently — events are never load-bearing for the loop's outcome.
|
|
pub fn emit(&self, frame: FrameId, parent_frame: Option<FrameId>, inner: LoopEvent) {
|
|
let _ = self.tx.send(Event {
|
|
conversation: self.conversation.clone(),
|
|
frame,
|
|
parent_frame,
|
|
inner,
|
|
});
|
|
}
|
|
|
|
pub fn conversation(&self) -> &ConversationId { &self.conversation }
|
|
|
|
/// Recover the sink from a tool's extensions (the kernel inserts one into
|
|
/// every `ToolCtx` it builds, so shipped tools can emit out-of-band).
|
|
pub fn from_extensions(ext: &crate::tool::Extensions) -> Option<EventSink> {
|
|
ext.get::<EventSink>().map(|s| (*s).clone())
|
|
}
|
|
}
|
|
|
|
/// A running tool call, as passed to `LoopHooks::pre_tool_call` (mutable) and
|
|
/// `post_tool_call`. Distinct from the model's [`crate::model::ToolCall`]:
|
|
/// this one carries the store id allocated before execution.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PendingToolCall {
|
|
pub id: ToolCallId,
|
|
pub message_id: MessageId,
|
|
pub provider_id: Option<String>,
|
|
pub name: String,
|
|
pub arguments: Value,
|
|
}
|
|
|
|
impl PendingToolCall {
|
|
pub fn wire_call(&self) -> ToolCall {
|
|
ToolCall {
|
|
id: self.provider_id.clone().unwrap_or_default(),
|
|
name: self.name.clone(),
|
|
arguments: self.arguments.clone(),
|
|
}
|
|
}
|
|
}
|