Files
Skald-Circle/crates/agent-loop/src/events.rs
T
dguiducci 0297fe71bd 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.
2026-07-26 12:15:53 +01:00

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(),
}
}
}