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:
@@ -0,0 +1,281 @@
|
||||
//! `HistoryStore` — the durability heart of the loop.
|
||||
//!
|
||||
//! Contract (enforced by doc, relied upon by recovery):
|
||||
//!
|
||||
//! 1. **Every state transition is an immediate write** — the kernel never
|
||||
//! accumulates state in RAM. A crash loses only RAM, never truth.
|
||||
//! 2. `MessageId`/`ToolCallId` are **monotonically increasing per frame**.
|
||||
//! 3. `resolve_call` is the ONLY path to terminal states; `set_call_state`
|
||||
//! is only for `Running → AwaitingHuman`.
|
||||
//! 4. `load` returns calls nested inside their messages — the input of the
|
||||
//! assembler's well-formed projection.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ids::{ConversationId, FrameId, MessageId, SummaryId, ToolCallId};
|
||||
use crate::model::Usage;
|
||||
|
||||
// ── Role ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Who produced a message. `Agent` is an injected agent-to-agent message
|
||||
/// (sub-agent prompt, async result delivery); it projects to `user` on the wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Agent,
|
||||
}
|
||||
|
||||
// ── CallState ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Lifecycle of a tool call — semantics identical to Skald's
|
||||
/// `chat_llm_tools.status`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CallState {
|
||||
/// Was executing at crash time → interrupted (NOT terminal).
|
||||
Running,
|
||||
/// 'pending': approval or clarification in flight (NOT terminal).
|
||||
AwaitingHuman,
|
||||
/// Terminal.
|
||||
Done,
|
||||
/// Terminal.
|
||||
Failed,
|
||||
/// Deliberate /stop — NEVER re-execute.
|
||||
Cancelled,
|
||||
/// Policy/human denial — NEVER re-execute.
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl CallState {
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::Done | Self::Failed | Self::Cancelled | Self::Rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CallOutcome ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// The result of an execution, before recording.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CallOutcome {
|
||||
Completed(crate::tool::ToolOutput),
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
Rejected { reason: String },
|
||||
}
|
||||
|
||||
impl CallOutcome {
|
||||
pub fn state(&self) -> CallState {
|
||||
match self {
|
||||
Self::Completed(_) => CallState::Done,
|
||||
Self::Failed(_) => CallState::Failed,
|
||||
Self::Cancelled => CallState::Cancelled,
|
||||
Self::Rejected { .. } => CallState::Rejected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text persisted as the call's result (what the model will read back).
|
||||
pub fn result_text(&self) -> String {
|
||||
match self {
|
||||
Self::Completed(out) => out.to_wire(),
|
||||
Self::Failed(e) => format!("Error: {e}"),
|
||||
Self::Cancelled => "Tool call cancelled by user.".to_string(),
|
||||
Self::Rejected { reason } => format!("Tool call rejected: {reason}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn result_kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Completed(out) => out.kind(),
|
||||
Self::Failed(_) => "error",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Rejected { .. } => "rejected",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frames ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// What a frame is opened with (a sub-agent dispatch; the root carries the
|
||||
/// conversation's entry agent).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameSpec {
|
||||
/// Agent id in the HOST's catalog (opaque to the crate).
|
||||
pub agent: String,
|
||||
/// The sub-agent's prompt (root: None).
|
||||
pub prompt: Option<String>,
|
||||
pub depth: u32,
|
||||
/// The parent frame's tool call that spawned this frame.
|
||||
pub parent_call: Option<ToolCallId>,
|
||||
/// Host free-form (run_context_json, …).
|
||||
pub meta: Value,
|
||||
}
|
||||
|
||||
impl FrameSpec {
|
||||
pub fn root(agent: impl Into<String>) -> Self {
|
||||
Self {
|
||||
agent: agent.into(),
|
||||
prompt: None,
|
||||
depth: 0,
|
||||
parent_call: None,
|
||||
meta: Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored frame.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameRecord {
|
||||
pub id: FrameId,
|
||||
pub conversation: ConversationId,
|
||||
pub parent: Option<FrameId>,
|
||||
pub spec: FrameSpec,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewMessage {
|
||||
pub role: Role,
|
||||
pub content: String,
|
||||
/// TIC/notify/injection: not echoed to the UI as a user message.
|
||||
pub synthetic: bool,
|
||||
pub reasoning: Option<String>,
|
||||
/// Attachments, command display, … (host free-form).
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
impl NewMessage {
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: Role::User, content: content.into(), synthetic: false, reasoning: None, metadata: None }
|
||||
}
|
||||
|
||||
pub fn assistant(content: impl Into<String>, reasoning: Option<String>) -> Self {
|
||||
Self { role: Role::Assistant, content: content.into(), synthetic: false, reasoning, metadata: None }
|
||||
}
|
||||
|
||||
pub fn agent(content: impl Into<String>) -> Self {
|
||||
Self { role: Role::Agent, content: content.into(), synthetic: false, reasoning: None, metadata: None }
|
||||
}
|
||||
|
||||
pub fn synthetic(mut self, synthetic: bool) -> Self {
|
||||
self.synthetic = synthetic;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_metadata(mut self, metadata: Value) -> Self {
|
||||
self.metadata = Some(metadata);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored message with its tool calls nested.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredMessage {
|
||||
pub id: MessageId,
|
||||
pub role: Role,
|
||||
pub content: String,
|
||||
pub reasoning: Option<String>,
|
||||
pub synthetic: bool,
|
||||
/// Orphan of a cancelled turn — excluded from `load`.
|
||||
pub failed: bool,
|
||||
pub metadata: Option<Value>,
|
||||
pub usage: Usage,
|
||||
pub calls: Vec<StoredCall>,
|
||||
}
|
||||
|
||||
// ── Tool calls ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// What a call is recorded with, BEFORE execution (phase 1 of the fan-out).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewCall {
|
||||
/// The model's wire call id ("call_abc", "toolu_…"), needed to rebuild
|
||||
/// `tool_calls`/`tool` wire messages. Synthesized by the store when absent.
|
||||
pub provider_id: Option<String>,
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
impl NewCall {
|
||||
pub fn new(name: impl Into<String>, arguments: Value) -> Self {
|
||||
Self { provider_id: None, name: name.into(), arguments }
|
||||
}
|
||||
|
||||
pub fn with_provider_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.provider_id = Some(id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored tool call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredCall {
|
||||
pub id: ToolCallId,
|
||||
pub message_id: MessageId,
|
||||
/// The model's wire call id (see [`NewCall::provider_id`]).
|
||||
pub provider_id: String,
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
pub state: CallState,
|
||||
pub result: Option<String>,
|
||||
pub result_kind: String,
|
||||
/// Host free-form (Skald: preview_old/new, media refs).
|
||||
pub extras: Value,
|
||||
}
|
||||
|
||||
// ── Summaries ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewSummary {
|
||||
pub text: String,
|
||||
/// Last message covered by the summary — the projection resumes after it.
|
||||
pub covered_up_to: MessageId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredSummary {
|
||||
pub id: SummaryId,
|
||||
pub text: String,
|
||||
pub covered_up_to: MessageId,
|
||||
}
|
||||
|
||||
// ── HistoryStore ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
pub trait HistoryStore: Send + Sync {
|
||||
// ── frames ──
|
||||
async fn open_frame(
|
||||
&self,
|
||||
conv: &ConversationId,
|
||||
parent: Option<FrameId>,
|
||||
spec: FrameSpec,
|
||||
) -> crate::Result<FrameId>;
|
||||
async fn close_frame(&self, frame: FrameId) -> crate::Result<()>;
|
||||
/// All active frames of a conversation (recovery: batch detection, cascade).
|
||||
async fn active_frames(&self, conv: &ConversationId) -> crate::Result<Vec<FrameRecord>>;
|
||||
async fn deepest_active(&self, conv: &ConversationId) -> crate::Result<Option<FrameRecord>>;
|
||||
|
||||
// ── messages ──
|
||||
async fn append(&self, frame: FrameId, msg: NewMessage) -> crate::Result<MessageId>;
|
||||
async fn set_usage(&self, msg: MessageId, usage: &Usage) -> crate::Result<()>;
|
||||
/// Frame history with calls nested per message. EXCLUDES failed messages
|
||||
/// (orphans of cancelled turns).
|
||||
async fn load(&self, frame: FrameId) -> crate::Result<Vec<StoredMessage>>;
|
||||
async fn load_since(&self, frame: FrameId, after: MessageId) -> crate::Result<Vec<StoredMessage>>;
|
||||
async fn last(&self, frame: FrameId) -> crate::Result<Option<StoredMessage>>;
|
||||
async fn mark_failed(&self, msg: MessageId) -> crate::Result<()>;
|
||||
|
||||
// ── tool calls ──
|
||||
async fn append_call(&self, msg: MessageId, call: NewCall) -> crate::Result<ToolCallId>;
|
||||
/// The ONLY path to terminal states.
|
||||
async fn resolve_call(&self, id: ToolCallId, outcome: &CallOutcome) -> crate::Result<()>;
|
||||
/// Only `Running → AwaitingHuman`.
|
||||
async fn set_call_state(&self, id: ToolCallId, state: CallState) -> crate::Result<()>;
|
||||
async fn calls_in_state(&self, frame: FrameId, states: &[CallState]) -> crate::Result<Vec<StoredCall>>;
|
||||
|
||||
// ── summaries ──
|
||||
async fn save_summary(&self, frame: FrameId, s: NewSummary) -> crate::Result<SummaryId>;
|
||||
async fn latest_summary(&self, frame: FrameId) -> crate::Result<Option<StoredSummary>>;
|
||||
}
|
||||
Reference in New Issue
Block a user