agent-loop: new crate — LLM loop kernel + Model clients (phase 0)

Extract the LLM agent loop into a standalone workspace crate with zero
deps on skald-core/core-api (blueprint project-loop.md, D13-D15):

- kernel: round loop, model fallback with rebuild, parallel tool fan-out
  (ordered id alloc / bounded concurrent exec / ordered record), streaming
  deltas drained before outcomes, sticky cancellation
- models: OpenAiModel/AnthropicModel/OllamaModel/LmStudioModel ported from
  llm-client onto the Model trait; ModelError carries the HTTP status;
  is_retriable default = the 401/403/404/422 rule
- DTL as crate protocol (ToolRendering Inline/DeferredToolReference/
  SystemToolBlock; Anthropic conversions + Kimi system+tools passthrough),
  host catalog behind ActivationSource/ToolActivator
- HistoryStore durability contract + InMemoryStore; LinearAssembler with
  well-formed projection (incl. DTL injection, summary, crash survivors)
- LoopManager singleton (broadcast bus + live registry), one live loop
  per conversation, orphan-marking on start_turn
- 32 tests green (kernel §13 suite, assembler DTL, SSE/Anthropic ports),
  clippy clean
This commit is contained in:
2026-07-25 23:40:41 +01:00
parent 5081ec2afe
commit b8cc6d263b
26 changed files with 5684 additions and 0 deletions
+343
View File
@@ -0,0 +1,343 @@
//! 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).
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::activation::{ActivationSource, ToolRendering};
use crate::ids::{ConversationId, FrameId};
use crate::model::ModelInfo;
use crate::store::{HistoryStore, Role, StoredMessage};
// ── SystemContext ────────────────────────────────────────────────────────────
/// The system prompt as LAYERS (the static prefix is cacheable, the dynamic
/// tail is per-turn fresh).
#[derive(Debug, Clone, Default)]
pub struct SystemContext {
/// The agent's prompt (static, cacheable).
pub base: String,
/// Per-interface extras (e.g. output format rules).
pub extra_static: Vec<String>,
/// Per-turn: date/time, memory, run context.
pub dynamic_tail: Vec<String>,
pub tail_reminder: Option<String>,
}
impl SystemContext {
pub fn base(s: impl Into<String>) -> Self {
Self { base: s.into(), ..Default::default() }
}
pub fn with_dynamic(mut self, s: impl Into<String>) -> Self {
self.dynamic_tail.push(s.into());
self
}
pub fn with_static(mut self, s: impl Into<String>) -> Self {
self.extra_static.push(s.into());
self
}
pub fn with_reminder(mut self, s: impl Into<String>) -> Self {
self.tail_reminder = Some(s.into());
self
}
}
// ── SystemContextSource ──────────────────────────────────────────────────────
/// What the kernel knows about the current turn when asking for the system
/// context.
#[derive(Debug, Clone)]
pub struct TurnInfo {
pub conversation: ConversationId,
pub frame: FrameId,
pub agent: String,
/// The user message that opened the turn (None on resume).
pub user_message: Option<String>,
}
#[async_trait]
pub trait SystemContextSource: Send + Sync {
async fn system_context(&self, turn: &TurnInfo) -> crate::Result<SystemContext>;
}
/// A fixed system context (simple hosts, tests).
pub struct StaticSystemContext {
ctx: SystemContext,
}
impl StaticSystemContext {
pub fn new(base: impl Into<String>) -> Self {
Self { ctx: SystemContext::base(base) }
}
}
#[async_trait]
impl SystemContextSource for StaticSystemContext {
async fn system_context(&self, _turn: &TurnInfo) -> crate::Result<SystemContext> {
Ok(self.ctx.clone())
}
}
// ── ContextAssembler ─────────────────────────────────────────────────────────
pub struct AssembleInput {
pub frame: FrameId,
pub system: SystemContext,
pub model: ModelInfo,
pub round: usize,
}
#[async_trait]
pub trait ContextAssembler: Send + Sync {
async fn build(
&self,
store: &Arc<dyn HistoryStore>,
input: &AssembleInput,
) -> crate::Result<Vec<Value>>;
}
// ── 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`.
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>>,
}
impl LinearAssembler {
pub fn new() -> Self {
Self { max_messages: None, max_tool_result_chars: None, activation: None }
}
pub fn with_max_messages(mut self, n: usize) -> Self {
self.max_messages = Some(n);
self
}
pub fn with_tool_result_limit(mut self, n: usize) -> Self {
self.max_tool_result_chars = Some(n);
self
}
pub fn with_activation(mut self, src: Arc<dyn ActivationSource>) -> Self {
self.activation = Some(src);
self
}
}
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]";
#[async_trait]
impl ContextAssembler for LinearAssembler {
async fn build(
&self,
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()
}
_ => 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 }));
}
}
}
}