agent-loop: projection, recovery, compaction into the crate (phase 3)
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:
2026-07-26 17:09:01 +01:00
parent 3fca7867fa
commit 24ee5b89d7
74 changed files with 7661 additions and 5982 deletions
+33 -6
View File
@@ -18,7 +18,9 @@ use crate::cron::TaskManager;
use crate::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, config, sources};
use crate::events::{GlobalEvent, ServerEvent};
use crate::notification::Notification;
use crate::session::handler::{ChatSessionHandler, InterfaceTool, PendingMsg, PendingUserInput};
use crate::session::handler::{
ApprovalDecision, ChatSessionHandler, InterfaceTool, PendingMsg, PendingUserInput,
};
use crate::session::manager::ChatSessionManager;
use crate::tools::tool_names as tn;
@@ -370,7 +372,7 @@ impl ChatHub {
}
/// Resume any interrupted turn for a source's active session.
/// Calls `resume_turn` which re-executes pending tool calls (approval or
/// Calls `recover_turn`, which re-executes pending tool calls (approval or
/// clarification) and re-runs the LLM loop if needed.
/// Safe to call unconditionally — returns immediately if there is nothing to resume.
/// Events are published to the global broadcast bus so existing subscribers
@@ -382,7 +384,7 @@ impl ChatHub {
};
// Guard against double-driving. A client sends `resume` on connect whenever
// history shows a pending/interrupted tool — including when the turn is still
// live and merely awaiting an approval. Without this check `resume_turn` would
// live and merely awaiting an approval. Without this check the recovery would
// block on the `processing` lock and, once the approval unblocks the original
// turn and it finishes, run a spurious *second* turn on the just-completed
// conversation. If a turn is already in flight it owns the session and emits
@@ -408,11 +410,36 @@ impl ChatHub {
let tx = Self::bridge_to_global(self.global_tx.clone(), source, session_id);
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
let interface_tools = self.execute_task_tools(session_id, &handler).await;
handler.resume_turn(None, None, interface_tools, tx).await
handler.recover_turn(interface_tools, tx).await
}
/// Apply a human decision to a tool call nothing is waiting on anymore (an
/// approval answered after a restart), then continue the conversation.
/// Events reach the reconnected client through the global bus, as for
/// [`Self::resume_session`].
pub async fn resolve_pending_call(
&self,
session_id: i64,
call: i64,
decision: ApprovalDecision,
) -> anyhow::Result<()> {
let decision = match decision {
ApprovalDecision::Approved => agent_loop::recovery::HumanDecision::Approved,
ApprovalDecision::Rejected { note } => agent_loop::recovery::HumanDecision::Rejected {
reason: ApprovalDecision::rejection_message(&note),
},
};
let source = chat_sessions::find_by_id(&self.db, session_id).await?
.map(|s| s.source)
.unwrap_or_else(|| "web".to_string());
let tx = Self::bridge_to_global(self.global_tx.clone(), source, session_id);
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
let interface_tools = self.execute_task_tools(session_id, &handler).await;
handler.resolve_pending_call(call, decision, interface_tools, tx).await
}
/// Builds the `execute_task` interface tool for a session, mirroring the injection
/// done for live turns (`run_agent_turn`). Empty when no TaskManager is configured
/// done for live turns. Empty when no TaskManager is configured
/// so `execute_task mode=async` can be rebuilt by `build_execution` during resume.
async fn execute_task_tools(
&self,
@@ -728,7 +755,7 @@ impl ChatHub {
let count = notes.len();
// Build a synthetic assistant message with a reasoning trace and a
// pre-completed read_notification tool call carrying the notifications as results.
// The agent is then woken via resume() — resume_turn sees the tool calls on
// The agent is then woken via resume() — recovery sees the tool calls on
// the last assistant message and runs the LLM loop so the agent can respond.
let result_json = serde_json::to_string(&notes).unwrap_or_else(|_| "[]".to_string());