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
+46 -209
View File
@@ -1,27 +1,23 @@
//! The system context (layered) and the `ContextAssembler` — from system +
//! history to wire messages.
//!
//! **Well-formedness contract** (every assembler MUST honor it):
//!
//! 1. Order: static system → compaction summary (if any) → messages after
//! `covered_up_to` → dynamic tail → tail reminder.
//! 2. Every assistant `tool_call` has a tool-result: `Done`→result,
//! `Failed`→error, `Cancelled`/`Rejected`→note, **`Running`/`AwaitingHuman`
//! surviving a crash → synthetic "interrupted" result**.
//! 3. No `failed` messages (orphans) — already filtered by the store.
//! 4. DTL injection (§4.10 of the blueprint): when `model.tool_rendering` is
//! not `Inline` and an `ActivationSource` is present, each activation is
//! projected at its anchor (marker vs system+tools block, append-only).
//! The projection itself lives in [`crate::projection`], which owns the
//! well-formedness contract and every provider-shaped decision. This module is
//! the seam: hosts implement [`SystemContextSource`] to say *what* goes in the
//! system prompt, and [`LinearAssembler`] configures the projection.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use serde_json::Value;
use crate::activation::{ActivationSource, ToolRendering};
use crate::activation::ActivationSource;
use crate::ids::{ConversationId, FrameId};
use crate::model::ModelInfo;
use crate::store::{HistoryStore, Role, StoredMessage};
use crate::projection::{
MediaSource, Projection, ProjectionHooks, ResultLimit, ToolResultDigest,
};
use crate::store::HistoryStore;
// ── SystemContext ────────────────────────────────────────────────────────────
@@ -115,36 +111,55 @@ pub trait ContextAssembler: Send + Sync {
// ── LinearAssembler ──────────────────────────────────────────────────────────
/// The shipped assembler: system + summary + history, with an optional
/// message window and tool-result truncation. Honors the DTL injection
/// contract when given an `ActivationSource`.
/// The shipped assembler: a [`Projection`] plus the host hooks it may use.
///
/// Out of the box it produces a correct OpenAI-shaped conversation. A host with
/// stricter models overrides the projection (`with_projection`) and plugs in its
/// media authorization and result-digest policy.
pub struct LinearAssembler {
/// Keep at most this many history messages (cut at a User/Agent boundary,
/// never mid assistant+tool group).
pub max_messages: Option<usize>,
/// Truncate each tool result to this many chars.
pub max_tool_result_chars: Option<usize>,
/// DTL activations (only consulted when `tool_rendering != Inline`).
pub activation: Option<Arc<dyn ActivationSource>>,
pub projection: Projection,
pub hooks: ProjectionHooks,
}
impl LinearAssembler {
pub fn new() -> Self {
Self { max_messages: None, max_tool_result_chars: None, activation: None }
Self { projection: Projection::default(), hooks: ProjectionHooks::default() }
}
/// Replace the whole protocol configuration.
pub fn with_projection(mut self, projection: Projection) -> Self {
self.projection = projection;
self
}
/// Keep at most this many history messages (cut boundary-safely).
pub fn with_max_messages(mut self, n: usize) -> Self {
self.max_messages = Some(n);
self.projection.max_messages = Some(n);
self
}
/// Shrink every tool result longer than `n` chars.
pub fn with_tool_result_limit(mut self, n: usize) -> Self {
self.max_tool_result_chars = Some(n);
self.projection.max_tool_result =
Some(ResultLimit { max_chars: n, previous_turns_only: false });
self
}
/// DTL activations (consulted only when `tool_rendering != Inline`).
pub fn with_activation(mut self, src: Arc<dyn ActivationSource>) -> Self {
self.activation = Some(src);
self.hooks.activation = Some(src);
self
}
/// Which media a message may inline.
pub fn with_media(mut self, src: Arc<dyn MediaSource>) -> Self {
self.hooks.media = Some(src);
self
}
/// How an over-long tool result is condensed.
pub fn with_digest(mut self, digest: Arc<dyn ToolResultDigest>) -> Self {
self.hooks.digest = Some(digest);
self
}
}
@@ -153,9 +168,8 @@ impl Default for LinearAssembler {
fn default() -> Self { Self::new() }
}
/// The summary block is prefixed so the model understands what it is (Skald
/// keeps its own SUMMARY_PREFIX in its assembler).
pub const SUMMARY_PREFIX: &str = "[CONTEXT SUMMARY — earlier messages were compacted into this summary]";
/// Re-exported for hosts that only need the default summary header.
pub use crate::projection::SUMMARY_PREFIX;
#[async_trait]
impl ContextAssembler for LinearAssembler {
@@ -164,183 +178,6 @@ impl ContextAssembler for LinearAssembler {
store: &Arc<dyn HistoryStore>,
input: &AssembleInput,
) -> crate::Result<Vec<Value>> {
let mut out: Vec<Value> = Vec::new();
// 1. static system
if !input.system.base.is_empty() {
out.push(json!({ "role": "system", "content": input.system.base }));
}
for s in &input.system.extra_static {
out.push(json!({ "role": "system", "content": s }));
}
// 2. summary + surviving history
let summary = store.latest_summary(input.frame).await?;
if let Some(s) = &summary {
out.push(json!({
"role": "system",
"content": format!("{SUMMARY_PREFIX}\n\n{}", s.text),
}));
}
let mut history = match &summary {
Some(s) => store.load_since(input.frame, s.covered_up_to).await?,
None => store.load(input.frame).await?,
};
if let Some(max) = self.max_messages {
history = window(history, max);
}
// 3. DTL activations (consulted only in non-Inline modes)
let activations = match (&self.activation, input.model.tool_rendering) {
(Some(src), ToolRendering::Inline) => {
let _ = src;
Vec::new()
}
(Some(src), _) => src.activations(input.frame).await.unwrap_or_default(),
(None, _) => Vec::new(),
};
for msg in &history {
project_message(&mut out, msg, self.max_tool_result_chars);
inject_activations(&mut out, msg, &activations, &input.model.tool_rendering);
}
// 4. dynamic tail + reminder
for s in &input.system.dynamic_tail {
out.push(json!({ "role": "system", "content": s }));
}
if let Some(r) = &input.system.tail_reminder {
out.push(json!({ "role": "system", "content": r }));
}
Ok(out)
}
}
/// Cut the history to at most `max` messages, at a User/Agent boundary so an
/// assistant+tool group is never split.
fn window(history: Vec<StoredMessage>, max: usize) -> Vec<StoredMessage> {
if history.len() <= max {
return history;
}
let start = history.len() - max;
let cut = history[start..]
.iter()
.position(|m| matches!(m.role, Role::User | Role::Agent))
.map(|p| start + p)
.unwrap_or(start);
history[cut..].to_vec()
}
/// Project one stored message (and its tool results) to wire messages.
fn project_message(out: &mut Vec<Value>, msg: &StoredMessage, result_limit: Option<usize>) {
match msg.role {
Role::System => {
out.push(json!({ "role": "system", "content": msg.content }));
}
Role::User | Role::Agent => {
out.push(json!({ "role": "user", "content": msg.content }));
}
Role::Assistant => {
let mut wire = json!({ "role": "assistant", "content": msg.content });
if let Some(r) = &msg.reasoning {
// Echoed under both names: DeepSeek expects reasoning_content,
// others reasoning (the clients normalize on read).
wire["reasoning_content"] = json!(r);
}
if !msg.calls.is_empty() {
let calls: Vec<Value> = msg
.calls
.iter()
.map(|c| {
json!({
"id": c.provider_id,
"type": "function",
"function": {
"name": c.name,
"arguments": serde_json::to_string(&c.arguments)
.unwrap_or_else(|_| "{}".into()),
},
})
})
.collect();
wire["tool_calls"] = Value::Array(calls);
}
out.push(wire);
for call in &msg.calls {
let mut content = match call.state {
crate::store::CallState::Running | crate::store::CallState::AwaitingHuman => {
"[interrupted: this tool call did not complete — the session restarted \
before a result was recorded]"
.to_string()
}
crate::store::CallState::Failed => {
format!("Error: {}", call.result.as_deref().unwrap_or("unknown error"))
}
_ => call.result.clone().unwrap_or_default(),
};
if let Some(limit) = result_limit
&& content.chars().count() > limit
{
content = format!(
"{}… [truncated]",
content.chars().take(limit).collect::<String>()
);
}
out.push(json!({
"role": "tool",
"tool_call_id": call.provider_id,
"content": content,
}));
}
}
}
}
/// DTL injection at an activation anchor (blueprint §4.10):
/// - `DeferredToolReference`: `_tool_references` marker on the FIRST tool
/// result of the anchored assistant message (the client converts it).
/// - `SystemToolBlock`: a `{role:"system", tools:[defs]}` message appended
/// right after the anchored message's tool-result group.
fn inject_activations(
out: &mut Vec<Value>,
msg: &StoredMessage,
activations: &[crate::activation::Activation],
mode: &ToolRendering,
) {
let acts: Vec<&crate::activation::Activation> =
activations.iter().filter(|a| a.anchor == msg.id).collect();
if acts.is_empty() {
return;
}
match mode {
ToolRendering::Inline => {}
ToolRendering::DeferredToolReference => {
let names: Vec<Value> = acts
.iter()
.flat_map(|a| &a.defs)
.filter_map(|d| d["function"]["name"].as_str())
.map(|n| json!(n))
.collect();
if names.is_empty() {
return;
}
// Attach to the first tool result just emitted for this message.
if let Some(tool_msg) = out
.iter_mut()
.rev()
.take(msg.calls.len())
.find(|m| m["role"].as_str() == Some("tool"))
{
tool_msg["_tool_references"] = Value::Array(names);
}
}
ToolRendering::SystemToolBlock => {
let defs: Vec<Value> = acts.iter().flat_map(|a| a.defs.clone()).collect();
if !defs.is_empty() {
out.push(json!({ "role": "system", "tools": defs }));
}
}
crate::projection::project(store, input, &self.projection, &self.hooks).await
}
}