agent-loop: projection, recovery, compaction into the crate (phase 3)
Nightly Build / build (push) Successful in 6m49s
Nightly Build / build (push) Successful in 6m49s
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).
This commit is contained in:
@@ -58,6 +58,10 @@ pub struct TurnParams {
|
||||
/// 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`.
|
||||
@@ -139,6 +143,29 @@ struct RunningEntry {
|
||||
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 {
|
||||
@@ -212,7 +239,7 @@ impl LoopManager {
|
||||
system: params.system,
|
||||
tools: params.tools,
|
||||
model_hint: params.model_hint,
|
||||
selector: None,
|
||||
selector: params.selector,
|
||||
token: None,
|
||||
live_input: params.live_input,
|
||||
extensions: params.extensions,
|
||||
@@ -287,6 +314,108 @@ impl LoopManager {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user