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
+24 -56
View File
@@ -413,7 +413,7 @@ async fn run_job(
});
// Drain events concurrently. rx closes when the last tx clone is dropped,
// which happens only after resume_turn() completes the full sub-agent chain.
// which happens only after the turn completes the full sub-agent chain.
while let Some(_) = rx.recv().await {}
let handle_result = jh.await
@@ -465,7 +465,7 @@ async fn run_job(
if let Some(parent_id) = job.parent_session_id {
if let Some(hub) = hub {
inject_async_result(
pool,
&task_mgr.pool,
hub,
parent_id,
job.id,
@@ -512,69 +512,37 @@ async fn run_job(
}
}
/// Injects an async task result into the parent session using the same pattern as
/// the notification system: writes a synthetic assistant message + completed
/// `task_completed` tool call directly to the DB, then calls `hub.resume()` so
/// the parent LLM wakes up and events are properly bridged to the WebSocket.
/// Delivers an async task's result to the parent session through the loop's
/// [`AsyncResultSink`] seam (blueprint §7.2): the library writes the synthetic
/// assistant message + completed `task_completed` call, and Skald's
/// [`DurableSink`] resumes the parent so the model reads it right away.
///
/// Failures are logged, never propagated: the job itself succeeded, and losing
/// the delivery must not mark it failed.
async fn inject_async_result(
pool: &SqlitePool,
pool: &Arc<SqlitePool>,
hub: &Arc<ChatHub>,
parent_session_id: i64,
task_id: i64,
task_title: &str,
result: &str,
) {
// Resolve source_id from the parent session row.
let source_id = match crate::db::chat_sessions::find_by_id(pool, parent_session_id).await {
Ok(Some(s)) => s.source,
Ok(None) => { error!("inject_async_result: session {parent_session_id} not found"); return; }
Err(e) => { error!("inject_async_result: DB error: {e}"); return; }
};
use agent_loop::delegate::{AsyncResultSink, CompletedTask};
use crate::loop_adapters::async_task::DurableSink;
use crate::loop_adapters::history::SqliteHistory;
// Get the active stack for the parent session.
let stack = match crate::db::chat_sessions_stack::active_for_session(pool, parent_session_id).await {
Ok(Some(s)) => s,
Ok(None) => { error!("inject_async_result: no active stack for session {parent_session_id}"); return; }
Err(e) => { error!("inject_async_result: stack lookup failed: {e}"); return; }
};
info!(parent_session_id, task_id, task_title, "delivering async task result");
// Write a synthetic assistant message (reasoning trace).
let reasoning = format!(
"The system is notifying me that async task #{task_id} ('{}') has completed. \
Let me process the result via task_completed.",
task_title,
);
let assistant_id = match crate::db::chat_history::append(
pool, stack.id, &crate::db::chat_history::Role::Assistant,
"", true, Some(&reasoning),
).await {
Ok(id) => id,
Err(e) => { error!("inject_async_result: append assistant failed: {e}"); return; }
};
// Write the completed task_completed tool call with the result payload.
let result_json = serde_json::to_string(&serde_json::json!({
"task_id": task_id,
"title": task_title,
"result": result,
})).unwrap_or_else(|_| "{}".to_string());
let tool_call_id = match crate::db::chat_llm_tools::append(
pool, assistant_id, "task_completed",
&serde_json::json!({"task_id": task_id}).to_string(),
).await {
Ok(id) => id,
Err(e) => { error!("inject_async_result: append tool call failed: {e}"); return; }
};
if let Err(e) = crate::db::chat_llm_tools::complete(pool, tool_call_id, &result_json, "string").await {
error!("inject_async_result: complete tool call failed: {e}"); return;
}
info!(parent_session_id, task_id, task_title, "inject_async_result: resuming parent session");
if let Err(e) = hub.resume(&source_id).await {
error!("inject_async_result: hub.resume failed: {e}");
let sink = DurableSink::new(Arc::clone(pool), Arc::clone(hub));
let delivered = sink
.deliver(SqliteHistory::conversation(parent_session_id), CompletedTask {
id: agent_loop::ids::TaskId(task_id),
title: task_title.to_string(),
result: result.to_string(),
})
.await;
if let Err(e) = delivered {
error!(parent_session_id, task_id, "async result delivery failed: {e}");
}
}