ChatSessionHandler now runs the root turn on the agent-loop kernel instead of run_agent_turn; sub-agents follow on the same kernel via DelegateTool. The old loop stays for resume/recovery until phase 3. agent-loop: - DelegateTool + AgentCatalog/AgentProfile (full toolset override, per-child selector/assembler, frame-scoped get), StaticCatalog, FilteredToolSet; sync flow with sticky child_token; batch via the generic fan-out - manager: start_loop skips the registry (children are not double-driving; they ride the parent's token tree); LoopParams gains selector/token overrides - store: get_frame, get_call, set_call_extras; HistoryStore result text aligned to raw-stored semantics (projection formats) - events: ApprovalRequired.request_id, AgentSpawned/Finished parent info; AskUserTool with_name + suggested_answers alias + Question.frame skald-core (loop_adapters + handler): - SkaldAssembler (byte-parity port of MessageBuilder's projection: scratchpad/summary/window, DTL Kimi/Anthropic injection, media, user coalescing, reasoning echo) + AgentSystemContext (prompt layers, substitutions, MCP list, shared folders, user profile) - SkaldAgentCatalog (build_sub_agent_config port), SkaldHumanChannel, scratchpad/todos tools, execute_task sync/async alias, LegacyInterfaceTool, PendingLiveInput - ApprovalGate: PendingWrite diffs via LoopEvent::Host (memory/disk routed like the fs-tools); SkaldWritePreviewHook for executed-write diffs; EventTranslator LoopEvent→ServerEvent (display meta, preview, FileChanged, AgentStart/Done, root-only Done/Truncated/Cancelled) - handle_message: builds TurnParams and drives the kernel; resume of pending tools runs first (results belong to the previous turn); ChatEvent publication stays handler-side; /stop cancels the live loop - ToolRegistry.get_tool/all_tools; def builders made pub(crate) Full workspace suite green (179 skald-core, 34 agent-loop, adapters incl.); two pre-existing doc-test failures fixed along the way.
83 lines
2.5 KiB
Rust
83 lines
2.5 KiB
Rust
//! `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 parent_frame: Option<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 },
|
|
/// The gate was waiting for a human and the channel closed: the turn ends
|
|
/// and the call STAYS `AwaitingHuman` (the gate marked it before
|
|
/// suspending) — the same semantics as `ToolFailure::Suspend`.
|
|
Suspend,
|
|
}
|
|
|
|
#[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
|
|
}
|
|
}
|
|
}
|