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,77 @@
|
||||
//! `Gate` — the pre-execution decision point (policy and/or human). It MAY
|
||||
//! block waiting for a human: the implementation decides (oneshot, UI, …).
|
||||
//! Before suspending, an implementation marks the call `AwaitingHuman` via the
|
||||
//! store (durability) and emits `LoopEvent::ApprovalRequired`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::events::EventSink;
|
||||
use crate::ids::{FrameId, ToolCallId};
|
||||
use crate::tool::Extensions;
|
||||
|
||||
/// A tool call awaiting a gate decision.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingCall {
|
||||
pub id: ToolCallId,
|
||||
pub name: String,
|
||||
pub args: Value,
|
||||
pub frame: FrameId,
|
||||
pub agent: String,
|
||||
/// Host free-form (source, permission group, …).
|
||||
pub extensions: Extensions,
|
||||
}
|
||||
|
||||
/// The gate's verdict.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GateDecision {
|
||||
Allow,
|
||||
Reject { reason: String },
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Gate: Send + Sync {
|
||||
/// Decide on a call. MAY block awaiting a human — in that case the
|
||||
/// implementation marks the call `AwaitingHuman` first (via the store the
|
||||
/// host gave it) and emits `ApprovalRequired` on `events`.
|
||||
async fn check(&self, call: &PendingCall, events: &EventSink) -> GateDecision;
|
||||
}
|
||||
|
||||
/// Everything runs. The default for simple hosts and tests.
|
||||
pub struct AllowAll;
|
||||
|
||||
#[async_trait]
|
||||
impl Gate for AllowAll {
|
||||
async fn check(&self, _call: &PendingCall, _events: &EventSink) -> GateDecision {
|
||||
GateDecision::Allow
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject calls whose name matches a pattern: exact, or `prefix*`.
|
||||
pub struct DenyList {
|
||||
patterns: Vec<String>,
|
||||
}
|
||||
|
||||
impl DenyList {
|
||||
pub fn new(patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
Self { patterns: patterns.into_iter().map(Into::into).collect() }
|
||||
}
|
||||
|
||||
fn matches(&self, name: &str) -> bool {
|
||||
self.patterns.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(prefix) => name.starts_with(prefix),
|
||||
None => name == p,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Gate for DenyList {
|
||||
async fn check(&self, call: &PendingCall, _events: &EventSink) -> GateDecision {
|
||||
if self.matches(&call.name) {
|
||||
GateDecision::Reject { reason: format!("tool '{}' denied by policy", call.name) }
|
||||
} else {
|
||||
GateDecision::Allow
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user