Skip to main content

meerkat_core/
agent.rs

1//! Agent - the core agent orchestrator
2//!
3//! The Agent struct ties together all components and runs the agent loop.
4
5mod builder;
6pub mod comms_impl;
7pub mod compact;
8mod extraction;
9mod hook_impl;
10#[cfg(test)]
11mod hooks_behavior_tests;
12mod runner;
13pub mod skills;
14mod state;
15#[cfg(test)]
16#[doc(hidden)]
17pub(crate) mod test_turn_state_handle;
18use crate::budget::Budget;
19use crate::comms::{
20    CommsCommand, CommsTrustMutation, CommsTrustMutationResult, EventStream, PeerDirectoryEntry,
21    PeerId, SendAndStreamError, SendError, SendReceipt, StreamError, StreamScope,
22    TrustedPeerDescriptor,
23};
24use crate::compact::SessionCompactionCadence;
25use crate::completion_feed::CompletionSeq;
26use crate::config::{AgentConfig, HookRunOverrides};
27use crate::error::AgentError;
28use crate::event::ExternalToolDelta;
29use crate::hooks::HookEngine;
30use crate::lifecycle::RunId;
31use crate::lifecycle::run_primitive::ProviderParamsOverride;
32use crate::ops::OperationId;
33use crate::ops_lifecycle::{OperationKind, OperationStatus, OperationTerminalOutcome};
34use crate::retry::RetryPolicy;
35use crate::schema::{CompiledSchema, SchemaError};
36use crate::session::Session;
37use crate::state::LoopState;
38#[cfg(target_arch = "wasm32")]
39use crate::tokio;
40use crate::tool_catalog::{
41    ToolCatalogCapabilities, ToolCatalogEntry, ToolCatalogMode, deferred_session_entry_count,
42    select_catalog_mode_from_snapshot,
43};
44use crate::tool_scope::ToolScope;
45use crate::turn_execution_authority::{
46    ContentShape, TurnPhase, TurnPrimitiveKind, TurnTerminalCauseKind, TurnTerminalOutcome,
47};
48use crate::types::{
49    AssistantBlock, BlockAssistantMessage, Message, OutputSchema, StopReason, ToolCallView,
50    ToolDef, ToolName, ToolNameSet, Usage,
51};
52use async_trait::async_trait;
53use serde::{Deserialize, Serialize};
54use std::collections::{BTreeMap, BTreeSet};
55use std::sync::Arc;
56
57pub use builder::{AgentBuildPolicyError, AgentBuilder, DefaultSystemPromptPolicy};
58pub use runner::{AgentRunner, SnapshotProjectionError, SystemContextStateError};
59
60/// Trait for LLM clients that can be used with the agent
61#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
62#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
63pub trait AgentLlmClient: Send + Sync {
64    /// Stream a response from the LLM
65    async fn stream_response(
66        &self,
67        messages: &[Message],
68        tools: &[Arc<ToolDef>],
69        max_tokens: u32,
70        temperature: Option<f32>,
71        provider_params: Option<&ProviderParamsOverride>,
72    ) -> Result<LlmStreamResult, AgentError>;
73
74    /// Get the typed catalog provider identity for this client.
75    ///
76    /// Clients return the typed [`crate::provider::Provider`] directly so no
77    /// boundary ever parses a caller-supplied string back into catalog
78    /// identity. String projections are derived via
79    /// [`crate::provider::Provider::as_str`].
80    fn provider(&self) -> crate::provider::Provider;
81
82    /// Get the current effective model identifier.
83    ///
84    /// Used by the agent loop for profile-default resolution (e.g., call timeout
85    /// defaults that vary per model family). Must reflect the current model even
86    /// after hot-swap.
87    fn model(&self) -> &str;
88
89    /// Prepare the next prebuilt fallback model after the generated turn
90    /// authority has classified the LLM failure as recoverable.
91    ///
92    /// This method does not classify failures and must not call the provider.
93    /// It only selects an already-constructed candidate and returns the typed
94    /// state the agent loop must apply before the retry attempt.
95    fn prepare_model_fallback(&self, _failure: &AgentError) -> Option<AgentLlmFallbackSwitch> {
96        None
97    }
98
99    /// Move the client-local active candidate from `previous_identity` to the
100    /// exact `target_identity` as one reversible transaction step.
101    ///
102    /// The core loop invokes this only after every target-dependent operation
103    /// (including target-provider schema compilation) has been prevalidated,
104    /// but before auth/session/machine state is committed. Implementations must
105    /// either perform the exact switch or return an error. The default fails
106    /// closed so a custom client cannot propose a fallback while silently
107    /// continuing to issue requests through its old provider client.
108    ///
109    /// Core verifies [`AgentLlmClient::active_model_fallback_identity`] after
110    /// the call and invokes this method in reverse if a later transaction step
111    /// fails.
112    fn commit_model_fallback(
113        &self,
114        _previous_identity: &crate::SessionLlmIdentity,
115        target_identity: &crate::SessionLlmIdentity,
116    ) -> Result<(), AgentError> {
117        Err(AgentError::ConfigError(format!(
118            "LLM client proposed fallback target '{}:{}' without an activation implementation",
119            target_identity.provider.as_str(),
120            target_identity.model
121        )))
122    }
123
124    /// Exact identity of the client-local active fallback candidate.
125    ///
126    /// Fallback-capable clients must expose the full session identity,
127    /// including auth binding and provider parameters. The default is absent,
128    /// which makes fallback activation fail closed before canonical state is
129    /// mutated.
130    fn active_model_fallback_identity(&self) -> Option<crate::SessionLlmIdentity> {
131        None
132    }
133
134    /// Compile an extraction schema against an inactive fallback target.
135    ///
136    /// This must delegate to the exact prebuilt target client without changing
137    /// which client is active. Core calls it before auth, machine, visibility,
138    /// session, or client activation state is mutated, then injects the
139    /// compiled representation into the target provider request.
140    fn compile_model_fallback_schema(
141        &self,
142        target_identity: &crate::SessionLlmIdentity,
143        _output_schema: &OutputSchema,
144    ) -> Result<CompiledSchema, AgentError> {
145        Err(AgentError::ConfigError(format!(
146            "LLM client cannot compile structured output for fallback target '{}:{}'",
147            target_identity.provider.as_str(),
148            target_identity.model
149        )))
150    }
151
152    /// Reset per-call observation of user-visible streaming output.
153    ///
154    /// Adapters that emit display/reasoning deltas before returning the final
155    /// stream result use this to let the retry loop distinguish a pre-stream
156    /// failure from a post-partial-output failure. The default is no-op for
157    /// clients that do not stream visible events outside the returned blocks.
158    fn begin_stream_output_observation(&self) {}
159
160    /// Whether the current LLM call has emitted user-visible streaming output.
161    ///
162    /// A `true` value suppresses model fallback for the failed call: retrying
163    /// against a different model after users already saw partial output can
164    /// produce duplicate assistant answers. Ordinary same-model retry policy is
165    /// still governed by the generated turn recovery authority.
166    fn stream_output_observed(&self) -> bool {
167        false
168    }
169
170    /// Compile an output schema for this provider.
171    ///
172    /// Default implementation normalizes the schema without provider-specific lowering.
173    /// Adapters override this to apply provider-specific transformations (e.g.,
174    /// Anthropic adds `additionalProperties: false`, Gemini strips unsupported keywords).
175    fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
176        // Default passthrough: normalized clone, no provider-specific lowering
177        Ok(CompiledSchema {
178            schema: output_schema.schema.as_value().clone(),
179            warnings: Vec::new(),
180        })
181    }
182}
183
184/// Hook for wrapping the final agent-facing LLM client.
185///
186/// Factories and runtimes apply this after provider/raw-client adaptation so
187/// embedders can compose cross-cutting behavior without provider-specific
188/// registry hooks.
189pub type AgentLlmClientDecorator =
190    Arc<dyn Fn(Arc<dyn AgentLlmClient>) -> Arc<dyn AgentLlmClient> + Send + Sync + 'static>;
191
192/// One fallback target skipped while selecting a viable backup model.
193#[derive(Debug, Clone)]
194pub struct AgentLlmFallbackSkippedTarget {
195    pub identity: crate::SessionLlmIdentity,
196    pub reason: String,
197}
198
199/// Typed state produced when an agent-facing LLM client activates a fallback.
200///
201/// The client owns only prebuilt candidate selection. The agent loop owns
202/// applying request policy, durable identity metadata, and tool visibility
203/// before issuing the machine-authorized retry.
204#[derive(Debug, Clone)]
205pub struct AgentLlmFallbackSwitch {
206    pub previous_identity: crate::SessionLlmIdentity,
207    pub new_identity: crate::SessionLlmIdentity,
208    pub request_policy: crate::SessionLlmRequestPolicy,
209    /// Proposed effective-registry witness for the exact target provider/model.
210    /// Core rejects foreign authority and freshly resolves all capability and
211    /// token-limit facts through the agent's captured registry. The witness is
212    /// required: unresolved fallback targets fail closed.
213    pub target_profile: crate::ModelProfileWitness,
214    pub skipped_targets: Vec<AgentLlmFallbackSkippedTarget>,
215}
216
217/// One-shot authorization for an exact sticky model-fallback activation.
218///
219/// There is deliberately no public constructor and the fields are private.
220/// The constructor is owned by the `agent` module, so only the core agent loop
221/// can mint this value after generated recovery acceptance and exact
222/// effective-registry validation. A public
223/// [`crate::handles::ModelRoutingHandle`] therefore cannot be driven directly
224/// with a caller-minted or foreign-registry profile.
225///
226/// ```compile_fail
227/// use meerkat_core::StickyModelFallbackActivationProof;
228///
229/// // Routing callers cannot fabricate an activation proof.
230/// let _proof = StickyModelFallbackActivationProof::new();
231/// ```
232pub struct StickyModelFallbackActivationProof {
233    previous_identity: crate::SessionLlmIdentity,
234    target_identity: crate::SessionLlmIdentity,
235    target_profile: crate::ModelProfileWitness,
236    target_capability_base_filter: crate::ToolFilter,
237    retry_attempt: u32,
238}
239
240impl StickyModelFallbackActivationProof {
241    fn new(
242        previous_identity: crate::SessionLlmIdentity,
243        target_identity: crate::SessionLlmIdentity,
244        target_profile: crate::ModelProfileWitness,
245        retry_attempt: u32,
246    ) -> Self {
247        let target_capability_base_filter = crate::capability_base_filter_for_image_tool_results(
248            target_profile.profile().image_tool_results,
249        );
250        Self {
251            previous_identity,
252            target_identity,
253            target_profile,
254            target_capability_base_filter,
255            retry_attempt,
256        }
257    }
258
259    /// Exact identity the generated recovery transition must still own.
260    pub fn previous_identity(&self) -> &crate::SessionLlmIdentity {
261        &self.previous_identity
262    }
263
264    /// Exact registry-resolved identity being activated.
265    pub fn target_identity(&self) -> &crate::SessionLlmIdentity {
266        &self.target_identity
267    }
268
269    /// Registry-owned target profile carried by this authorization.
270    pub fn target_profile(&self) -> &crate::ModelProfileWitness {
271        &self.target_profile
272    }
273
274    /// Registry-derived capability filter for the target model.
275    pub fn target_capability_base_filter(&self) -> &crate::ToolFilter {
276        &self.target_capability_base_filter
277    }
278
279    /// Machine-accepted retry attempt bound into this authorization.
280    pub fn retry_attempt(&self) -> u32 {
281        self.retry_attempt
282    }
283}
284
285/// Result of streaming from the LLM
286pub struct LlmStreamResult {
287    blocks: Vec<AssistantBlock>,
288    stop_reason: StopReason,
289    usage: Usage,
290}
291
292impl LlmStreamResult {
293    pub fn new(blocks: Vec<AssistantBlock>, stop_reason: StopReason, usage: Usage) -> Self {
294        Self {
295            blocks,
296            stop_reason,
297            usage,
298        }
299    }
300
301    pub fn blocks(&self) -> &[AssistantBlock] {
302        &self.blocks
303    }
304    pub fn stop_reason(&self) -> StopReason {
305        self.stop_reason
306    }
307    pub fn usage(&self) -> &Usage {
308        &self.usage
309    }
310
311    pub fn into_message(self) -> BlockAssistantMessage {
312        BlockAssistantMessage::new(self.blocks, self.stop_reason)
313    }
314
315    pub fn into_parts(self) -> (Vec<AssistantBlock>, StopReason, Usage) {
316        (self.blocks, self.stop_reason, self.usage)
317    }
318}
319
320/// Snapshot of the core agent's live execution state.
321///
322/// When a runtime-backed turn-state handle is attached, this snapshots the
323/// runtime-owned turn machine; otherwise it falls back to the in-process
324/// standalone turn state used by core-only execution.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct AgentExecutionSnapshot {
327    pub loop_state: LoopState,
328    pub turn_phase: TurnPhase,
329    /// Machine-owned turn-terminality verdict.
330    ///
331    /// The `TurnTerminalityClassified.terminal` verdict emitted by the canonical
332    /// MeerkatMachine `ClassifyTurnTerminality` input. Consumers mirror this bool
333    /// and must not reclassify [`TurnPhase`] locally.
334    pub turn_terminal: bool,
335    pub active_run_id: Option<RunId>,
336    pub terminal_run_id: Option<RunId>,
337    pub primitive_kind: TurnPrimitiveKind,
338    pub admitted_content_shape: Option<ContentShape>,
339    pub vision_enabled: bool,
340    pub image_tool_results_enabled: bool,
341    pub tool_calls_pending: u32,
342    pub pending_operation_ids: Option<Vec<OperationId>>,
343    pub barrier_operation_ids: Vec<OperationId>,
344    pub has_barrier_ops: bool,
345    pub barrier_satisfied: bool,
346    pub boundary_count: u32,
347    pub cancel_after_boundary: bool,
348    pub terminal_outcome: TurnTerminalOutcome,
349    pub terminal_cause_kind: Option<TurnTerminalCauseKind>,
350    pub extraction_attempts: u32,
351    pub max_extraction_retries: u32,
352    pub applied_cursor: CompletionSeq,
353}
354
355/// Result of polling for external tool updates.
356///
357/// Returned by [`AgentToolDispatcher::poll_external_updates`].
358#[derive(Debug, Clone, Default)]
359pub struct ExternalToolUpdate {
360    /// Notices about completed background operations since last poll.
361    pub notices: Vec<ExternalToolDelta>,
362    /// Names of servers still connecting in the background.
363    pub pending: Vec<String>,
364}
365
366/// Typed command requesting cancellation at the next turn boundary.
367///
368/// Carried over the cancel-after-boundary command channel from the surface
369/// that authorized the request (e.g. `SessionService::cancel_after_boundary`)
370/// to the agent loop, which observes it at the next boundary. The agent
371/// resolves the request against its own live active run. The exact run witness
372/// prevents a delayed request from an old executor attachment from cancelling
373/// a successor run after same-session replacement.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct CancelAfterBoundaryCommand {
376    expected_run_id: RunId,
377}
378
379impl CancelAfterBoundaryCommand {
380    /// Bind a cooperative-cancel command to one exact run incarnation.
381    pub fn for_run(expected_run_id: RunId) -> Self {
382        Self { expected_run_id }
383    }
384
385    /// Exact run incarnation this command is authorized to affect.
386    pub fn expected_run_id(&self) -> &RunId {
387        &self.expected_run_id
388    }
389}
390
391/// Producer end of the cancel-after-boundary command channel.
392///
393/// Cloned and handed to the requesting surface via
394/// [`Agent::cancel_after_boundary_handle`]; mirrors the cloneable-handle shape
395/// of the session-side `interrupt_notify` so a surface can request boundary
396/// cancellation without holding a reference to the agent.
397pub type CancelAfterBoundarySender = tokio::sync::mpsc::UnboundedSender<CancelAfterBoundaryCommand>;
398
399/// Typed context supplied by the agent loop when dispatching a tool call.
400///
401/// This is a dispatch-time projection of the already-admitted turn input. It
402/// lets tool surfaces resolve typed turn-scoped references, such as a
403/// `source=current_turn, index=0` image ref, without writing surface-local
404/// metadata into canonical transcript history.
405#[derive(Debug, Clone, Default, PartialEq, Eq)]
406pub struct ToolDispatchContext {
407    current_turn: Option<CurrentTurnContent>,
408    turn_metadata: BTreeMap<String, serde_json::Value>,
409}
410
411/// Dispatch-context key carrying the current durable objective id.
412pub const TOOL_DISPATCH_OBJECTIVE_ID_KEY: &str = "meerkat.objective_id";
413
414impl ToolDispatchContext {
415    pub fn from_current_turn_input(input: &crate::types::ContentInput) -> Self {
416        let blocks = match input {
417            crate::types::ContentInput::Text(_) => None,
418            crate::types::ContentInput::Blocks(blocks) => Some(blocks.clone()),
419        };
420        Self {
421            current_turn: blocks.map(CurrentTurnContent::new),
422            turn_metadata: BTreeMap::new(),
423        }
424    }
425
426    /// Project the typed run input into a dispatch context. The
427    /// pending-tool-results continuation carries no caller content, so it
428    /// projects to an empty context rather than a fabricated empty prompt.
429    pub fn from_run_input(input: &crate::types::RunInput) -> Self {
430        match input {
431            crate::types::RunInput::Content { content } => Self::from_current_turn_input(content),
432            crate::types::RunInput::PendingToolResults => Self::default(),
433        }
434    }
435
436    #[must_use]
437    pub fn with_turn_metadata(mut self, metadata: BTreeMap<String, serde_json::Value>) -> Self {
438        self.turn_metadata = metadata;
439        self
440    }
441
442    pub fn turn_metadata(&self, key: &str) -> Option<&serde_json::Value> {
443        self.turn_metadata.get(key)
444    }
445
446    pub fn current_turn(&self) -> Option<&CurrentTurnContent> {
447        self.current_turn.as_ref()
448    }
449
450    pub fn current_turn_image(
451        &self,
452        image_ref: CurrentTurnImageRef,
453    ) -> Option<&crate::types::ContentBlock> {
454        self.current_turn
455            .as_ref()
456            .and_then(|current_turn| current_turn.image(image_ref))
457    }
458}
459
460/// Typed reference to an image in the current admitted turn.
461///
462/// The wrapped index addresses the turn's *filtered image stream*, not the
463/// raw block list: ref `N` designates the `(N + 1)`-th image block of the
464/// current turn, skipping non-image blocks (so ref `0` is the first image
465/// even when text blocks precede it).
466///
467/// The field is private. In-process code mints refs only via
468/// [`CurrentTurnContent::image_ref`], which returns a ref only when the
469/// referenced image exists. Wire ingress (e.g. the comms `image_ref` tool
470/// input) deserializes a bare JSON integer directly into this type via
471/// `#[serde(transparent)]` — that is the sanctioned parse-at-ingress path,
472/// and resolution through [`CurrentTurnContent::image`] still validates
473/// existence.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
475#[serde(transparent)]
476pub struct CurrentTurnImageRef(usize);
477
478impl std::fmt::Display for CurrentTurnImageRef {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        std::fmt::Display::fmt(&self.0, f)
481    }
482}
483
484/// Multimodal content from the currently admitted turn.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct CurrentTurnContent {
487    blocks: Vec<crate::types::ContentBlock>,
488}
489
490impl CurrentTurnContent {
491    pub fn new(blocks: Vec<crate::types::ContentBlock>) -> Self {
492        Self { blocks }
493    }
494
495    pub fn blocks(&self) -> &[crate::types::ContentBlock] {
496        &self.blocks
497    }
498
499    /// Mint a typed reference to the `n`-th image of this turn's filtered
500    /// image stream. Returns `Some` only when that image exists, so every
501    /// in-process [`CurrentTurnImageRef`] is resolvable at mint time.
502    pub fn image_ref(&self, n: usize) -> Option<CurrentTurnImageRef> {
503        self.images().nth(n).map(|_| CurrentTurnImageRef(n))
504    }
505
506    pub fn image(&self, image_ref: CurrentTurnImageRef) -> Option<&crate::types::ContentBlock> {
507        self.images().nth(image_ref.0)
508    }
509
510    fn images(&self) -> impl Iterator<Item = &crate::types::ContentBlock> {
511        self.blocks
512            .iter()
513            .filter(|block| matches!(block, crate::types::ContentBlock::Image { .. }))
514    }
515}
516
517/// Completion notice for a detached background operation, projected from
518/// canonical ops-lifecycle terminal state plus dispatcher-owned display metadata.
519///
520/// This is a rebuildable projection (INV-003), not authoritative state.
521/// Terminal class and timing come from `OperationLifecycleSnapshot` (INV-001).
522/// Shell-projected detail is supplementary display only (INV-002).
523#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct DetachedOpCompletion {
525    /// App-facing job identifier (the control noun for surfaces).
526    pub job_id: String,
527    /// Operation kind from canonical ops-lifecycle.
528    pub kind: OperationKind,
529    /// Terminal status from canonical ops-lifecycle.
530    pub status: OperationStatus,
531    /// Terminal outcome from canonical ops-lifecycle.
532    pub terminal_outcome: Option<OperationTerminalOutcome>,
533    /// Canonical display label from ops-lifecycle snapshot.
534    pub display_name: String,
535    /// Dispatcher-projected summary (exit code, output tail). Display only.
536    pub detail: String,
537    /// Monotonic elapsed millis from ops-lifecycle snapshot.
538    pub elapsed_ms: Option<u64>,
539}
540
541/// Dispatcher binding capabilities — what optional bindings this dispatcher supports.
542///
543/// Returned by [`AgentToolDispatcher::capabilities`]. Replaces individual
544/// `supports_*` boolean methods with a single structured query.
545#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
546pub struct DispatcherCapabilities {
547    /// Whether `bind_ops_lifecycle` is implemented.
548    pub ops_lifecycle: bool,
549}
550
551/// Result of a dispatcher binding operation.
552///
553/// Distinguishes "binding was applied" from "binding was skipped" so callers
554/// can decide whether to wire downstream side effects (e.g. bridge tasks).
555///
556/// **Semantics (decision 11 — supported/best-effort/rejected):**
557/// - `Ok(Bound(d))` = **supported** — binding succeeded, side effects should be wired
558/// - `Ok(Skipped(d))` = **best-effort** — inner shared or incompatible, dispatcher unchanged
559/// - `Err(SharedOwnership)` = **rejected** — outer wrapper is shared, caught by factory pre-check
560/// - `Err(Unsupported)` = **rejected** — type doesn't support this binding, caught by `capabilities()`
561pub enum BindOutcome {
562    /// Binding was applied. The dispatcher was rebound.
563    Bound(Arc<dyn AgentToolDispatcher>),
564    /// Binding was skipped — inner dispatcher was shared or unsupported.
565    /// The returned dispatcher is unchanged but safe to use.
566    Skipped(Arc<dyn AgentToolDispatcher>),
567}
568
569impl BindOutcome {
570    /// Extract the dispatcher, regardless of bind status.
571    pub fn into_dispatcher(self) -> Arc<dyn AgentToolDispatcher> {
572        match self {
573            Self::Bound(d) | Self::Skipped(d) => d,
574        }
575    }
576
577    /// Whether the binding was actually applied.
578    pub fn was_bound(&self) -> bool {
579        matches!(self, Self::Bound(_))
580    }
581}
582
583/// Trait for tool dispatchers
584#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
585#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
586pub trait AgentToolDispatcher: Send + Sync {
587    /// Get available tool definitions
588    fn tools(&self) -> Arc<[Arc<ToolDef>]>;
589
590    /// Query exact catalog support for this dispatcher.
591    ///
592    /// Dispatchers report `exact_catalog=true` only when `tool_catalog()`
593    /// returns the exact precedence-resolved winner registry for the plane
594    /// they own. Wrappers that cannot prove exactness must leave this false.
595    fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
596        ToolCatalogCapabilities::default()
597    }
598
599    /// Return the precedence-resolved tool catalog for this dispatcher.
600    ///
601    /// The default implementation mirrors `tools()` as a visible-only inline
602    /// catalog. Callers must gate any deferred-catalog behavior on
603    /// `tool_catalog_capabilities().exact_catalog`.
604    fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
605        self.tools()
606            .iter()
607            .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
608            .collect::<Vec<_>>()
609            .into()
610    }
611
612    /// Return non-draining pending source names for exact-catalog discovery.
613    ///
614    /// Pending sources are catalog-level discovery metadata rather than
615    /// provider-visible tools. The default implementation reports none.
616    fn pending_catalog_sources(&self) -> Arc<[String]> {
617        Arc::from([])
618    }
619
620    /// Execute a tool call, returning the transcript result and any async operations.
621    ///
622    /// The `ToolDispatchOutcome` separates transcript data (`result`) from
623    /// execution metadata (`async_ops`). Most tools return no async ops;
624    /// use `ToolDispatchOutcome::from(result)` for synchronous tools.
625    async fn dispatch(
626        &self,
627        call: ToolCallView<'_>,
628    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError>;
629
630    /// Execute a tool call with the current turn's typed dispatch context.
631    ///
632    /// Most tools do not need turn-local context and inherit the plain
633    /// `dispatch` behavior. Context-sensitive surfaces override this method
634    /// rather than reaching into session history or prompt text.
635    async fn dispatch_with_context(
636        &self,
637        call: ToolCallView<'_>,
638        _context: &ToolDispatchContext,
639    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
640        self.dispatch(call).await
641    }
642
643    /// Poll for external tool updates from background operations (e.g. async MCP loading).
644    ///
645    /// The default implementation returns an empty update. Implementations that
646    /// support background tool loading (like `McpRouterAdapter`) override this
647    /// to drain completed results and report pending servers.
648    async fn poll_external_updates(&self) -> ExternalToolUpdate {
649        ExternalToolUpdate::default()
650    }
651
652    /// Snapshot the live external tool-surface machine state, if supported.
653    ///
654    /// This is a hidden diagnostic surface for MeerkatMachine mapping work.
655    /// Dispatchers that do not own dynamic external tool mutation should
656    /// return `None`.
657    fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
658        None
659    }
660
661    /// Query which optional bindings this dispatcher supports.
662    fn capabilities(&self) -> DispatcherCapabilities {
663        DispatcherCapabilities::default()
664    }
665
666    /// Bind a session-canonical ops registry into this dispatcher.
667    ///
668    /// Dispatchers that emit session-visible `AsyncOpRef`s must route those
669    /// operation IDs into the bound registry. Under the identity-first Mob
670    /// regime the owner binding passed here is the canonical bridge session
671    /// binding, even though many compatibility surfaces still spell it
672    /// `session_id`. Default returns Unsupported.
673    fn bind_ops_lifecycle(
674        self: Arc<Self>,
675        _registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
676        _owner_bridge_session_id: crate::types::SessionId,
677    ) -> Result<BindOutcome, OpsLifecycleBindError> {
678        Err(OpsLifecycleBindError::Unsupported)
679    }
680
681    /// Return the completion enrichment provider, if available.
682    ///
683    /// Dispatchers with shell job management return a provider that maps
684    /// operation IDs to display details (job ID, status detail string).
685    fn completion_enrichment(
686        &self,
687    ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
688        None
689    }
690
691    /// Bind a session-scoped MCP server lifecycle handle (Phase 5G / T5g).
692    ///
693    /// Dispatchers that manage per-server MCP handshake lifecycle (like
694    /// `McpRouterAdapter`) use the handle to mirror connection state into
695    /// the session's MeerkatMachine DSL. The default implementation is a
696    /// no-op for dispatchers that have no MCP handshake to route.
697    fn bind_mcp_server_lifecycle_handle(
698        &self,
699        _handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
700    ) {
701    }
702
703    /// Bind the session-canonical external tool-surface handle.
704    ///
705    /// MCP dispatchers use this to route add/remove/reload/call lifecycle
706    /// semantics through the session's MeerkatMachine DSL instead of their
707    /// standalone compatibility projection. The default implementation is a
708    /// no-op for dispatchers that do not own dynamic external tool surfaces.
709    fn bind_external_tool_surface_handle(
710        &self,
711        _handle: Arc<dyn crate::handles::ExternalToolSurfaceHandle>,
712    ) {
713    }
714}
715
716/// Compute whether the current exact catalog should stay inline or switch to deferred mode.
717pub fn select_tool_catalog_mode<T>(dispatcher: &T) -> ToolCatalogMode
718where
719    T: AgentToolDispatcher + ?Sized,
720{
721    let capabilities = dispatcher.tool_catalog_capabilities();
722    if !capabilities.exact_catalog {
723        return ToolCatalogMode::Inline;
724    }
725    let pending_sources = dispatcher.pending_catalog_sources();
726    let catalog = dispatcher.tool_catalog();
727    select_catalog_mode_from_snapshot(
728        capabilities.exact_catalog,
729        catalog.as_ref(),
730        pending_sources.as_ref(),
731    )
732}
733
734/// Compute whether the catalog control plane should be composed for this
735/// dispatcher, even if the current adaptive snapshot remains inline.
736pub fn should_compose_tool_catalog_control_plane<T>(dispatcher: &T) -> bool
737where
738    T: AgentToolDispatcher + ?Sized,
739{
740    let capabilities = dispatcher.tool_catalog_capabilities();
741    if !capabilities.exact_catalog {
742        return false;
743    }
744    if capabilities.may_require_catalog_control_plane {
745        return true;
746    }
747
748    let pending_sources = dispatcher.pending_catalog_sources();
749    if !pending_sources.is_empty() {
750        return true;
751    }
752
753    let catalog = dispatcher.tool_catalog();
754    deferred_session_entry_count(catalog.as_ref()) > 0
755}
756
757/// Error from [`AgentToolDispatcher::bind_ops_lifecycle`].
758#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
759pub enum OpsLifecycleBindError {
760    #[error("ops lifecycle binding is unsupported")]
761    Unsupported,
762    #[error("dispatcher has shared ownership and cannot be rebound")]
763    SharedOwnership,
764}
765
766/// A tool dispatcher that filters tools based on a policy
767///
768/// Legacy tool lists are filtered once at construction time based on the
769/// allowed_tools list. Exact-catalog dispatchers keep catalog callability live.
770/// The inner dispatcher is used for actual dispatch, but only allowed tools are
771/// exposed via tools() and dispatch() returns AccessDenied for filtered tools.
772pub struct FilteredToolDispatcher<T: AgentToolDispatcher + ?Sized> {
773    inner: Arc<T>,
774    allowed_tools: ToolNameSet,
775    /// Pre-computed filtered tool list for non-exact dispatchers.
776    filtered_tools: Arc<[Arc<ToolDef>]>,
777}
778
779impl<T: AgentToolDispatcher + ?Sized> FilteredToolDispatcher<T> {
780    pub fn new<I, N>(inner: Arc<T>, allowed_tools: I) -> Self
781    where
782        I: IntoIterator<Item = N>,
783        N: Into<ToolName>,
784    {
785        let allowed_set: ToolNameSet = allowed_tools
786            .into_iter()
787            .map(Into::into)
788            .collect::<ToolNameSet>();
789
790        let filtered: Vec<Arc<ToolDef>> = if inner.tool_catalog_capabilities().exact_catalog {
791            inner
792                .tool_catalog()
793                .iter()
794                .filter(|entry| entry.currently_callable())
795                .map(|entry| Arc::clone(&entry.tool))
796                .filter(|t| allowed_set.contains(t.name.as_str()))
797                .collect()
798        } else {
799            inner
800                .tools()
801                .iter()
802                .filter(|t| allowed_set.contains(t.name.as_str()))
803                .map(Arc::clone)
804                .collect()
805        };
806
807        Self {
808            inner,
809            allowed_tools: allowed_set,
810            filtered_tools: filtered.into(),
811        }
812    }
813}
814
815#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
816#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
817impl<T: AgentToolDispatcher + ?Sized + 'static> AgentToolDispatcher for FilteredToolDispatcher<T> {
818    fn tools(&self) -> Arc<[Arc<ToolDef>]> {
819        if self.inner.tool_catalog_capabilities().exact_catalog {
820            return self
821                .inner
822                .tool_catalog()
823                .iter()
824                .filter(|entry| entry.currently_callable())
825                .map(|entry| Arc::clone(&entry.tool))
826                .filter(|tool| self.allowed_tools.contains(tool.name.as_str()))
827                .collect::<Vec<_>>()
828                .into();
829        }
830        Arc::clone(&self.filtered_tools)
831    }
832
833    async fn dispatch(
834        &self,
835        call: ToolCallView<'_>,
836    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
837        self.dispatch_with_context(call, &ToolDispatchContext::default())
838            .await
839    }
840
841    async fn dispatch_with_context(
842        &self,
843        call: ToolCallView<'_>,
844        context: &ToolDispatchContext,
845    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
846        if !self.allowed_tools.contains(call.name) {
847            let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
848                self.inner
849                    .tool_catalog()
850                    .iter()
851                    .any(|entry| entry.tool.name == call.name)
852            } else {
853                self.inner.tools().iter().any(|tool| tool.name == call.name)
854            };
855            if !inner_knows_tool {
856                return Err(crate::error::ToolError::not_found(call.name));
857            }
858            return Err(crate::error::ToolError::access_denied(call.name));
859        }
860        self.inner.dispatch_with_context(call, context).await
861    }
862
863    fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
864        self.inner.tool_catalog_capabilities()
865    }
866
867    fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
868        if !self.inner.tool_catalog_capabilities().exact_catalog {
869            return self
870                .tools()
871                .iter()
872                .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
873                .collect::<Vec<_>>()
874                .into();
875        }
876        self.inner
877            .tool_catalog()
878            .iter()
879            .filter(|entry| self.allowed_tools.contains(entry.tool.name.as_str()))
880            .cloned()
881            .collect::<Vec<_>>()
882            .into()
883    }
884
885    fn pending_catalog_sources(&self) -> Arc<[String]> {
886        self.inner.pending_catalog_sources()
887    }
888
889    async fn poll_external_updates(&self) -> ExternalToolUpdate {
890        self.inner.poll_external_updates().await
891    }
892
893    fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
894        self.inner.external_tool_surface_snapshot()
895    }
896
897    fn capabilities(&self) -> DispatcherCapabilities {
898        self.inner.capabilities()
899    }
900
901    fn bind_ops_lifecycle(
902        self: Arc<Self>,
903        registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
904        owner_bridge_session_id: crate::types::SessionId,
905    ) -> Result<BindOutcome, OpsLifecycleBindError> {
906        let owned = Arc::try_unwrap(self).map_err(|_| OpsLifecycleBindError::SharedOwnership)?;
907        if Arc::strong_count(&owned.inner) == 1 {
908            let outcome = owned
909                .inner
910                .bind_ops_lifecycle(registry, owner_bridge_session_id)?;
911            let bound = outcome.was_bound();
912            let d = outcome.into_dispatcher();
913            let allowed_tools = owned.allowed_tools.into_iter().collect::<Vec<_>>();
914            Ok(if bound {
915                BindOutcome::Bound(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
916            } else {
917                BindOutcome::Skipped(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
918            })
919        } else {
920            Ok(BindOutcome::Skipped(Arc::new(FilteredToolDispatcher {
921                inner: owned.inner,
922                allowed_tools: owned.allowed_tools,
923                filtered_tools: owned.filtered_tools,
924            })))
925        }
926    }
927
928    fn completion_enrichment(
929        &self,
930    ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
931        self.inner.completion_enrichment()
932    }
933
934    fn bind_mcp_server_lifecycle_handle(
935        &self,
936        handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
937    ) {
938        self.inner.bind_mcp_server_lifecycle_handle(handle);
939    }
940
941    fn bind_external_tool_surface_handle(
942        &self,
943        handle: Arc<dyn crate::handles::ExternalToolSurfaceHandle>,
944    ) {
945        self.inner.bind_external_tool_surface_handle(handle);
946    }
947}
948
949/// Trait for session stores
950#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
951#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
952pub trait AgentSessionStore: Send + Sync {
953    async fn save(&self, session: &Session) -> Result<(), AgentError>;
954    async fn load(&self, id: &str) -> Result<Option<Session>, AgentError>;
955}
956
957/// Runtime policy for inlining peer lifecycle updates into session context.
958#[derive(Debug, Clone, Copy, PartialEq, Eq)]
959pub enum InlinePeerNotificationPolicy {
960    /// Always inline batched peer lifecycle updates.
961    Always,
962    /// Never inline batched peer lifecycle updates.
963    Never,
964    /// Inline only when post-drain peer count is at or below this threshold.
965    AtMost(usize),
966}
967
968/// Default inline threshold when no explicit value is configured.
969pub const DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS: usize = 50;
970
971impl InlinePeerNotificationPolicy {
972    /// Resolve policy from transport/build-layer config representation.
973    pub fn try_from_raw(raw: Option<i32>) -> Result<Self, i32> {
974        match raw {
975            None => Ok(Self::AtMost(DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS)),
976            Some(-1) => Ok(Self::Always),
977            Some(0) => Ok(Self::Never),
978            Some(v) if v > 0 => Ok(Self::AtMost(v as usize)),
979            Some(v) => Err(v),
980        }
981    }
982}
983
984/// Error returned when a comms runtime capability is not available.
985#[derive(Debug, thiserror::Error)]
986pub enum CommsCapabilityError {
987    /// The runtime does not support this capability.
988    #[error("comms capability not supported: {0}")]
989    Unsupported(String),
990}
991
992/// Trait for comms runtime that can be used with the agent
993#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
994#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
995pub trait CommsRuntime: Send + Sync {
996    /// Canonical runtime routing identity for this peer, if available.
997    ///
998    /// `PeerId` is the UUID-shaped routing key used by peer directories and
999    /// trust stores. Implementations that only have the legacy string carrier
1000    /// may return a parsed UUID-shaped `public_key()` value; implementations
1001    /// with Ed25519 public keys should override this and return the pubkey-
1002    /// derived canonical [`PeerId`].
1003    fn peer_id(&self) -> Option<PeerId> {
1004        self.public_key()
1005            .as_deref()
1006            .and_then(|public_key| PeerId::parse(public_key).ok())
1007    }
1008
1009    /// Runtime-local transport/auth public key, if available.
1010    ///
1011    /// Returns an Ed25519 public key string in `ed25519:<base64>` format.
1012    /// This is not the canonical routing [`PeerId`]; use [`Self::peer_id`]
1013    /// for roster/projection identity and peer-directory lookups.
1014    fn public_key(&self) -> Option<String> {
1015        None
1016    }
1017
1018    /// Runtime-local Ed25519 public key bytes, if available.
1019    ///
1020    /// This is the typed form of [`Self::public_key`]. Trust installation
1021    /// paths that need to verify `PeerId`/pubkey consistency should prefer
1022    /// this method over reparsing the string carrier.
1023    fn public_key_bytes(&self) -> Option<[u8; 32]> {
1024        None
1025    }
1026
1027    /// Runtime-local canonical comms routing name, if available.
1028    ///
1029    /// This is the peer name used in trusted-peer descriptors and peer
1030    /// directories. It is separate from the advertised transport address so
1031    /// callers do not recover identity by parsing transport strings.
1032    fn comms_name(&self) -> Option<String> {
1033        None
1034    }
1035
1036    /// Runtime-local advertised comms address, if available.
1037    ///
1038    /// This is the canonical address the runtime expects peers to use when
1039    /// constructing a [`TrustedPeerDescriptor`]. Implementations that do not
1040    /// expose a stable advertised address can return `None`.
1041    fn advertised_address(&self) -> Option<String> {
1042        None
1043    }
1044
1045    /// Runtime-local bootstrap proof for the initial supervisor bind, if
1046    /// available.
1047    fn bridge_bootstrap_token(&self) -> Option<String> {
1048        None
1049    }
1050
1051    /// Apply a comms trust projection mutation authorized by generated
1052    /// machine/composition authority.
1053    ///
1054    /// This is the only mutable trust-store seam.
1055    async fn apply_trust_mutation(
1056        &self,
1057        _mutation: CommsTrustMutation,
1058    ) -> Result<CommsTrustMutationResult, SendError> {
1059        Err(SendError::Unsupported(
1060            "apply_trust_mutation not supported for this CommsRuntime".to_string(),
1061        ))
1062    }
1063
1064    /// Bind this target runtime to the generated MobMachine owner token whose
1065    /// trust handoffs may mutate mob-owned trust rows.
1066    ///
1067    /// Mob runtimes call this before submitting a generated mob trust mutation.
1068    /// Implementations must fail closed when they cannot remember and compare
1069    /// the owner token during [`Self::apply_trust_mutation`].
1070    async fn install_generated_mob_trust_owner(
1071        &self,
1072        _owner: Arc<dyn std::any::Any + Send + Sync>,
1073    ) -> Result<(), SendError> {
1074        Err(SendError::Unsupported(
1075            "generated mob trust owner binding not supported for this CommsRuntime".to_string(),
1076        ))
1077    }
1078
1079    /// Read-only preflight for binding this target runtime to a recovered
1080    /// MobMachine owner token.
1081    ///
1082    /// Resume uses this to validate every generated trust repair target before
1083    /// mutating any trust projection row. Implementations must not change the
1084    /// stored owner token here; [`Self::install_recovered_generated_mob_trust_owner`]
1085    /// performs the actual binding after the full batch has passed preflight.
1086    async fn validate_recovered_generated_mob_trust_owner(
1087        &self,
1088        _owner: Arc<dyn std::any::Any + Send + Sync>,
1089    ) -> Result<(), SendError> {
1090        Err(SendError::Unsupported(
1091            "recovered generated mob trust owner validation not supported for this CommsRuntime"
1092                .to_string(),
1093        ))
1094    }
1095
1096    /// Rebind this target runtime to the owner token of a recovered
1097    /// MobMachine authority.
1098    ///
1099    /// Recovery reconstructs generated authority from persisted machine state,
1100    /// which gives it a fresh process-local owner token. Implementations may
1101    /// bind this owner only when no generated MobMachine owner is already
1102    /// installed, or when it is the same owner token. They must fail closed
1103    /// rather than replacing a different live owner through recovery plumbing.
1104    async fn install_recovered_generated_mob_trust_owner(
1105        &self,
1106        _owner: Arc<dyn std::any::Any + Send + Sync>,
1107    ) -> Result<(), SendError> {
1108        Err(SendError::Unsupported(
1109            "recovered generated mob trust owner binding not supported for this CommsRuntime"
1110                .to_string(),
1111        ))
1112    }
1113
1114    /// Opaque host-acceptor registration material for reverse-lane demux
1115    /// composition (the runtime's identity pubkey, its ack-signing keypair,
1116    /// and its inbox sender), encoded by the concrete comms crate.
1117    ///
1118    /// A host that composes an acceptor demux in front of this runtime (so
1119    /// remote peers can dial one shared listener and be routed to this
1120    /// identity's inbox) decodes the payload where it holds the concrete
1121    /// comms dependency (`meerkat_comms::HostAcceptorRegistrationMaterial`).
1122    /// `None` means this runtime exposes no registration material and the
1123    /// composer must fail closed (no acceptor registration). The default is
1124    /// `None`; only the concrete comms runtime overrides it — the typed
1125    /// trait surface itself continues to expose no signing material.
1126    fn host_acceptor_registration_payload(&self) -> Option<Arc<dyn std::any::Any + Send + Sync>> {
1127        None
1128    }
1129
1130    /// Register a peer for admission-only trust without listing it in the
1131    /// directory.
1132    ///
1133    /// Used for control-plane edges — the canonical case is the supervisor
1134    /// bridge for session-backed mob members: lifecycle notifications
1135    /// (`mob.peer_added`, `mob.peer_retired`, …) must land at the member's
1136    /// inbox, but the supervisor must not appear as an ordinary sendable
1137    /// peer in `comms.peers` / REST / RPC / MCP. The admission gate consults
1138    /// both the public and private trust sets; `resolve_peer_directory()`
1139    /// consults only the public set.
1140    async fn add_private_trusted_peer(
1141        &self,
1142        _peer: TrustedPeerDescriptor,
1143    ) -> Result<(), SendError> {
1144        Err(SendError::Unsupported(
1145            "generated comms private trust mutation authority required".to_string(),
1146        ))
1147    }
1148
1149    /// Remove a previously registered private-trust edge by peer ID.
1150    ///
1151    /// Returns `true` if the edge was present and removed, `false` if it
1152    /// was not.
1153    async fn remove_private_trusted_peer(&self, _peer_id: &str) -> Result<bool, SendError> {
1154        Err(SendError::Unsupported(
1155            "generated comms private trust mutation authority required".to_string(),
1156        ))
1157    }
1158
1159    /// Install the host-owned outbound content-taint declaration.
1160    ///
1161    /// The declaration is host-set carrier config, not machine state: the
1162    /// host owns the "this session's content is tainted" fact and this
1163    /// runtime stamps it (inside the signed envelope region) on every
1164    /// outbound content-bearing send until changed. `None` clears the
1165    /// declaration (subsequent envelopes carry no claim — which receivers
1166    /// must never coalesce into `Clean`).
1167    ///
1168    /// The declaration is in-memory runtime state: a rebuilt runtime (e.g.
1169    /// a respawned mob member) starts with no declaration, which aligns
1170    /// with fresh-context taint semantics — hosts re-declare when their
1171    /// tracker re-marks the new context.
1172    ///
1173    /// Fails typed (never a silent no-op — silently dropping a security
1174    /// declaration would let tainted content ship with a clean-looking
1175    /// envelope) for runtimes that do not carry outbound comms.
1176    fn set_outbound_content_taint(
1177        &self,
1178        _taint: Option<crate::comms::SenderContentTaint>,
1179    ) -> Result<(), SendError> {
1180        Err(SendError::Unsupported(
1181            "outbound content-taint declaration not supported by this CommsRuntime".to_string(),
1182        ))
1183    }
1184
1185    /// Dispatch a canonical comms command.
1186    async fn send(&self, _cmd: CommsCommand) -> Result<SendReceipt, SendError> {
1187        Err(SendError::Unsupported(
1188            "send not implemented for this CommsRuntime".to_string(),
1189        ))
1190    }
1191
1192    #[doc(hidden)]
1193    fn stream(&self, scope: StreamScope) -> Result<EventStream, StreamError> {
1194        let scope_desc = match scope {
1195            StreamScope::Session(session_id) => format!("session {session_id}"),
1196            StreamScope::Interaction(interaction_id) => format!("interaction {}", interaction_id.0),
1197        };
1198        Err(StreamError::NotFound(scope_desc))
1199    }
1200
1201    /// List peers visible to this runtime.
1202    async fn peers(&self) -> Vec<PeerDirectoryEntry> {
1203        Vec::new()
1204    }
1205
1206    /// Count peers visible to this runtime.
1207    ///
1208    /// Implementations can override this to avoid materializing a full peer list.
1209    async fn peer_count(&self) -> usize {
1210        self.peers().await.len()
1211    }
1212
1213    #[doc(hidden)]
1214    async fn send_and_stream(
1215        &self,
1216        cmd: CommsCommand,
1217    ) -> Result<(SendReceipt, EventStream), SendAndStreamError> {
1218        let receipt = self.send(cmd).await?;
1219        Err(SendAndStreamError::StreamAttach {
1220            receipt,
1221            error: StreamError::Internal(
1222                "send_and_stream is not implemented for this runtime".to_string(),
1223            ),
1224        })
1225    }
1226
1227    /// Drain comms inbox and return messages formatted for the LLM
1228    async fn drain_messages(&self) -> Vec<String>;
1229    /// Get a notification when new messages arrive
1230    fn inbox_notify(&self) -> Arc<tokio::sync::Notify>;
1231    /// Returns true if a DISMISS signal was seen during the last `drain_messages` call.
1232    fn dismiss_received(&self) -> bool {
1233        false
1234    }
1235    /// Get an event injector for this runtime's inbox.
1236    ///
1237    /// Surfaces use this to push external events into the agent inbox.
1238    /// Returns `None` if the implementation doesn't support event injection.
1239    fn event_injector(&self) -> Option<Arc<dyn crate::EventInjector>> {
1240        None
1241    }
1242
1243    /// Internal runtime seam for interaction-scoped streaming.
1244    #[doc(hidden)]
1245    fn interaction_event_injector(
1246        &self,
1247    ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
1248        None
1249    }
1250
1251    /// Drain comms inbox and return structured interactions.
1252    ///
1253    /// Default implementation wraps `drain_messages()` results as `InteractionContent::Message`
1254    /// with generated IDs.
1255    async fn drain_inbox_interactions(&self) -> Vec<crate::interaction::InboxInteraction> {
1256        self.drain_messages()
1257            .await
1258            .into_iter()
1259            .map(|text| crate::interaction::InboxInteraction {
1260                objective_id: None,
1261                id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
1262                from_route: None,
1263                from: "unknown".into(),
1264                content: crate::interaction::InteractionContent::Message {
1265                    body: text.clone(),
1266                    blocks: None,
1267                },
1268                rendered_text: text,
1269                handling_mode: crate::types::HandlingMode::Queue,
1270                render_metadata: None,
1271                sender_taint: None,
1272            })
1273            .collect()
1274    }
1275
1276    /// Look up and remove a one-shot subscriber for the given interaction.
1277    ///
1278    /// Returns the event sender if a subscriber was registered (via `inject_with_subscription`).
1279    /// The entry is removed from the registry on lookup (one-shot).
1280    fn interaction_subscriber(
1281        &self,
1282        _id: &crate::interaction::InteractionId,
1283    ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1284        None
1285    }
1286
1287    /// Take and clear the one-shot sender for an interaction-scoped stream.
1288    fn take_interaction_stream_sender(
1289        &self,
1290        _id: &crate::interaction::InteractionId,
1291    ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1292        self.interaction_subscriber(_id)
1293    }
1294
1295    /// Signal that an interaction has reached a terminal state (complete or failed).
1296    ///
1297    /// Implementations should transition the reservation FSM to `Completed` and
1298    /// clean up registry entries. Called from the keep-alive loop after sending
1299    /// terminal events to the tap.
1300    fn mark_interaction_complete(&self, _id: &crate::interaction::InteractionId) {}
1301
1302    /// Signal that an interaction stream became unusable for an explicit,
1303    /// typed reason. Implementations with machine-owned stream lifecycle must
1304    /// drive `InteractionStreamAbandoned`; transport-only implementations may
1305    /// clean up their local projection directly.
1306    fn abandon_interaction_stream(
1307        &self,
1308        _id: &crate::interaction::InteractionId,
1309        _reason: crate::InteractionStreamAbandonReason,
1310    ) {
1311    }
1312
1313    /// Access the session's peer-interaction DSL handle (W1-A).
1314    ///
1315    /// Returns `None` for transport-only comms runtimes. A runtime that emits
1316    /// semantic peer request/response receipts must return `Some` after the
1317    /// surface installs machine authority.
1318    fn peer_interaction_handle(
1319        &self,
1320    ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1321        None
1322    }
1323
1324    /// Access peer request/response authority only when the runtime has the
1325    /// complete machine-owned lifecycle pair.
1326    ///
1327    /// Semantic peer request/response ingress requires both the peer
1328    /// interaction handle and the paired interaction-stream handle. The stream
1329    /// handle itself stays hidden behind runtime ownership; this witness lets
1330    /// authority boundaries fail closed instead of treating a lone peer handle
1331    /// as sufficient.
1332    fn peer_request_response_authority_handle(
1333        &self,
1334    ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1335        None
1336    }
1337
1338    /// Drain classified inbox interactions.
1339    ///
1340    /// Returns interactions with pre-computed classification from ingress.
1341    /// The host loop routes on the stored `PeerInputClass` instead of
1342    /// re-classifying after drain.
1343    ///
1344    /// Default returns `Unsupported`. Comms-enabled runtimes must override.
1345    async fn drain_classified_inbox_interactions(
1346        &self,
1347    ) -> Result<Vec<crate::interaction::ClassifiedInboxInteraction>, CommsCapabilityError> {
1348        Err(CommsCapabilityError::Unsupported(
1349            "drain_classified_inbox_interactions".to_string(),
1350        ))
1351    }
1352
1353    /// Drain canonical peer/event ingress candidates.
1354    ///
1355    /// This remains the live runtime drain bridge for call sites that consume
1356    /// the `PeerInputCandidate` noun directly. The underlying drain unit is
1357    /// identical to `ClassifiedInboxInteraction`, so the default
1358    /// implementation simply forwards the classified drain path.
1359    async fn drain_peer_input_candidates(&self) -> Vec<crate::interaction::PeerInputCandidate> {
1360        self.drain_classified_inbox_interactions()
1361            .await
1362            .unwrap_or_default()
1363    }
1364
1365    /// Snapshot the currently queued peer-ingress surface without draining it.
1366    ///
1367    /// This is a hidden diagnostic capability used while mapping the internal
1368    /// MeerkatMachine boundary onto existing comms ownership.
1369    async fn peer_ingress_queue_snapshot(
1370        &self,
1371    ) -> Result<crate::interaction::PeerIngressQueueSnapshot, CommsCapabilityError> {
1372        Err(CommsCapabilityError::Unsupported(
1373            "peer_ingress_queue_snapshot".to_string(),
1374        ))
1375    }
1376
1377    /// Snapshot the current peer runtime surface for MeerkatMachine mapping.
1378    ///
1379    /// This extends the queued ingress snapshot with the local trust membership
1380    /// that governs peer admission.
1381    async fn peer_ingress_runtime_snapshot(
1382        &self,
1383    ) -> Result<crate::interaction::PeerIngressRuntimeSnapshot, CommsCapabilityError> {
1384        Err(CommsCapabilityError::Unsupported(
1385            "peer_ingress_runtime_snapshot".to_string(),
1386        ))
1387    }
1388
1389    /// Snapshot only the public trust projection owned by generated public
1390    /// peer authority.
1391    ///
1392    /// Private/control-plane trust edges are admitted by separate generated
1393    /// private authority and must not be reconciled or removed by public peer
1394    /// projection owners.
1395    async fn public_trusted_peer_projection_snapshot(
1396        &self,
1397    ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1398        Err(CommsCapabilityError::Unsupported(
1399            "public_trusted_peer_projection_snapshot".to_string(),
1400        ))
1401    }
1402
1403    /// Snapshot the public trust projection owned by one generated source.
1404    ///
1405    /// This is the behavior-authority read used by generated trust
1406    /// reconciliation. Compatibility/public snapshots may still union public
1407    /// rows for display, but generated removals must diff only against rows
1408    /// previously installed by the same generated owner.
1409    async fn trusted_peer_projection_snapshot_for_source(
1410        &self,
1411        _source_kind: crate::comms::GeneratedCommsTrustAuthoritySourceKind,
1412    ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1413        Err(CommsCapabilityError::Unsupported(
1414            "trusted_peer_projection_snapshot_for_source".to_string(),
1415        ))
1416    }
1417
1418    /// Get a notification that fires only for actionable peer input.
1419    ///
1420    /// Default returns `Unsupported`. Comms-enabled runtimes must override.
1421    /// Used by the factory to bridge into `WaitTool` interrupt.
1422    fn actionable_input_notify(&self) -> Result<Arc<tokio::sync::Notify>, CommsCapabilityError> {
1423        Err(CommsCapabilityError::Unsupported(
1424            "actionable_input_notify".to_string(),
1425        ))
1426    }
1427
1428    /// Stage a one-shot reply endpoint for a Response to a peer outside the
1429    /// trust store.
1430    ///
1431    /// This is the legacy uncorrelated compatibility seam. It is
1432    /// Response-only, one-shot, and trust-store-losing; callers may supply
1433    /// only a machine-authorized endpoint already held in runtime state.
1434    /// Neither a Request's `reply_endpoint` nor any decoded payload/sender
1435    /// address is authority for this method. New ingress response paths use
1436    /// [`Self::stage_correlated_reply_endpoint`] instead.
1437    ///
1438    /// Parameters are primitives because core cannot name the comms-crate
1439    /// newtypes (dependency direction). Default fails typed, not no-op:
1440    /// silently dropping a reply-repair staging would strand the remote
1441    /// sender in a timeout with no cause. Callers decide policy — reply
1442    /// drains treat `Unsupported` as "runtime has no staging capability" and
1443    /// proceed, since in-proc runtimes resolve via the ingress route anyway.
1444    async fn stage_declared_reply_endpoint(
1445        &self,
1446        _dest: PeerId,
1447        _signer_pubkey: [u8; 32],
1448        _declared_address: String,
1449    ) -> Result<(), SendError> {
1450        Err(SendError::Unsupported(
1451            "declared reply endpoint staging not supported".to_string(),
1452        ))
1453    }
1454
1455    /// Stage an authenticated one-shot endpoint for the Response correlated
1456    /// to `in_reply_to` from `dest`.
1457    ///
1458    /// Unlike the legacy uncorrelated staging seam above, this endpoint is
1459    /// keyed by both peer identity and request id and therefore takes
1460    /// precedence over durable trust only for that exact Response. This is
1461    /// the only Request-ingress callback seam. `signer_pubkey` must
1462    /// come from a signature-verified envelope and derive `dest` in the
1463    /// concrete runtime. `declared_endpoint` must be the classifier's
1464    /// source-confined TCP projection: kernel-observed source IP plus the
1465    /// signed, nonzero declared port. Arbitrary payload addresses,
1466    /// sender-selected hosts, UDS addresses, and open-auth ingress are never
1467    /// callback authority.
1468    async fn stage_correlated_reply_endpoint(
1469        &self,
1470        _dest: PeerId,
1471        _in_reply_to: crate::interaction::InteractionId,
1472        _signer_pubkey: [u8; 32],
1473        _declared_endpoint: crate::comms::PeerAddress,
1474    ) -> Result<(), SendError> {
1475        Err(SendError::Unsupported(
1476            "correlated reply endpoint staging not supported".to_string(),
1477        ))
1478    }
1479
1480    /// Idempotently discard a previously staged correlated endpoint.
1481    /// Responders call this when validation or response sending fails before
1482    /// the Router consumes the exact one-shot entry.
1483    async fn unstage_correlated_reply_endpoint(
1484        &self,
1485        _dest: PeerId,
1486        _in_reply_to: crate::interaction::InteractionId,
1487    ) -> Result<(), SendError> {
1488        Err(SendError::Unsupported(
1489            "correlated reply endpoint cleanup not supported".to_string(),
1490        ))
1491    }
1492
1493    /// One-shot reply waiter for an agent-blocking bridge request (member
1494    /// upcall lane). Consulted by the comms drain BEFORE session injection: a
1495    /// taken waiter receives the terminal Response candidate (typed
1496    /// terminality intact) and the candidate never becomes session input.
1497    ///
1498    /// Returns `Some(sender)` only for a live waiter. A tombstoned (timed
1499    /// out) waiter entry is consumed and `None` is returned — pair with
1500    /// [`Self::has_bridge_reply_waiter`] to distinguish "tombstone consumed"
1501    /// (discard the late reply) from "never registered" (ordinary session
1502    /// path). Default: no registry (a query, not a capability — absence of a
1503    /// waiter is the universal normal case).
1504    fn take_bridge_reply_waiter(
1505        &self,
1506        _in_reply_to: &crate::interaction::InteractionId,
1507    ) -> Option<tokio::sync::oneshot::Sender<crate::interaction::PeerInputCandidate>> {
1508        None
1509    }
1510
1511    /// True when a bridge-reply waiter entry (live or tombstoned) is
1512    /// registered for `in_reply_to`. See [`Self::take_bridge_reply_waiter`].
1513    fn has_bridge_reply_waiter(&self, _in_reply_to: &crate::interaction::InteractionId) -> bool {
1514        false
1515    }
1516}
1517
1518/// The main Agent struct
1519pub struct Agent<C, T, S>
1520where
1521    C: AgentLlmClient + ?Sized,
1522    T: AgentToolDispatcher + ?Sized,
1523    S: AgentSessionStore + ?Sized,
1524{
1525    config: AgentConfig,
1526    client: Arc<C>,
1527    tools: Arc<T>,
1528    tool_scope: ToolScope,
1529    store: Arc<S>,
1530    session: Session,
1531    budget: Budget,
1532    retry_policy: RetryPolicy,
1533    depth: u32,
1534    pub(super) comms_runtime: Option<Arc<dyn CommsRuntime>>,
1535    pub(super) hook_engine: Option<Arc<dyn HookEngine>>,
1536    pub(super) hook_run_overrides: HookRunOverrides,
1537    /// Optional context compaction strategy.
1538    pub(crate) compactor: Option<Arc<dyn crate::compact::Compactor>>,
1539    /// Optional host-supplied compaction summary curator. When present it
1540    /// produces the compaction summary instead of the summarization LLM call.
1541    pub(crate) compaction_curator: Option<Arc<dyn crate::compact::CompactionCurator>>,
1542    /// Input tokens from the last LLM response (for compaction trigger).
1543    pub(crate) last_input_tokens: u64,
1544    /// Session-scoped compaction cadence tracked across runs.
1545    pub(crate) compaction_cadence: SessionCompactionCadence,
1546    /// Optional memory store for indexing compaction discards.
1547    pub(crate) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
1548    /// Runtime-owned resultful handoff for durable transcript+memory
1549    /// compaction pairs. Absent on standalone paths.
1550    pub(crate) compaction_commit_coordinator:
1551        Option<Arc<dyn crate::memory::CompactionCommitCoordinator>>,
1552    /// Typed lifecycle for the current transcript-rewrite + staged-memory
1553    /// transaction. Runtime reconciliation advances this to commit-only before
1554    /// touching the memory store; abort is legal only while runtime commit is
1555    /// still pending.
1556    pub(crate) compaction_transaction: Option<CompactionTransaction>,
1557    /// Deterministic projection identity installed immediately before the
1558    /// durable stage await. A hard interrupt can drop that await before a
1559    /// receipt reaches the transaction owner, so cleanup must retain the exact
1560    /// identity rather than infer empty RuntimeStore authority.
1561    pub(crate) in_flight_compaction_stage: Option<crate::memory::CompactionProjectionId>,
1562    /// Optional skill engine for per-turn `/skill-ref` activation.
1563    pub(crate) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
1564    /// Skill references to resolve and inject for the next turn.
1565    /// Set by surfaces before calling `run()`, consumed on run start.
1566    pub pending_skill_references: Option<Vec<crate::skills::SkillKey>>,
1567    /// Per-interaction event tap for streaming events to subscribers.
1568    pub(crate) event_tap: crate::event_tap::EventTap,
1569    /// Shared control state for runtime system-context appends.
1570    pub(crate) system_context_state: crate::session::SystemContextStateHandle,
1571    /// Optional default event channel configured at build time.
1572    /// Used by run methods when no per-call event channel is provided.
1573    pub(crate) default_event_tx: Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>>,
1574    /// Optional session checkpointer for keep-alive persistence.
1575    ///
1576    /// Wired by `AgentBuilder::with_checkpointer`, installed by
1577    /// `PersistentSessionService`, and consumed by
1578    /// `Agent::checkpoint_current_session`.
1579    pub(crate) checkpointer: Option<Arc<dyn crate::checkpoint::SessionCheckpointer>>,
1580    /// Optional blob store used to hydrate image refs at execution seams.
1581    pub(crate) blob_store: Option<Arc<dyn crate::BlobStore>>,
1582    /// Original error detail preserved from `terminalize_fatal_error` so
1583    /// `build_result` can include the actual failure message (e.g. the API
1584    /// error body) instead of only the generic terminal-cause description.
1585    pub(crate) terminal_error_detail: Option<String>,
1586    /// Structured metadata captured from that concrete error before the
1587    /// public result is normalized into `AgentError::TerminalFailure`.
1588    pub(crate) terminal_error_metadata: Option<crate::TurnErrorMetadata>,
1589    /// True once the current run has accepted `RunCompleted` hooks.
1590    pub(crate) run_completed_hooks_applied: bool,
1591    /// True once the current run's public `RunCompleted` event has been
1592    /// emitted. Extraction may continue afterward as a separate post-run phase.
1593    pub(crate) run_completed_event_emitted: bool,
1594    /// Comms intents that should be silently injected into the session
1595    /// without triggering an LLM turn. Matched against `InteractionContent::Request.intent`.
1596    #[allow(dead_code)] // Used by comms_impl when comms feature is enabled
1597    pub(crate) silent_comms_intents: Vec<String>,
1598    /// Optional shared lifecycle registry for async operations.
1599    pub(crate) ops_lifecycle: Option<Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>>,
1600    /// Optional completion feed for cursor-based completion delivery.
1601    pub(crate) completion_feed: Option<Arc<dyn crate::completion_feed::CompletionFeed>>,
1602    /// Shared epoch cursor state for runtime-backed cursor writeback.
1603    pub(crate) epoch_cursor_state: Option<Arc<crate::runtime_epoch::EpochCursorState>>,
1604    /// Local cursor into the completion feed — only the agent boundary advances this.
1605    pub(crate) applied_cursor: crate::completion_feed::CompletionSeq,
1606    /// Optional enrichment provider for completion display details.
1607    pub(crate) completion_enrichment:
1608        Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>>,
1609    /// Shared effective mob authority handle. Owned by the agent, passed to
1610    /// mob tools at construction for authorization reads. Updated by
1611    /// `apply_session_effects` after each tool batch as a derived projection
1612    /// of the canonical `session.build_state().mob_tool_authority_context`.
1613    pub(crate) mob_authority_handle:
1614        Option<Arc<std::sync::RwLock<crate::service::MobToolAuthorityContext>>>,
1615    /// Runtime-backed turn-state handle, provided by the session runtime bindings.
1616    pub(crate) turn_state_handle: Option<Arc<dyn crate::TurnStateHandle>>,
1617    /// Runtime-backed model-routing authority. Sticky fallback commits route
1618    /// through this handle in the compensated client/auth/machine transaction.
1619    pub(crate) model_routing_handle: Option<Arc<dyn crate::handles::ModelRoutingHandle>>,
1620    /// Runtime-owned durable sticky-fallback transaction coordinator.
1621    /// Standalone agents leave this absent and consume staged machine commits
1622    /// synchronously in-process.
1623    pub(crate) sticky_model_fallback_commit_coordinator:
1624        Option<Arc<dyn crate::handles::StickyModelFallbackCommitCoordinator>>,
1625    /// Saga state retained across cancellation while the supervised durable
1626    /// sticky-fallback transaction is in flight.
1627    pub(crate) pending_sticky_model_fallback_activation:
1628        Option<state::PendingStickyModelFallbackActivation>,
1629    /// Effective model registry captured by the construction pipeline.
1630    /// Fallback profile and limit truth is freshly resolved through this exact
1631    /// registry before it can reach the routing machine.
1632    pub(crate) effective_model_registry: Option<Arc<crate::ModelRegistry>>,
1633    /// Registry-minted facts for the active model. This replaces client-local
1634    /// capability/limit projections as the durable source used by later turns.
1635    pub(crate) active_model_profile: Option<crate::ModelProfileWitness>,
1636    /// True when the runtime control plane must stamp execution kind metadata.
1637    pub(crate) runtime_execution_kind_required: bool,
1638    /// Typed execution intent for the current run, when this turn is owned by
1639    /// the runtime control plane rather than a direct surface call.
1640    pub(crate) runtime_execution_kind: Option<crate::lifecycle::RuntimeExecutionKind>,
1641    /// Exact per-call witness that the core turn machine admitted a runtime
1642    /// run. A completed future alone is not sufficient evidence: preflight
1643    /// failures can return before `StartConversationRun` and must never reuse
1644    /// the previous turn's terminal snapshot.
1645    pub(crate) runtime_started_run_id: Option<crate::lifecycle::RunId>,
1646    /// Machine-terminal failure observed for the exact runtime run above.
1647    /// Kept separate from the public `AgentError` so direct session surfaces
1648    /// preserve their original typed errors while the runtime can commit a
1649    /// failed-but-applied turn atomically.
1650    pub(crate) runtime_terminal_failure_witness:
1651        Option<Result<crate::TurnErrorMetadata, crate::error::AgentError>>,
1652    /// Stable transcript identity for the active runtime-owned turn.
1653    pub(crate) active_transcript_identity: Option<crate::types::TranscriptMessageIdentity>,
1654    /// Runtime-backed external tool-surface diagnostic handle, when provided
1655    /// by the session runtime bindings.
1656    pub(crate) external_tool_surface_handle: Option<Arc<dyn crate::ExternalToolSurfaceHandle>>,
1657    /// Runtime-backed auth lease handle (Phase 1.5-rev).
1658    pub(crate) auth_lease_handle: Option<crate::handles::GeneratedAuthLeaseHandle>,
1659    /// Runtime-backed MCP server lifecycle handle (Phase 5G / T5g). When set,
1660    /// the agent loop reads `pending_server_ids()` at each CallingLlm boundary
1661    /// to decide whether to emit the `[MCP_PENDING]` system notice.
1662    pub(crate) mcp_server_lifecycle_handle:
1663        Option<Arc<dyn crate::handles::McpServerLifecycleHandle>>,
1664    /// Producer end of the typed cancel-after-boundary command channel.
1665    ///
1666    /// Retained so [`Agent::cancel_after_boundary_handle`] can hand cloned
1667    /// senders to the surface that requests boundary-only cancellation. The
1668    /// agent never sends on this end itself; it only drains the matching
1669    /// receiver at turn boundaries.
1670    pub(crate) cancel_after_boundary_tx: CancelAfterBoundarySender,
1671    /// Consumer end of the typed cancel-after-boundary command channel.
1672    ///
1673    /// Drained (non-blocking) at each turn boundary by
1674    /// `observe_cancel_after_boundary_request`, replacing the previous
1675    /// `.swap`-polled `AtomicBool`. A delivered [`CancelAfterBoundaryCommand`]
1676    /// is observed at most once per boundary, mirroring the prior edge
1677    /// semantics.
1678    pub(crate) cancel_after_boundary_rx:
1679        tokio::sync::mpsc::UnboundedReceiver<CancelAfterBoundaryCommand>,
1680    /// Optional resolver for model-specific operational defaults (e.g., call timeout).
1681    /// Consulted at each LLM call for hot-swap-aware profile default resolution.
1682    pub(crate) model_defaults_resolver:
1683        Option<Arc<dyn crate::model_defaults::ModelOperationalDefaultsResolver>>,
1684    /// Explicit call-timeout override from the build/config composition seam.
1685    /// Takes precedence over profile-derived defaults.
1686    pub(crate) call_timeout_override: crate::config::CallTimeoutOverride,
1687    /// Structured-output extraction state carried into RunResult.
1688    pub(crate) extraction_state: extraction::ExtractionState,
1689    /// Last published hidden deferred-catalog names.
1690    pub(crate) last_hidden_deferred_catalog_names: BTreeSet<crate::types::ToolName>,
1691    /// Last published pending catalog sources.
1692    pub(crate) last_pending_catalog_sources: BTreeSet<String>,
1693    /// Dispatch-time projection of the current turn input for contextual tools.
1694    pub(crate) tool_dispatch_context: ToolDispatchContext,
1695    /// Runtime-owned dispatch metadata for this turn.
1696    pub(crate) turn_tool_dispatch_metadata: BTreeMap<String, serde_json::Value>,
1697    /// Typed tool-execution policy (per-call timeouts + concurrency bound)
1698    /// applied to the normal LLM-driven tool dispatch loop. Populated by the
1699    /// composition seam via `AgentBuilder::with_tools_config`; defaults to
1700    /// `ToolsConfig::default()` for standalone/test construction.
1701    pub(crate) tools_config: crate::config::ToolsConfig,
1702}
1703
1704#[derive(Clone)]
1705pub(crate) struct CompactionRollbackState {
1706    pub(crate) rollback_session: Session,
1707    pub(crate) rollback_last_input_tokens: u64,
1708    pub(crate) rollback_compaction_cadence: SessionCompactionCadence,
1709}
1710
1711pub(crate) enum CompactionTransactionPhase {
1712    AwaitingRuntimeCommit(Box<CompactionRollbackState>),
1713    RuntimeCommitted { bookkeeping_complete: bool },
1714    AbortPending { cadence_persist_pending: bool },
1715}
1716
1717pub(crate) struct CompactionTransaction {
1718    pub(crate) phase: CompactionTransactionPhase,
1719    pub(crate) projections: Vec<crate::memory::CompactionProjectionId>,
1720}
1721
1722#[cfg(test)]
1723#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1724mod tests {
1725    use super::{
1726        AgentToolDispatcher, CommsRuntime, DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS,
1727        FilteredToolDispatcher, InlinePeerNotificationPolicy, ToolDispatchContext,
1728    };
1729    use crate::comms::{
1730        PeerAddress, PeerId, PeerName, PeerTransport, SendError, TrustedPeerDescriptor,
1731    };
1732    use crate::types::{ContentBlock, ContentInput, ToolCallView, ToolDef, ToolResult};
1733    use async_trait::async_trait;
1734    use serde_json::json;
1735    use std::sync::Arc;
1736    use tokio::sync::Notify;
1737
1738    struct NoopCommsRuntime {
1739        notify: Arc<Notify>,
1740    }
1741
1742    struct ContextAwareToolDispatcher;
1743
1744    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1745    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1746    impl AgentToolDispatcher for ContextAwareToolDispatcher {
1747        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
1748            Arc::from([Arc::new(ToolDef {
1749                name: "inspect_context".into(),
1750                description: "inspect context".to_string(),
1751                input_schema: json!({"type": "object"}),
1752                provenance: None,
1753            })])
1754        }
1755
1756        async fn dispatch(
1757            &self,
1758            call: ToolCallView<'_>,
1759        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1760            Ok(ToolResult::new(
1761                call.id.to_string(),
1762                json!({"saw_context_image": false}).to_string(),
1763                false,
1764            )
1765            .into())
1766        }
1767
1768        async fn dispatch_with_context(
1769            &self,
1770            call: ToolCallView<'_>,
1771            context: &ToolDispatchContext,
1772        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1773            let saw_context_image = context
1774                .current_turn()
1775                .and_then(|turn| turn.image_ref(0))
1776                .and_then(|image_ref| context.current_turn_image(image_ref))
1777                .is_some();
1778            Ok(ToolResult::new(
1779                call.id.to_string(),
1780                json!({"saw_context_image": saw_context_image}).to_string(),
1781                false,
1782            )
1783            .into())
1784        }
1785    }
1786
1787    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1788    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1789    impl CommsRuntime for NoopCommsRuntime {
1790        async fn drain_messages(&self) -> Vec<String> {
1791            Vec::new()
1792        }
1793
1794        fn inbox_notify(&self) -> std::sync::Arc<Notify> {
1795            self.notify.clone()
1796        }
1797    }
1798
1799    #[tokio::test]
1800    async fn test_comms_runtime_trait_defaults_hide_unimplemented_features() {
1801        let runtime = NoopCommsRuntime {
1802            notify: Arc::new(Notify::new()),
1803        };
1804        assert!(<NoopCommsRuntime as CommsRuntime>::public_key(&runtime).is_none());
1805        // The only mutable trust seam is apply_trust_mutation; without a
1806        // generated handoff it fails closed.
1807        let peer = TrustedPeerDescriptor {
1808            peer_id: PeerId::new(),
1809            name: PeerName::new("peer-a").expect("valid peer name"),
1810            address: PeerAddress::new(PeerTransport::Inproc, "peer-a"),
1811            pubkey: [0u8; 32],
1812        };
1813        let result =
1814            <NoopCommsRuntime as CommsRuntime>::add_private_trusted_peer(&runtime, peer).await;
1815        assert!(matches!(result, Err(SendError::Unsupported(_))));
1816    }
1817
1818    /// T-12: bridge-reply waiter + declared-reply-endpoint trait defaults.
1819    /// `take_bridge_reply_waiter` → None (no registry),
1820    /// `has_bridge_reply_waiter` → false, and
1821    /// `stage_declared_reply_endpoint` fails typed (never a silent no-op) so
1822    /// a caller cannot mistake a dropped security-relevant repair for success.
1823    #[tokio::test]
1824    async fn test_comms_runtime_bridge_reply_defaults() {
1825        let runtime = NoopCommsRuntime {
1826            notify: Arc::new(Notify::new()),
1827        };
1828        let interaction_id = crate::interaction::InteractionId(uuid::Uuid::new_v4());
1829        assert!(
1830            <NoopCommsRuntime as CommsRuntime>::take_bridge_reply_waiter(&runtime, &interaction_id)
1831                .is_none()
1832        );
1833        assert!(
1834            !<NoopCommsRuntime as CommsRuntime>::has_bridge_reply_waiter(&runtime, &interaction_id)
1835        );
1836        let staged = <NoopCommsRuntime as CommsRuntime>::stage_declared_reply_endpoint(
1837            &runtime,
1838            PeerId::new(),
1839            [0x11u8; 32],
1840            "tcp://127.0.0.1:1".to_string(),
1841        )
1842        .await;
1843        assert!(matches!(staged, Err(SendError::Unsupported(_))));
1844    }
1845
1846    #[tokio::test]
1847    async fn filtered_tool_dispatcher_preserves_dispatch_context() {
1848        let dispatcher =
1849            FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), ["inspect_context"]);
1850        let args = serde_json::value::RawValue::from_string("{}".to_string())
1851            .expect("empty object should be valid JSON");
1852        let call = ToolCallView {
1853            id: "ctx-1",
1854            name: "inspect_context",
1855            args: &args,
1856        };
1857        let context = ToolDispatchContext::from_current_turn_input(&ContentInput::Blocks(vec![
1858            ContentBlock::Image {
1859                media_type: "image/png".to_string(),
1860                data: "abc".into(),
1861            },
1862        ]));
1863
1864        let outcome = dispatcher
1865            .dispatch_with_context(call, &context)
1866            .await
1867            .expect("filtered wrapper should dispatch");
1868        let payload: serde_json::Value =
1869            serde_json::from_str(&outcome.result.text_content()).expect("tool result JSON");
1870        assert_eq!(payload["saw_context_image"], true);
1871    }
1872
1873    #[test]
1874    fn test_inline_peer_notification_policy_from_raw() {
1875        assert_eq!(
1876            InlinePeerNotificationPolicy::try_from_raw(None),
1877            Ok(InlinePeerNotificationPolicy::AtMost(
1878                DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS
1879            ))
1880        );
1881        assert_eq!(
1882            InlinePeerNotificationPolicy::try_from_raw(Some(-1)),
1883            Ok(InlinePeerNotificationPolicy::Always)
1884        );
1885        assert_eq!(
1886            InlinePeerNotificationPolicy::try_from_raw(Some(0)),
1887            Ok(InlinePeerNotificationPolicy::Never)
1888        );
1889        assert_eq!(
1890            InlinePeerNotificationPolicy::try_from_raw(Some(25)),
1891            Ok(InlinePeerNotificationPolicy::AtMost(25))
1892        );
1893        assert_eq!(
1894            InlinePeerNotificationPolicy::try_from_raw(Some(-42)),
1895            Err(-42)
1896        );
1897    }
1898
1899    /// UNIT-002: DetachedOpCompletion serializes without operation_id.
1900    /// The app-facing control noun is job_id (CONTRACT-003).
1901    #[test]
1902    fn unit_002_detached_op_completion_has_no_operation_id() {
1903        use crate::agent::DetachedOpCompletion;
1904        use crate::ops_lifecycle::{OperationKind, OperationStatus};
1905
1906        let completion = DetachedOpCompletion {
1907            job_id: "j_test".into(),
1908            kind: OperationKind::BackgroundToolOp,
1909            status: OperationStatus::Completed,
1910            terminal_outcome: None,
1911            display_name: "test cmd".into(),
1912            detail: "ok".into(),
1913            elapsed_ms: None,
1914        };
1915        #[allow(clippy::unwrap_used)]
1916        let json = serde_json::to_value(&completion).unwrap();
1917        assert!(
1918            json.get("operation_id").is_none(),
1919            "operation_id must not appear in serialized DetachedOpCompletion (CONTRACT-003)"
1920        );
1921        assert!(
1922            json.get("job_id").is_some(),
1923            "job_id must be the app-facing control noun"
1924        );
1925    }
1926}