Files
Skald-Circle/crates/agent-loop/src/manager.rs
T
dguiducci 24ee5b89d7
Nightly Build / build (push) Successful in 6m49s
agent-loop: projection, recovery, compaction into the crate (phase 3)
The session handler is now a thin shell: three entry points in
kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call)
and the ChatSessionHandler. Everything that shaped a Value — projection,
recovery, compaction mechanics, the LLM loop, message building — lives
in agent-loop or behind a loop_adapters trait.

agent-loop:
- projection/ (mod + media): stored history -> wire messages, the one
  place provider divergence lives; well-formedness contract, DTL
  injections (append-only), media parts. LinearAssembler is now a
  Projection + ProjectionHooks config, not its own implementation
- recovery.rs: reap interrupted batches -> resolve the deepest frame's
  non-terminal calls (Running by policy + RestartHint, AwaitingHuman
  re-asked) -> un-wedge finished children -> cascade up, every frame on
  its own agent (B3)
- compaction.rs: split point (never assistant+tool group), transcript,
  SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row
- manager: resolve_pending (gate skipped, real ToolContext, then
  continue incl. sub-agent); start_loop used by recovery; LiveInput
- delegate: AsyncExecutor + StoreSink for mode:async (durable cron row,
  result delivered back into the parent conversation)
- kernel/context/store: support the above (TurnScope via Extensions,
  frame lookups, aligned result-text semantics)

skald-core:
- loop_adapters: UserLoopRuntime (D12 - one LoopManager per user),
  TurnScope (per-turn state in the Extensions type-map; no scope is
  denied), projection_cfg/media_source/tool_digest (Skald's projection
  knobs without owning projection code), async_task (CronExecutor +
  DurableSink)
- session/handler: stripped to mod.rs + kernel_turn.rs + config.rs +
  interface_tools.rs + media.rs; deleted agent_dispatch, approval,
  dispatch, emitter, gate, llm_call, llm_loop, message_builder,
  messages, outcome, resume
- compactor.rs: policy only (threshold, model pick, CompactionEvent);
  mechanics are the crate's

CLAUDE.md updated (recovery, compaction, sub-agents, approval gate,
projection sections now describe the crate-owned flow).
2026-07-26 17:09:01 +01:00

561 lines
20 KiB
Rust

//! `LoopManager` — the singleton (per tenant/user) that owns the event bus and
//! the registry of live loops, and spawns disposable `LlmLoop`s (blueprint D1).
//!
//! Policy: **one live loop per conversation** — `start_turn` rejects a second
//! one (anti double-driving). Serialization/queueing of user messages stays
//! with the host.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::context::{ContextAssembler, LinearAssembler, SystemContextSource};
use crate::events::{Event, EventSink, LoopEvent};
use crate::gate::{AllowAll, Gate};
use crate::hooks::LoopHooks;
use crate::human::HumanChannel;
use crate::ids::{ConversationId, FrameId};
use crate::kernel::{KernelDeps, TurnOutcome};
use crate::model::{ModelHint, ModelSelector, RetryPolicy};
use crate::store::{FrameSpec, HistoryStore, NewMessage, Role};
use crate::tool::{Extensions, ToolSet};
// ── LiveInput ────────────────────────────────────────────────────────────────
/// Pull-based live user input (blueprint D10): drained at round boundaries.
#[async_trait]
pub trait LiveInput: Send + Sync {
async fn drain(&self) -> Vec<NewMessage>;
}
// ── TurnMeta ─────────────────────────────────────────────────────────────────
/// Per-turn metadata.
#[derive(Debug, Clone, Default)]
pub struct TurnMeta {
/// Synthetic turn (TIC/notify) — no user echo semantics.
pub synthetic: bool,
/// Interactive surface (web chat, telegram, …).
pub interactive: bool,
/// Label for UI/logging ("session 42", "cron job X").
pub context_label: Option<String>,
/// The user message that opened the turn (for `TurnInfo`).
pub user_message: Option<String>,
}
// ── TurnParams / LoopParams ──────────────────────────────────────────────────
/// Parameters of a user turn (root frame).
pub struct TurnParams {
/// Root frame (opened by the host or via `LoopManager::open_root`).
pub frame: FrameId,
pub agent: String,
pub system: Arc<dyn SystemContextSource>,
/// Already filtered (visibility/approval).
pub tools: Arc<dyn ToolSet>,
pub model_hint: ModelHint,
/// Per-turn selector override — e.g. this agent's required strength, which
/// is host policy (D14) and varies turn to turn while the manager lives as
/// long as the tenant. `None` = the manager's.
pub selector: Option<Arc<dyn ModelSelector>>,
/// None for sub-agents / cron / resume.
pub live_input: Option<Arc<dyn LiveInput>>,
/// Flows into `ToolCtx.extensions`.
pub extensions: Extensions,
pub meta: TurnMeta,
/// Per-turn assembler override (default: the manager's).
pub assembler: Option<Arc<dyn ContextAssembler>>,
}
/// Parameters of a raw loop (DelegateTool, recovery, background runners).
pub struct LoopParams {
pub conversation: ConversationId,
pub frame: FrameId,
pub parent_frame: Option<FrameId>,
pub agent: String,
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,
pub assembler: Option<Arc<dyn ContextAssembler>>,
}
// ── TurnHandle ───────────────────────────────────────────────────────────────
/// Handle of a spawned turn.
pub struct TurnHandle {
pub conversation: ConversationId,
pub frame: FrameId,
/// Clone; cancels THIS turn (sticky down the whole call tree).
pub cancel: CancellationToken,
join: JoinHandle<crate::Result<TurnOutcome>>,
}
impl TurnHandle {
pub async fn join(self) -> crate::Result<TurnOutcome> {
self.join.await.map_err(|e| anyhow::anyhow!("loop task panicked: {e}"))?
}
}
// ── StartError ───────────────────────────────────────────────────────────────
#[derive(Debug)]
pub enum StartError {
/// A loop is already live on this conversation (anti double-driving).
AlreadyRunning,
Store(anyhow::Error),
}
impl std::fmt::Display for StartError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyRunning => write!(f, "a loop is already running on this conversation"),
Self::Store(e) => write!(f, "store error: {e}"),
}
}
}
impl std::error::Error for StartError {}
// ── RunningInfo ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct RunningInfo {
pub conversation: ConversationId,
pub frame: FrameId,
pub agent: String,
}
struct RunningEntry {
frame: FrameId,
agent: String,
cancel: CancellationToken,
}
/// Holds a conversation in the live registry for work that is not one spawned
/// loop (see [`LoopManager::claim`]). Releases on drop, including on an early
/// return or a panic — a leaked claim would lock the conversation for the
/// process's lifetime.
pub(crate) struct ConversationClaim {
conversation: ConversationId,
registry: Arc<Mutex<HashMap<ConversationId, RunningEntry>>>,
token: CancellationToken,
}
impl ConversationClaim {
/// The claim's cancellation token — `/stop` cancels it through the registry.
pub(crate) fn token(&self) -> CancellationToken {
self.token.clone()
}
}
impl Drop for ConversationClaim {
fn drop(&mut self) {
self.registry.lock().unwrap().remove(&self.conversation);
}
}
// ── LoopManager ──────────────────────────────────────────────────────────────
pub struct LoopManager {
deps: Arc<KernelDeps>,
bus: broadcast::Sender<Event<LoopEvent>>,
registry: Arc<Mutex<HashMap<ConversationId, RunningEntry>>>,
human: Option<Arc<dyn HumanChannel>>,
}
impl LoopManager {
pub fn builder() -> LoopManagerBuilder { LoopManagerBuilder::default() }
/// Subscribe to the global event bus (every event tagged with
/// conversation/frame/parent_frame).
pub fn events(&self) -> broadcast::Receiver<Event<LoopEvent>> { self.bus.subscribe() }
/// The host-provided human channel, if any.
pub fn human(&self) -> Option<Arc<dyn HumanChannel>> { self.human.clone() }
/// Convenience: open a root frame on the store.
pub async fn open_root(&self, conv: &ConversationId, spec: FrameSpec) -> crate::Result<FrameId> {
self.deps.store.open_frame(conv, None, spec).await
}
pub fn store(&self) -> Arc<dyn HistoryStore> { self.deps.store.clone() }
// ── user turns ──
/// High-level entry point:
/// 1. rejects when a loop is already live on the conversation;
/// 2. marks a trailing orphan User/Agent message failed (alternation rule
/// for strict APIs);
/// 3. appends the user message + echo event;
/// 4. spawns the loop; returns the handle immediately.
pub async fn start_turn(
&self,
conv: ConversationId,
msg: NewMessage,
mut params: TurnParams,
) -> Result<TurnHandle, StartError> {
{
let registry = self.registry.lock().unwrap();
if registry.contains_key(&conv) {
return Err(StartError::AlreadyRunning);
}
}
// Orphan rule: a trailing User/Agent message with no assistant reply
// breaks strict alternation — mark it failed before appending.
if let Some(last) = self.deps.store.last(params.frame).await.map_err(StartError::Store)?
&& matches!(last.role, Role::User | Role::Agent)
{
self.deps.store.mark_failed(last.id).await.map_err(StartError::Store)?;
}
let events = self.sink(conv.clone());
let id = self.deps.store.append(params.frame, msg.clone()).await.map_err(StartError::Store)?;
events.emit(params.frame, None, LoopEvent::UserMessage {
message_id: id,
content: msg.content.clone(),
synthetic: msg.synthetic,
metadata: msg.metadata.clone(),
});
params.meta.user_message = Some(msg.content);
self.spawn(LoopParams {
conversation: conv,
frame: params.frame,
parent_frame: None,
agent: params.agent,
system: params.system,
tools: params.tools,
model_hint: params.model_hint,
selector: params.selector,
token: None,
live_input: params.live_input,
extensions: params.extensions,
meta: params.meta,
assembler: params.assembler,
})
}
// ── 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> {
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> {
let conv = params.conversation.clone();
let frame = params.frame;
let agent = params.agent.clone();
let token = CancellationToken::new();
let events = self.sink(conv.clone());
{
let mut registry = self.registry.lock().unwrap();
registry.insert(conv.clone(), RunningEntry {
frame,
agent,
cancel: token.clone(),
});
}
let deps = self.deps.clone();
let registry = self.registry.clone();
let turn_token = token.clone();
let join_conv = conv.clone();
let join = tokio::spawn(async move {
let outcome = crate::kernel::run(deps, params, turn_token, events).await;
registry.lock().unwrap().remove(&join_conv);
outcome
});
Ok(TurnHandle { conversation: conv, frame, cancel: token, join })
}
// ── control ──
/// `/stop`: cancel the live loop on a conversation, if any.
pub fn cancel(&self, conv: &ConversationId) {
if let Some(entry) = self.registry.lock().unwrap().get(conv) {
entry.cancel.cancel();
}
}
pub fn is_running(&self, conv: &ConversationId) -> bool {
self.registry.lock().unwrap().contains_key(conv)
}
/// Take the conversation for something that is not a single spawned loop —
/// a recovery pass, an out-of-band tool resolution. `None` when another
/// loop already holds it (anti double-driving, same rule as `start_turn`).
///
/// The claim registers in the live registry, so `/stop` cancels it and
/// `list_running` shows it; dropping the guard releases it.
pub(crate) fn claim(
&self,
conv: &ConversationId,
frame: FrameId,
agent: &str,
) -> Option<ConversationClaim> {
let token = CancellationToken::new();
let mut registry = self.registry.lock().unwrap();
if registry.contains_key(conv) {
return None;
}
registry.insert(conv.clone(), RunningEntry {
frame,
agent: agent.to_string(),
cancel: token.clone(),
});
Some(ConversationClaim {
conversation: conv.clone(),
registry: self.registry.clone(),
token,
})
}
// ── recovery (blueprint §8) ──
/// A [`Recovery`](crate::recovery::Recovery) bound to this manager.
pub fn recovery(
self: &Arc<Self>,
catalog: Arc<dyn crate::delegate::AgentCatalog>,
policy: crate::recovery::RecoveryPolicy,
) -> crate::recovery::Recovery {
crate::recovery::Recovery::new(self.clone(), catalog, policy)
}
/// Resume a conversation left mid-turn: recovery with the default policy.
pub async fn resume(
self: &Arc<Self>,
conv: &ConversationId,
catalog: Arc<dyn crate::delegate::AgentCatalog>,
root: &TurnParams,
) -> crate::Result<crate::recovery::RecoveryReport> {
self.recovery(catalog, crate::recovery::RecoveryPolicy::default())
.run(conv, root)
.await
}
/// Resolve a call a human answered out of band — the approval card clicked
/// after a restart, when no loop is left holding the oneshot.
///
/// On approval the tool runs with the **gate skipped**: the human just
/// decided, and asking the rules again would either re-prompt or overturn
/// them. The conversation is then recovered, so the model sees the result
/// and continues.
pub async fn resolve_pending(
self: &Arc<Self>,
call: crate::ids::ToolCallId,
decision: crate::recovery::HumanDecision,
catalog: Arc<dyn crate::delegate::AgentCatalog>,
root: &TurnParams,
) -> crate::Result<crate::recovery::RecoveryReport> {
crate::recovery::resolve_pending(self, call, decision, catalog, root).await
}
// ── compaction (blueprint §9) ──
/// A [`Compaction`](crate::compaction::Compaction) on one frame, sharing
/// this manager's store, hooks and event bus. Configure it with the
/// builder methods, then `run()`.
pub fn new_compaction(
&self,
conv: ConversationId,
frame: FrameId,
) -> crate::compaction::Compaction {
crate::compaction::Compaction {
store: self.deps.store.clone(),
selector: self.deps.models.clone(),
hooks: self.deps.hooks.clone(),
events: self.sink(conv.clone()),
conversation: conv,
frame,
mode: crate::compaction::CompactionMode::default(),
hint: ModelHint::default(),
prompt: Arc::new(crate::compaction::DefaultPrompt),
temperature: None,
log: None,
}
}
pub(crate) fn deps(&self) -> &Arc<KernelDeps> {
&self.deps
}
pub(crate) fn sink_for(&self, conv: ConversationId) -> EventSink {
self.sink(conv)
}
/// Global view (UI "running agents").
pub fn list_running(&self) -> Vec<RunningInfo> {
self.registry
.lock()
.unwrap()
.iter()
.map(|(conversation, e)| RunningInfo {
conversation: conversation.clone(),
frame: e.frame,
agent: e.agent.clone(),
})
.collect()
}
/// Cancel all live loops. Joins are detached — callers wanting a drain
/// should hold the handles.
pub async fn shutdown(&self) {
let tokens: Vec<CancellationToken> = self
.registry
.lock()
.unwrap()
.values()
.map(|e| e.cancel.clone())
.collect();
for t in tokens {
t.cancel();
}
}
fn sink(&self, conv: ConversationId) -> EventSink {
EventSink::new(conv, self.bus.clone())
}
}
// ── Builder ──────────────────────────────────────────────────────────────────
pub struct LoopManagerBuilder {
models: Option<Arc<dyn ModelSelector>>,
store: Option<Arc<dyn HistoryStore>>,
gate: Option<Arc<dyn Gate>>,
hooks: Vec<Arc<dyn LoopHooks>>,
human: Option<Arc<dyn HumanChannel>>,
assembler: Option<Arc<dyn ContextAssembler>>,
max_rounds: usize,
max_parallel_calls: usize,
retry: RetryPolicy,
bus_capacity: usize,
}
impl Default for LoopManagerBuilder {
fn default() -> Self {
Self {
models: None,
store: None,
gate: None,
hooks: Vec::new(),
human: None,
assembler: None,
max_rounds: 20,
max_parallel_calls: 4,
retry: RetryPolicy::default(),
bus_capacity: 512,
}
}
}
impl LoopManagerBuilder {
pub fn models(mut self, models: Arc<dyn ModelSelector>) -> Self {
self.models = Some(models);
self
}
pub fn store(mut self, store: Arc<dyn HistoryStore>) -> Self {
self.store = Some(store);
self
}
pub fn gate(mut self, gate: impl Gate + 'static) -> Self {
self.gate = Some(Arc::new(gate));
self
}
pub fn gate_arc(mut self, gate: Arc<dyn Gate>) -> Self {
self.gate = Some(gate);
self
}
pub fn hook(mut self, hook: Arc<dyn LoopHooks>) -> Self {
self.hooks.push(hook);
self
}
pub fn human(mut self, human: Arc<dyn HumanChannel>) -> Self {
self.human = Some(human);
self
}
pub fn assembler(mut self, assembler: Arc<dyn ContextAssembler>) -> Self {
self.assembler = Some(assembler);
self
}
pub fn max_rounds(mut self, n: usize) -> Self {
self.max_rounds = n;
self
}
pub fn max_parallel_calls(mut self, n: usize) -> Self {
self.max_parallel_calls = n;
self
}
pub fn retry(mut self, retry: RetryPolicy) -> Self {
self.retry = retry;
self
}
pub fn bus_capacity(mut self, n: usize) -> Self {
self.bus_capacity = n;
self
}
pub fn build(self) -> crate::Result<LoopManager> {
let deps = Arc::new(KernelDeps {
models: self.models.ok_or_else(|| anyhow::anyhow!("LoopManager: models required"))?,
store: self.store.ok_or_else(|| anyhow::anyhow!("LoopManager: store required"))?,
gate: self.gate.unwrap_or_else(|| Arc::new(AllowAll)),
hooks: self.hooks,
assembler: self.assembler.unwrap_or_else(|| Arc::new(LinearAssembler::new())),
max_rounds: self.max_rounds,
max_parallel_calls: self.max_parallel_calls,
retry: self.retry,
});
let (bus, _) = broadcast::channel(self.bus_capacity);
Ok(LoopManager {
deps,
bus,
registry: Arc::new(Mutex::new(HashMap::new())),
human: self.human,
})
}
}