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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user