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
+130
View File
@@ -0,0 +1,130 @@
//! Test utilities: a scripted `FakeModel` + builders for kernel and recovery
//! scenarios. (Blueprint: will move behind a `test-util` feature if the crate
//! is ever published.)
use std::collections::VecDeque;
use std::sync::Mutex;
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::model::{
Model, ModelError, ModelRequest, ModelResponse, NamedModel, StreamDelta, ToolCall, Usage,
};
/// One scripted step: the response (or error) plus optional deltas to emit
/// before returning.
pub struct Step {
pub result: Result<ModelResponse, ModelError>,
pub deltas: Vec<StreamDelta>,
/// Never return (cancellation tests).
pub pending: bool,
}
impl Step {
pub fn message(content: impl Into<String>) -> Self {
Self { result: Ok(ModelResponse::message(content)), deltas: Vec::new(), pending: false }
}
pub fn message_with_usage(content: impl Into<String>, input: u32, output: u32) -> Self {
let mut resp = ModelResponse::message(content);
*resp.usage_mut() = Usage {
input_tokens: Some(input),
output_tokens: Some(output),
..Usage::default()
};
Self { result: Ok(resp), deltas: Vec::new(), pending: false }
}
pub fn tool_calls(content: impl Into<String>, calls: Vec<ToolCall>) -> Self {
Self { result: Ok(ModelResponse::tool_calls(content, calls)), deltas: Vec::new(), pending: false }
}
pub fn error(status: Option<u16>, message: impl Into<String>) -> Self {
Self { result: Err(ModelError::new(status, message)), deltas: Vec::new(), pending: false }
}
/// Never completes — the only way out is cancelling the turn.
pub fn pending() -> Self {
Self { result: Ok(ModelResponse::message("")), deltas: Vec::new(), pending: true }
}
/// Stream these deltas (in order) before returning the response.
pub fn with_deltas(mut self, deltas: Vec<StreamDelta>) -> Self {
self.deltas = deltas;
self
}
}
/// A scripted model: pops one [`Step`] per `complete` call, records every
/// request for assertions. Clone the `Arc` around it to inspect afterwards.
pub struct FakeModel {
script: Mutex<VecDeque<Step>>,
requests: Mutex<Vec<ModelRequest>>,
default_model: String,
}
impl FakeModel {
pub fn new(default_model: impl Into<String>, script: Vec<Step>) -> Self {
Self {
script: Mutex::new(script.into()),
requests: Mutex::new(Vec::new()),
default_model: default_model.into(),
}
}
/// All requests seen so far (one per attempt, fallback included).
pub fn requests(&self) -> Vec<ModelRequest> {
self.requests.lock().unwrap().clone()
}
/// Steps not yet consumed (assert a script was fully driven).
pub fn remaining(&self) -> usize {
self.script.lock().unwrap().len()
}
}
impl NamedModel for FakeModel {
fn default_model(&self) -> &str { &self.default_model }
}
#[async_trait]
impl Model for FakeModel {
async fn complete(
&self,
req: &ModelRequest,
deltas: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
self.requests.lock().unwrap().push(req.clone());
let step = self
.script
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| panic!("FakeModel: script exhausted (request for model {})", req.model));
if let Some(tx) = deltas {
for d in step.deltas {
let _ = tx.try_send(d);
}
}
if step.pending {
std::future::pending::<()>().await;
}
step.result
}
}
/// A `ModelHandle` over a shared `FakeModel` (tests keep the Arc to inspect
/// `requests()` afterwards).
pub fn handle(fake: &std::sync::Arc<FakeModel>, id: &str) -> crate::model::ModelHandle {
crate::model::ModelHandle {
id: id.to_string(),
model: fake.clone(),
info: crate::model::ModelInfo::default(),
}
}
/// Build a wire `ToolCall` compactly in tests.
pub fn call(id: &str, name: &str, args: serde_json::Value) -> ToolCall {
ToolCall { id: id.to_string(), name: name.to_string(), arguments: args }
}