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
+365
View File
@@ -0,0 +1,365 @@
//! The `Tool` trait, the type-erased [`ToolCtx`] (blueprint D3 — a type-map,
//! axum/tower style, not generics), and the cancellable execution machinery
//! (ported verbatim from Skald's core-api: it was already pure).
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::ids::{ConversationId, FrameId, ToolCallId};
// ── Extensions ───────────────────────────────────────────────────────────────
/// A type-map of host values threaded into every tool call (axum/tower
/// style). Hosts insert in ONE place (turn construction) and read with typed
/// helpers — never scattered string keys.
#[derive(Clone, Default)]
pub struct Extensions {
map: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
}
impl Extensions {
pub fn new() -> Self { Self::default() }
pub fn insert<T: Send + Sync + 'static>(&mut self, value: Arc<T>) -> &mut Self {
self.map.insert(TypeId::of::<T>(), value);
self
}
pub fn get<T: Send + Sync + 'static>(&self) -> Option<Arc<T>> {
self.map.get(&TypeId::of::<T>())?.clone().downcast::<T>().ok()
}
pub fn contains<T: Send + Sync + 'static>(&self) -> bool {
self.map.contains_key(&TypeId::of::<T>())
}
}
impl std::fmt::Debug for Extensions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Extensions({} entries)", self.map.len())
}
}
// ── ToolCtx ──────────────────────────────────────────────────────────────────
/// Per-invocation execution context threaded into a tool call.
#[derive(Clone)]
pub struct ToolCtx {
pub conversation: ConversationId,
pub frame: FrameId,
/// Agent of the current frame (self-call check for delegation).
pub agent: String,
/// The call being executed (parent_call of any child frame).
pub call_id: ToolCallId,
pub cancel: CancellationToken,
pub extensions: Extensions,
}
// ── ToolOutput / ToolFailure ─────────────────────────────────────────────────
/// A reference to one media file a tool produced. The assembler decides
/// whether to inline it — the kernel only transports it.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MediaRef {
/// Absolute host path, already containment-checked by the producing tool.
pub host_path: String,
/// Sniffed MIME (informational — pipelines re-sniff from bytes).
pub mime: String,
}
/// The successful output of a tool.
#[derive(Debug, Clone)]
pub enum ToolOutput {
Text(String),
Json(Value),
/// A text note plus media refs; the wire message carries only `text`.
Media { text: String, refs: Vec<MediaRef> },
}
impl ToolOutput {
/// Canonical string form persisted as the call result and replayed to the
/// model (both OpenAI and Anthropic encode tool results as text/JSON).
pub fn to_wire(&self) -> String {
match self {
Self::Text(s) => s.clone(),
Self::Json(v) => serde_json::to_string(v).unwrap_or_else(|_| "null".into()),
Self::Media { text, .. } => text.clone(),
}
}
pub fn kind(&self) -> &'static str {
match self {
Self::Text(_) | Self::Media { .. } => "string",
Self::Json(_) => "json",
}
}
pub fn media(&self) -> &[MediaRef] {
match self {
Self::Media { refs, .. } => refs,
_ => &[],
}
}
}
impl From<String> for ToolOutput {
fn from(s: String) -> Self { Self::Text(s) }
}
impl From<&str> for ToolOutput {
fn from(s: &str) -> Self { Self::Text(s.to_string()) }
}
/// How a tool call can fail.
#[derive(Debug, Clone)]
pub enum ToolFailure {
Failed(String),
/// The tool suspended waiting for a human and the channel closed: the turn
/// ends, the call STAYS `AwaitingHuman` for the resume. (The tool marks
/// the call `AwaitingHuman` via the store BEFORE returning this.)
Suspend,
}
impl std::fmt::Display for ToolFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Failed(e) => write!(f, "{e}"),
Self::Suspend => write!(f, "tool suspended awaiting human input"),
}
}
}
impl std::error::Error for ToolFailure {}
// ── RestartHint / Visibility ─────────────────────────────────────────────────
/// What recovery does with a call that was `Running` at crash (blueprint D7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RestartHint {
/// Re-gate and re-execute (default — today's behavior; idempotent tools).
#[default]
ReExecute,
/// Resolve as Failed "interrupted" (tools with non-idempotent external
/// side effects, e.g. shell commands).
MarkInterrupted,
}
/// Declared visibility — the HOST filters at `ToolSet` construction, the
/// kernel never filters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Visibility {
#[default]
Always,
InteractiveOnly,
RootOnly,
SubAgentsOnly,
}
// ── Tool ─────────────────────────────────────────────────────────────────────
/// A single LLM-callable tool.
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
/// OpenAI-shaped tool definition (`{"type":"function","function":{…}}`).
fn definition(&self) -> Value;
/// The simple execution path. The kernel wraps it in a [`SimpleExecution`]
/// by default (drop of the future = stop) — override [`start`](Self::start)
/// for remote/child teardown instead.
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<crate::tool::ToolOutput, ToolFailure>;
/// May this call run in parallel with other concurrency-safe calls of the
/// same round? (Generalized sub-agent batch, blueprint §7.) Default false
/// → the sequential path.
fn concurrency_safe(&self, _args: &Value) -> bool { false }
/// Recovery behavior when the call was `Running` at crash (D7).
fn restart_hint(&self) -> RestartHint { RestartHint::ReExecute }
/// Declared visibility (host-side filtering only).
fn visibility(&self) -> Visibility { Visibility::Always }
/// Start one execution, returning a live handle. The default wraps
/// [`call`](Self::call) in a [`SimpleExecution`]. Tools needing
/// remote/child teardown (kill a process group, POST an /interrupt)
/// override this with a bespoke [`ToolExecution::stop`].
fn start<'a>(&'a self, args: Value, ctx: &'a ToolCtx) -> Box<dyn ToolExecution + 'a> {
Box::new(SimpleExecution::new(Box::pin(self.call(args, ctx))))
}
}
// ── ToolSet ──────────────────────────────────────────────────────────────────
/// The per-turn tool registry, ALREADY filtered by the host (visibility,
/// approval, interactive). `defs` is re-read at EVERY round and every
/// fallback attempt: grants activated at round N are visible at round N+1,
/// and a cross-mode DTL fallback re-shapes for free.
pub trait ToolSet: Send + Sync {
fn defs(&self, model: &crate::model::ModelInfo) -> Vec<Value>;
fn find(&self, name: &str) -> Option<Arc<dyn Tool>>;
}
/// A trivial `ToolSet` from a list of tools (testing, simple hosts).
pub struct ToolRegistry {
tools: Vec<Arc<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self { Self { tools: Vec::new() } }
pub fn with(mut self, tool: impl Tool + 'static) -> Self {
self.tools.push(Arc::new(tool));
self
}
pub fn with_arc(mut self, tool: Arc<dyn Tool>) -> Self {
self.tools.push(tool);
self
}
pub fn into_toolset(self) -> Arc<dyn ToolSet> { Arc::new(self) }
}
impl Default for ToolRegistry {
fn default() -> Self { Self::new() }
}
impl ToolSet for ToolRegistry {
fn defs(&self, _model: &crate::model::ModelInfo) -> Vec<Value> {
self.tools.iter().map(|t| t.definition()).collect()
}
fn find(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.iter().find(|t| t.name() == name).cloned()
}
}
// ── ToolExecution ────────────────────────────────────────────────────────────
/// Lifecycle state of a single tool execution (in-memory, richer than the
/// persisted `CallState`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolExecutionState {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
/// Terminal outcome of [`ToolExecution::wait`].
#[derive(Debug, Clone)]
pub enum ExecutionOutcome {
Completed(ToolOutput),
Failed(String),
Cancelled,
/// The tool suspended awaiting a human (`ToolFailure::Suspend`): the turn
/// ends and the call STAYS `AwaitingHuman` — never resolve it here.
Suspended,
}
impl ExecutionOutcome {
pub fn into_call_outcome(self) -> crate::store::CallOutcome {
match self {
Self::Completed(out) => crate::store::CallOutcome::Completed(out),
Self::Failed(e) => crate::store::CallOutcome::Failed(e),
Self::Cancelled => crate::store::CallOutcome::Cancelled,
// Handled by the kernel before this conversion is reached.
Self::Suspended => crate::store::CallOutcome::Cancelled,
}
}
}
/// A single live execution of a [`Tool`]. Pure: it never touches a store or a
/// transport — the kernel mirrors transitions to persistence and events.
pub trait ToolExecution: Send + Sync {
fn state(&self) -> ToolExecutionState;
/// Drive the work to its terminal outcome. Called exactly once.
fn wait<'a>(&'a self) -> Pin<Box<dyn Future<Output = ExecutionOutcome> + Send + 'a>>;
/// Tool-specific cancellation. The default relies on the driver dropping
/// the `wait` future.
fn stop<'a>(&'a self) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async {})
}
}
/// The boxed work unit inside a [`SimpleExecution`].
pub type ToolWork<'a> =
Pin<Box<dyn Future<Output = Result<ToolOutput, ToolFailure>> + Send + 'a>>;
/// Default [`ToolExecution`] for any tool that is a single async unit of work:
/// `wait` races the work against a stop-token, so `stop()` (or dropping
/// `wait`) aborts the in-flight I/O.
pub struct SimpleExecution<'a> {
state: Mutex<ToolExecutionState>,
stop: CancellationToken,
work: tokio::sync::Mutex<Option<ToolWork<'a>>>,
}
impl<'a> SimpleExecution<'a> {
pub fn new(work: ToolWork<'a>) -> Self {
Self {
state: Mutex::new(ToolExecutionState::Running),
stop: CancellationToken::new(),
work: tokio::sync::Mutex::new(Some(work)),
}
}
}
impl ToolExecution for SimpleExecution<'_> {
fn state(&self) -> ToolExecutionState { *self.state.lock().unwrap() }
fn wait<'b>(&'b self) -> Pin<Box<dyn Future<Output = ExecutionOutcome> + Send + 'b>> {
Box::pin(async move {
let work = self.work.lock().await.take();
let Some(work) = work else { return ExecutionOutcome::Cancelled };
let outcome = tokio::select! {
biased;
_ = self.stop.cancelled() => ExecutionOutcome::Cancelled,
r = work => match r {
Ok(out) => ExecutionOutcome::Completed(out),
Err(ToolFailure::Failed(e)) => ExecutionOutcome::Failed(e),
Err(ToolFailure::Suspend) => ExecutionOutcome::Suspended,
},
};
*self.state.lock().unwrap() = match outcome {
ExecutionOutcome::Completed(_) => ToolExecutionState::Completed,
ExecutionOutcome::Failed(_) => ToolExecutionState::Failed,
ExecutionOutcome::Cancelled | ExecutionOutcome::Suspended => ToolExecutionState::Cancelled,
};
outcome
})
}
fn stop<'b>(&'b self) -> Pin<Box<dyn Future<Output = ()> + Send + 'b>> {
Box::pin(async move { self.stop.cancel() })
}
}
/// Run a [`ToolExecution`] to completion honouring a cancellation token: on
/// cancel, `exec.stop()` is called once (tool-specific teardown), then `wait`
/// resolves.
pub async fn drive_execution(exec: &dyn ToolExecution, cancel: &CancellationToken) -> ExecutionOutcome {
let work = exec.wait();
tokio::pin!(work);
let mut stopped = false;
loop {
tokio::select! {
biased;
outcome = &mut work => return outcome,
_ = cancel.cancelled(), if !stopped => {
exec.stop().await;
stopped = true;
}
}
}
}