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
+30 -41
View File
@@ -218,6 +218,15 @@ pub async fn resolve_tool(
let msg = ApprovalDecision::rejection_message(&body.note);
if !live {
chat_llm_tools::reject(db, tc_id, &msg).await?;
// The refusal is part of the conversation: let the model read it and
// carry on, instead of leaving the turn dead where it stopped.
let hub = ctx.chat_hub.clone();
tokio::spawn(async move {
if let Err(e) = hub.resume_session(session_id).await {
tracing::warn!(session_id, tool_call_id = tc_id, error = %e,
"post-restart continue after rejection failed");
}
});
}
return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
@@ -241,48 +250,28 @@ pub async fn resolve_tool(
}
// ── Post-restart path: no in-memory oneshot to unblock. ───────────────────
// Sub-agent tools (`execute_task` etc.) cannot run through the flat
// `execute_tool` path — they need the recursive dispatcher. Mark the call
// pre-approved and drive the owning session's resume, which re-dispatches it
// via `execute_tool_call` (gate skipped) and continues the loop. Events stream
// to the reconnected client through the global bus; return immediately.
if tc_name == "execute_task" || tc_name == tn::EXECUTE_SUBTASK || tc_name == "run_subtask" {
let handler = ctx.chat_hub.handler_for_session(session_id).await?;
handler.mark_pre_approved(tc_id);
let hub = ctx.chat_hub.clone();
tokio::spawn(async move {
if let Err(e) = hub.resume_session(session_id).await {
tracing::warn!(session_id, tool_call_id = tc_id, error = %e, "post-restart resume of sub-agent tool failed");
}
});
return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "running".to_string(),
result: None,
result_type: "string".to_string(),
}));
}
// Simple tools: execute directly on the owning session and return the result.
let handler = ctx.chat_hub.handler_for_session(session_id).await?;
match handler.execute_tool(&tc_name, args).await {
Ok(result) => {
let wire = result.to_wire();
let kind = result.kind();
chat_llm_tools::complete(db, tc_id, &wire, kind).await?;
Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "done".to_string(),
result: Some(wire),
result_type: kind.to_string(),
}))
// One path for every tool: the loop's `resolve_pending` runs the call with
// the gate skipped (the human just decided) but with this session's real
// context — owner pool, per-user container — then continues the turn. A
// sub-agent dispatch works here too: it opens its child frame like any
// other call. Events stream to the reconnected client through the global
// bus, so the endpoint returns as soon as the work is scheduled.
let hub = ctx.chat_hub.clone();
tokio::spawn(async move {
if let Err(e) = hub
.resolve_pending_call(session_id, tc_id, ApprovalDecision::Approved)
.await
{
tracing::warn!(session_id, tool_call_id = tc_id, error = %e,
"post-restart approval failed");
}
Err(e) => {
let msg = e.to_string();
chat_llm_tools::fail(db, tc_id, &msg).await?;
Err(anyhow::anyhow!(msg).into())
}
}
});
Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "running".to_string(),
result: None,
result_type: "string".to_string(),
}))
}
// ── GET /api/tools/:tool_call_id — full execution detail for the detail page ──
+1 -1
View File
@@ -381,7 +381,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
// Attachments uploaded beforehand, plus an optional custom-command
// marker. Persisted on the user turn as MessageMetadata; the
// [SYSTEM INFO] block the LLM sees is generated on the fly by the
// MessageBuilder (never stored as text), and the UI renders the
// projection (never stored as text), and the UI renders the
// command's `display` instead of the expanded `content`.
let attachments = client_msg.attachments.clone();
let metadata = (!attachments.is_empty() || command_ref.is_some())