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::{AgentControlStateError, AgentRunner, SnapshotProjectionError};
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    /// Measure the exact provider-lowered JSON request body for this invocation.
75    ///
76    /// The default is deliberately unavailable. Provider adapters that can
77    /// reproduce their stream request lowering return a witness; custom
78    /// clients retain existing behavior without pretending an estimate is
79    /// exact.
80    fn request_pressure(
81        &self,
82        _messages: &[Message],
83        _tools: &[Arc<ToolDef>],
84        _max_tokens: u32,
85        _temperature: Option<f32>,
86        _provider_params: Option<&ProviderParamsOverride>,
87    ) -> Result<Option<crate::ProviderRequestPressure>, AgentError> {
88        Ok(None)
89    }
90
91    /// Get the typed catalog provider identity for this client.
92    ///
93    /// Clients return the typed [`crate::provider::Provider`] directly so no
94    /// boundary ever parses a caller-supplied string back into catalog
95    /// identity. String projections are derived via
96    /// [`crate::provider::Provider::as_str`].
97    fn provider(&self) -> crate::provider::Provider;
98
99    /// Get the current effective model identifier.
100    ///
101    /// Used by the agent loop for profile-default resolution (e.g., call timeout
102    /// defaults that vary per model family). Must reflect the current model even
103    /// after hot-swap.
104    fn model(&self) -> &str;
105
106    /// Prepare the next prebuilt fallback model after the generated turn
107    /// authority has classified the LLM failure as recoverable.
108    ///
109    /// This method does not classify failures and must not call the provider.
110    /// It only selects an already-constructed candidate and returns the typed
111    /// state the agent loop must apply before the retry attempt.
112    fn prepare_model_fallback(&self, _failure: &AgentError) -> Option<AgentLlmFallbackSwitch> {
113        None
114    }
115
116    /// Move the client-local active candidate from `previous_identity` to the
117    /// exact `target_identity` as one reversible transaction step.
118    ///
119    /// The core loop invokes this only after every target-dependent operation
120    /// (including target-provider schema compilation) has been prevalidated,
121    /// but before auth/session/machine state is committed. Implementations must
122    /// either perform the exact switch or return an error. The default fails
123    /// closed so a custom client cannot propose a fallback while silently
124    /// continuing to issue requests through its old provider client.
125    ///
126    /// Core verifies [`AgentLlmClient::active_model_fallback_identity`] after
127    /// the call and invokes this method in reverse if a later transaction step
128    /// fails.
129    fn commit_model_fallback(
130        &self,
131        _previous_identity: &crate::SessionLlmIdentity,
132        target_identity: &crate::SessionLlmIdentity,
133    ) -> Result<(), AgentError> {
134        Err(AgentError::ConfigError(format!(
135            "LLM client proposed fallback target '{}:{}' without an activation implementation",
136            target_identity.provider.as_str(),
137            target_identity.model
138        )))
139    }
140
141    /// Exact identity of the client-local active fallback candidate.
142    ///
143    /// Fallback-capable clients must expose the full session identity,
144    /// including auth binding and provider parameters. The default is absent,
145    /// which makes fallback activation fail closed before canonical state is
146    /// mutated.
147    fn active_model_fallback_identity(&self) -> Option<crate::SessionLlmIdentity> {
148        None
149    }
150
151    /// Compile an extraction schema against an inactive fallback target.
152    ///
153    /// This must delegate to the exact prebuilt target client without changing
154    /// which client is active. Core calls it before auth, machine, visibility,
155    /// session, or client activation state is mutated, then injects the
156    /// compiled representation into the target provider request.
157    fn compile_model_fallback_schema(
158        &self,
159        target_identity: &crate::SessionLlmIdentity,
160        _output_schema: &OutputSchema,
161    ) -> Result<CompiledSchema, AgentError> {
162        Err(AgentError::ConfigError(format!(
163            "LLM client cannot compile structured output for fallback target '{}:{}'",
164            target_identity.provider.as_str(),
165            target_identity.model
166        )))
167    }
168
169    /// Reset per-call observation of user-visible streaming output.
170    ///
171    /// Adapters that emit display/reasoning deltas before returning the final
172    /// stream result use this to let the retry loop distinguish a pre-stream
173    /// failure from a post-partial-output failure. The default is no-op for
174    /// clients that do not stream visible events outside the returned blocks.
175    fn begin_stream_output_observation(&self) {}
176
177    /// Whether the current LLM call has emitted user-visible streaming output.
178    ///
179    /// A `true` value suppresses model fallback for the failed call: retrying
180    /// against a different model after users already saw partial output can
181    /// produce duplicate assistant answers. Ordinary same-model retry policy is
182    /// still governed by the generated turn recovery authority.
183    fn stream_output_observed(&self) -> bool {
184        false
185    }
186
187    /// Monotonic count of raw provider stream events observed by this client.
188    ///
189    /// This feeds the agent loop's stream-inactivity watchdog
190    /// (`RetryPolicy::stream_inactivity_timeout`): the loop snapshots the
191    /// count around each stream-event window and treats "no change" as a
192    /// silent stream. Clients that consume a provider event stream should bump
193    /// the count on every received event — including non-visible ones — so
194    /// liveness is distinct from visible output
195    /// ([`Self::stream_output_observed`]).
196    ///
197    /// `None` (the default) means this client does not report stream liveness
198    /// and the watchdog is disabled for its calls; only the hard call/turn
199    /// timeouts apply. This fails open on purpose: a non-streaming custom
200    /// client would otherwise look permanently silent and be killed while
201    /// healthy.
202    fn stream_activity_count(&self) -> Option<u64> {
203        None
204    }
205
206    /// Compile an output schema for this provider.
207    ///
208    /// Default implementation normalizes the schema without provider-specific lowering.
209    /// Adapters override this to apply provider-specific transformations (e.g.,
210    /// Anthropic adds `additionalProperties: false`, Gemini strips unsupported keywords).
211    fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
212        // Default passthrough: normalized clone, no provider-specific lowering
213        Ok(CompiledSchema {
214            schema: output_schema.schema.as_value().clone(),
215            warnings: Vec::new(),
216        })
217    }
218}
219
220/// Hook for wrapping the final agent-facing LLM client.
221///
222/// Factories and runtimes apply this after provider/raw-client adaptation so
223/// embedders can compose cross-cutting behavior without provider-specific
224/// registry hooks.
225pub type AgentLlmClientDecorator =
226    Arc<dyn Fn(Arc<dyn AgentLlmClient>) -> Arc<dyn AgentLlmClient> + Send + Sync + 'static>;
227
228/// One fallback target skipped while selecting a viable backup model.
229#[derive(Debug, Clone)]
230pub struct AgentLlmFallbackSkippedTarget {
231    pub identity: crate::SessionLlmIdentity,
232    pub reason: String,
233}
234
235/// Typed state produced when an agent-facing LLM client activates a fallback.
236///
237/// The client owns only prebuilt candidate selection. The agent loop owns
238/// applying request policy, durable identity metadata, and tool visibility
239/// before issuing the machine-authorized retry.
240#[derive(Debug, Clone)]
241pub struct AgentLlmFallbackSwitch {
242    pub previous_identity: crate::SessionLlmIdentity,
243    pub new_identity: crate::SessionLlmIdentity,
244    pub request_policy: crate::SessionLlmRequestPolicy,
245    /// Proposed effective-registry witness for the exact target provider/model.
246    /// Core rejects foreign authority and freshly resolves all capability and
247    /// token-limit facts through the agent's captured registry. The witness is
248    /// required: unresolved fallback targets fail closed.
249    pub target_profile: crate::ModelProfileWitness,
250    pub skipped_targets: Vec<AgentLlmFallbackSkippedTarget>,
251}
252
253/// One-shot authorization for an exact sticky model-fallback activation.
254///
255/// There is deliberately no public constructor and the fields are private.
256/// The constructor is owned by the `agent` module, so only the core agent loop
257/// can mint this value after generated recovery acceptance and exact
258/// effective-registry validation. A public
259/// [`crate::handles::ModelRoutingHandle`] therefore cannot be driven directly
260/// with a caller-minted or foreign-registry profile.
261///
262/// ```compile_fail
263/// use meerkat_core::StickyModelFallbackActivationProof;
264///
265/// // Routing callers cannot fabricate an activation proof.
266/// let _proof = StickyModelFallbackActivationProof::new();
267/// ```
268pub struct StickyModelFallbackActivationProof {
269    previous_identity: crate::SessionLlmIdentity,
270    target_identity: crate::SessionLlmIdentity,
271    target_profile: crate::ModelProfileWitness,
272    target_capability_base_filter: crate::ToolFilter,
273    retry_attempt: u32,
274}
275
276impl StickyModelFallbackActivationProof {
277    fn new(
278        previous_identity: crate::SessionLlmIdentity,
279        target_identity: crate::SessionLlmIdentity,
280        target_profile: crate::ModelProfileWitness,
281        retry_attempt: u32,
282    ) -> Self {
283        let target_capability_base_filter = crate::capability_base_filter_for_image_tool_results(
284            target_profile.profile().image_tool_results,
285        );
286        Self {
287            previous_identity,
288            target_identity,
289            target_profile,
290            target_capability_base_filter,
291            retry_attempt,
292        }
293    }
294
295    /// Exact identity the generated recovery transition must still own.
296    pub fn previous_identity(&self) -> &crate::SessionLlmIdentity {
297        &self.previous_identity
298    }
299
300    /// Exact registry-resolved identity being activated.
301    pub fn target_identity(&self) -> &crate::SessionLlmIdentity {
302        &self.target_identity
303    }
304
305    /// Registry-owned target profile carried by this authorization.
306    pub fn target_profile(&self) -> &crate::ModelProfileWitness {
307        &self.target_profile
308    }
309
310    /// Registry-derived capability filter for the target model.
311    pub fn target_capability_base_filter(&self) -> &crate::ToolFilter {
312        &self.target_capability_base_filter
313    }
314
315    /// Machine-accepted retry attempt bound into this authorization.
316    pub fn retry_attempt(&self) -> u32 {
317        self.retry_attempt
318    }
319}
320
321/// Result of streaming from the LLM
322pub struct LlmStreamResult {
323    blocks: Vec<AssistantBlock>,
324    stop_reason: StopReason,
325    usage: Usage,
326}
327
328impl LlmStreamResult {
329    pub fn new(blocks: Vec<AssistantBlock>, stop_reason: StopReason, usage: Usage) -> Self {
330        Self {
331            blocks,
332            stop_reason,
333            usage,
334        }
335    }
336
337    pub fn blocks(&self) -> &[AssistantBlock] {
338        &self.blocks
339    }
340    pub fn stop_reason(&self) -> StopReason {
341        self.stop_reason
342    }
343    pub fn usage(&self) -> &Usage {
344        &self.usage
345    }
346
347    pub fn into_message(self) -> BlockAssistantMessage {
348        BlockAssistantMessage::new(self.blocks, self.stop_reason)
349    }
350
351    pub fn into_parts(self) -> (Vec<AssistantBlock>, StopReason, Usage) {
352        (self.blocks, self.stop_reason, self.usage)
353    }
354}
355
356/// Snapshot of the core agent's live execution state.
357///
358/// When a runtime-backed turn-state handle is attached, this snapshots the
359/// runtime-owned turn machine; otherwise it falls back to the in-process
360/// standalone turn state used by core-only execution.
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct AgentExecutionSnapshot {
363    pub loop_state: LoopState,
364    pub turn_phase: TurnPhase,
365    /// Machine-owned turn-terminality verdict.
366    ///
367    /// The `TurnTerminalityClassified.terminal` verdict emitted by the canonical
368    /// MeerkatMachine `ClassifyTurnTerminality` input. Consumers mirror this bool
369    /// and must not reclassify [`TurnPhase`] locally.
370    pub turn_terminal: bool,
371    pub active_run_id: Option<RunId>,
372    pub terminal_run_id: Option<RunId>,
373    pub primitive_kind: TurnPrimitiveKind,
374    pub admitted_content_shape: Option<ContentShape>,
375    pub vision_enabled: bool,
376    pub image_tool_results_enabled: bool,
377    pub tool_calls_pending: u32,
378    pub pending_operation_ids: Option<Vec<OperationId>>,
379    pub barrier_operation_ids: Vec<OperationId>,
380    pub has_barrier_ops: bool,
381    pub barrier_satisfied: bool,
382    pub boundary_count: u32,
383    pub cancel_after_boundary: bool,
384    pub terminal_outcome: TurnTerminalOutcome,
385    pub terminal_cause_kind: Option<TurnTerminalCauseKind>,
386    pub extraction_attempts: u32,
387    pub max_extraction_retries: u32,
388    pub applied_cursor: CompletionSeq,
389}
390
391/// Result of polling for external tool updates.
392///
393/// Returned by [`AgentToolDispatcher::poll_external_updates`].
394#[derive(Debug, Clone, Default)]
395pub struct ExternalToolUpdate {
396    /// Notices about completed background operations since last poll.
397    pub notices: Vec<ExternalToolDelta>,
398    /// Names of servers still connecting in the background.
399    pub pending: Vec<String>,
400}
401
402/// Typed command requesting cancellation at the next turn boundary.
403///
404/// Carried over the cancel-after-boundary command channel from the surface
405/// that authorized the request (e.g. `SessionService::cancel_after_boundary`)
406/// to the agent loop, which observes it at the next boundary. The agent
407/// resolves the request against its own live active run. The exact run witness
408/// prevents a delayed request from an old executor attachment from cancelling
409/// a successor run after same-session replacement.
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct CancelAfterBoundaryCommand {
412    expected_run_id: RunId,
413}
414
415impl CancelAfterBoundaryCommand {
416    /// Bind a cooperative-cancel command to one exact run incarnation.
417    pub fn for_run(expected_run_id: RunId) -> Self {
418        Self { expected_run_id }
419    }
420
421    /// Exact run incarnation this command is authorized to affect.
422    pub fn expected_run_id(&self) -> &RunId {
423        &self.expected_run_id
424    }
425}
426
427/// Producer end of the cancel-after-boundary command channel.
428///
429/// Cloned and handed to the requesting surface via
430/// [`Agent::cancel_after_boundary_handle`]; mirrors the cloneable-handle shape
431/// of the session-side `interrupt_notify` so a surface can request boundary
432/// cancellation without holding a reference to the agent.
433pub type CancelAfterBoundarySender = tokio::sync::mpsc::UnboundedSender<CancelAfterBoundaryCommand>;
434
435/// Typed context supplied by the agent loop when dispatching a tool call.
436///
437/// This is a dispatch-time projection of the already-admitted turn input. It
438/// lets tool surfaces resolve typed turn-scoped references, such as a
439/// `source=current_turn, index=0` image ref, without writing surface-local
440/// metadata into canonical transcript history.
441#[derive(Debug, Clone, Default, PartialEq, Eq)]
442pub struct ToolDispatchContext {
443    current_turn: Option<CurrentTurnContent>,
444    turn_metadata: BTreeMap<String, serde_json::Value>,
445    origin_session_id: Option<crate::types::SessionId>,
446    interaction_lineage_id: Option<crate::interaction::InteractionId>,
447    streaming: Option<crate::ToolStreamingDispatchContext>,
448}
449
450/// Dispatch-context key carrying the current durable objective id.
451pub const TOOL_DISPATCH_OBJECTIVE_ID_KEY: &str = "meerkat.objective_id";
452
453impl ToolDispatchContext {
454    pub fn from_current_turn_input(input: &crate::types::ContentInput) -> Self {
455        let blocks = match input {
456            crate::types::ContentInput::Text(_) => None,
457            crate::types::ContentInput::Blocks(blocks) => Some(blocks.clone()),
458        };
459        Self {
460            current_turn: blocks.map(CurrentTurnContent::new),
461            turn_metadata: BTreeMap::new(),
462            origin_session_id: None,
463            interaction_lineage_id: None,
464            streaming: None,
465        }
466    }
467
468    /// Project the typed run input into a dispatch context. The
469    /// pending-tool-results continuation carries no caller content, so it
470    /// projects to an empty context rather than a fabricated empty prompt.
471    pub fn from_run_input(input: &crate::types::RunInput) -> Self {
472        match input {
473            crate::types::RunInput::Content { content } => Self::from_current_turn_input(content),
474            crate::types::RunInput::PendingToolResults => Self::default(),
475        }
476    }
477
478    #[must_use]
479    pub fn with_turn_metadata(mut self, metadata: BTreeMap<String, serde_json::Value>) -> Self {
480        self.turn_metadata = metadata;
481        self
482    }
483
484    pub fn turn_metadata(&self, key: &str) -> Option<&serde_json::Value> {
485        self.turn_metadata.get(key)
486    }
487
488    pub fn current_turn(&self) -> Option<&CurrentTurnContent> {
489        self.current_turn.as_ref()
490    }
491
492    /// Bind the runtime-owned durable identity of the turn being dispatched.
493    ///
494    /// Standalone callers may leave this absent. Durable execution owners must
495    /// fail closed rather than minting replacement identity at dispatch time.
496    #[must_use]
497    pub fn with_runtime_identity(
498        mut self,
499        origin_session_id: crate::types::SessionId,
500        interaction_lineage_id: Option<crate::interaction::InteractionId>,
501    ) -> Self {
502        self.origin_session_id = Some(origin_session_id);
503        self.interaction_lineage_id = interaction_lineage_id;
504        self
505    }
506
507    pub fn origin_session_id(&self) -> Option<&crate::types::SessionId> {
508        self.origin_session_id.as_ref()
509    }
510
511    pub const fn interaction_lineage_id(&self) -> Option<crate::interaction::InteractionId> {
512        self.interaction_lineage_id
513    }
514
515    /// Streaming-only liveness surface minted by the canonical supervisor.
516    ///
517    /// Fast and detached dispatch contexts carry no streaming surface. A tool
518    /// that declared `Streaming` must fail closed if this is absent rather than
519    /// fabricating a progress sink or cancellation authority.
520    pub const fn streaming(&self) -> Option<&crate::ToolStreamingDispatchContext> {
521        self.streaming.as_ref()
522    }
523
524    pub(crate) fn with_streaming(mut self, streaming: crate::ToolStreamingDispatchContext) -> Self {
525        self.streaming = Some(streaming);
526        self
527    }
528
529    pub fn current_turn_image(
530        &self,
531        image_ref: CurrentTurnImageRef,
532    ) -> Option<&crate::types::ContentBlock> {
533        self.current_turn
534            .as_ref()
535            .and_then(|current_turn| current_turn.image(image_ref))
536    }
537}
538
539/// Typed reference to an image in the current admitted turn.
540///
541/// The wrapped index addresses the turn's *filtered image stream*, not the
542/// raw block list: ref `N` designates the `(N + 1)`-th image block of the
543/// current turn, skipping non-image blocks (so ref `0` is the first image
544/// even when text blocks precede it).
545///
546/// The field is private. In-process code mints refs only via
547/// [`CurrentTurnContent::image_ref`], which returns a ref only when the
548/// referenced image exists. Wire ingress (e.g. the comms `image_ref` tool
549/// input) deserializes a bare JSON integer directly into this type via
550/// `#[serde(transparent)]` — that is the sanctioned parse-at-ingress path,
551/// and resolution through [`CurrentTurnContent::image`] still validates
552/// existence.
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
554#[serde(transparent)]
555pub struct CurrentTurnImageRef(usize);
556
557impl std::fmt::Display for CurrentTurnImageRef {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        std::fmt::Display::fmt(&self.0, f)
560    }
561}
562
563/// Multimodal content from the currently admitted turn.
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct CurrentTurnContent {
566    blocks: Vec<crate::types::ContentBlock>,
567}
568
569impl CurrentTurnContent {
570    pub fn new(blocks: Vec<crate::types::ContentBlock>) -> Self {
571        Self { blocks }
572    }
573
574    pub fn blocks(&self) -> &[crate::types::ContentBlock] {
575        &self.blocks
576    }
577
578    /// Mint a typed reference to the `n`-th image of this turn's filtered
579    /// image stream. Returns `Some` only when that image exists, so every
580    /// in-process [`CurrentTurnImageRef`] is resolvable at mint time.
581    pub fn image_ref(&self, n: usize) -> Option<CurrentTurnImageRef> {
582        self.images().nth(n).map(|_| CurrentTurnImageRef(n))
583    }
584
585    pub fn image(&self, image_ref: CurrentTurnImageRef) -> Option<&crate::types::ContentBlock> {
586        self.images().nth(image_ref.0)
587    }
588
589    fn images(&self) -> impl Iterator<Item = &crate::types::ContentBlock> {
590        self.blocks
591            .iter()
592            .filter(|block| matches!(block, crate::types::ContentBlock::Image { .. }))
593    }
594}
595
596/// Completion notice for a detached background operation, projected from
597/// canonical ops-lifecycle terminal state plus dispatcher-owned display metadata.
598///
599/// This is a rebuildable projection (INV-003), not authoritative state.
600/// Terminal class and timing come from `OperationLifecycleSnapshot` (INV-001).
601/// Shell-projected detail is supplementary display only (INV-002).
602#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct DetachedOpCompletion {
604    /// App-facing job identifier (the control noun for surfaces).
605    pub job_id: String,
606    /// Operation kind from canonical ops-lifecycle.
607    pub kind: OperationKind,
608    /// Terminal status from canonical ops-lifecycle.
609    pub status: OperationStatus,
610    /// Terminal outcome from canonical ops-lifecycle.
611    pub terminal_outcome: Option<OperationTerminalOutcome>,
612    /// Canonical display label from ops-lifecycle snapshot.
613    pub display_name: String,
614    /// Dispatcher-projected summary (exit code, output tail). Display only.
615    pub detail: String,
616    /// Monotonic elapsed millis from ops-lifecycle snapshot.
617    pub elapsed_ms: Option<u64>,
618}
619
620/// Dispatcher binding capabilities — what optional bindings this dispatcher supports.
621///
622/// Returned by [`AgentToolDispatcher::capabilities`]. Replaces individual
623/// `supports_*` boolean methods with a single structured query.
624#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
625pub struct DispatcherCapabilities {
626    /// Whether `bind_ops_lifecycle` is implemented.
627    pub ops_lifecycle: bool,
628}
629
630/// Result of a dispatcher binding operation.
631///
632/// Distinguishes "binding was applied" from "binding was skipped" so callers
633/// can decide whether to wire downstream side effects (e.g. bridge tasks).
634///
635/// **Semantics (decision 11 — supported/best-effort/rejected):**
636/// - `Ok(Bound(d))` = **supported** — binding succeeded, side effects should be wired
637/// - `Ok(Skipped(d))` = **best-effort** — inner shared or incompatible, dispatcher unchanged
638/// - `Err(SharedOwnership)` = **rejected** — outer wrapper is shared, caught by factory pre-check
639/// - `Err(Unsupported)` = **rejected** — type doesn't support this binding, caught by `capabilities()`
640pub enum BindOutcome {
641    /// Binding was applied. The dispatcher was rebound.
642    Bound(Arc<dyn AgentToolDispatcher>),
643    /// Binding was skipped — inner dispatcher was shared or unsupported.
644    /// The returned dispatcher is unchanged but safe to use.
645    Skipped(Arc<dyn AgentToolDispatcher>),
646}
647
648impl BindOutcome {
649    /// Extract the dispatcher, regardless of bind status.
650    pub fn into_dispatcher(self) -> Arc<dyn AgentToolDispatcher> {
651        match self {
652            Self::Bound(d) | Self::Skipped(d) => d,
653        }
654    }
655
656    /// Whether the binding was actually applied.
657    pub fn was_bound(&self) -> bool {
658        matches!(self, Self::Bound(_))
659    }
660}
661
662/// Trait for tool dispatchers
663#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
664#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
665pub trait AgentToolDispatcher: Send + Sync {
666    /// Get available tool definitions
667    fn tools(&self) -> Arc<[Arc<ToolDef>]>;
668
669    /// Query exact catalog support for this dispatcher.
670    ///
671    /// Dispatchers report `exact_catalog=true` only when `tool_catalog()`
672    /// returns the exact precedence-resolved winner registry for the plane
673    /// they own. Wrappers that cannot prove exactness must leave this false.
674    fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
675        ToolCatalogCapabilities::default()
676    }
677
678    /// Return the precedence-resolved tool catalog for this dispatcher.
679    ///
680    /// The default implementation mirrors `tools()` as a visible-only inline
681    /// catalog. Callers must gate any deferred-catalog behavior on
682    /// `tool_catalog_capabilities().exact_catalog`.
683    fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
684        self.tools()
685            .iter()
686            .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
687            .collect::<Vec<_>>()
688            .into()
689    }
690
691    /// Live generation for one logical tool binding.
692    ///
693    /// Static dispatchers keep the default zero epoch. Mutable authorities
694    /// must override this and advance it for every replacement, including an
695    /// identical-metadata A→B replacement.
696    fn execution_binding_epoch(&self, _tool_name: &str) -> u64 {
697        0
698    }
699
700    /// Snapshot the current live logical binding advertised for `tool_name`.
701    fn execution_binding_fingerprint(
702        &self,
703        tool_name: &str,
704    ) -> Result<crate::EphemeralToolBindingFingerprint, crate::ToolExecutionResolutionError> {
705        let catalog = self.tool_catalog();
706        let entry = catalog
707            .iter()
708            .find(|entry| entry.tool.name == tool_name)
709            .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
710                tool_name: tool_name.to_string(),
711            })?;
712        Ok(crate::ephemeral_tool_catalog_binding_fingerprint(entry)
713            .with_live_authority(0, self.execution_binding_epoch(tool_name)))
714    }
715
716    /// Resolve the exact execution class and deadline chain before dispatch.
717    ///
718    /// The default uses this dispatcher's effective catalog, so wrappers that
719    /// filter or select winners apply the same decision to declaration and
720    /// resolution. Hybrid tools may override this method to inspect typed
721    /// arguments while preserving the catalog contract as the upper bound.
722    fn resolve_execution_plan(
723        &self,
724        call: ToolCallView<'_>,
725        _dispatch_context: &ToolDispatchContext,
726        resolution_context: &crate::ToolExecutionResolutionContext,
727    ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
728        let catalog = self.tool_catalog();
729        let entry = catalog
730            .iter()
731            .find(|entry| entry.tool.name == call.name)
732            .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
733                tool_name: call.name.to_string(),
734            })?;
735        if let Some(reason) = entry.callability.unavailable_reason() {
736            return Err(crate::ToolExecutionResolutionError::Unavailable {
737                tool_name: call.name.to_string(),
738                reason,
739            });
740        }
741        entry
742            .execution
743            .resolve_default(resolution_context.deadlines().clone())
744            .map_err(crate::ToolExecutionResolutionError::from)
745    }
746
747    /// Validate a resolved plan against both the caller-owned deadline prefix
748    /// and this dispatcher's live advertised catalog contract.
749    ///
750    /// This is the mandatory root seam after argument-sensitive resolution:
751    /// hybrid tools may select any advertised mode, while an override cannot
752    /// return a mode or mode-derived facet absent from the effective catalog.
753    fn validate_resolved_execution_plan(
754        &self,
755        call: ToolCallView<'_>,
756        resolution_context: &crate::ToolExecutionResolutionContext,
757        plan: &crate::ResolvedToolExecutionPlan,
758    ) -> Result<(), crate::ToolExecutionResolutionError> {
759        resolution_context.validate_resolved_plan(plan)?;
760        let catalog = self.tool_catalog();
761        let entry = catalog
762            .iter()
763            .find(|entry| entry.tool.name == call.name)
764            .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
765                tool_name: call.name.to_string(),
766            })?;
767        if let Some(reason) = entry.callability.unavailable_reason() {
768            return Err(crate::ToolExecutionResolutionError::Unavailable {
769                tool_name: call.name.to_string(),
770                reason,
771            });
772        }
773        entry
774            .execution
775            .validate_resolved_plan(plan)
776            .map_err(crate::ToolExecutionResolutionError::from)
777    }
778
779    /// Return non-draining pending source names for exact-catalog discovery.
780    ///
781    /// Pending sources are catalog-level discovery metadata rather than
782    /// provider-visible tools. The default implementation reports none.
783    fn pending_catalog_sources(&self) -> Arc<[String]> {
784        Arc::from([])
785    }
786
787    /// Execute a tool call, returning the transcript result and any async operations.
788    ///
789    /// The `ToolDispatchOutcome` separates transcript data (`result`) from
790    /// execution metadata (`async_ops`). Most tools return no async ops;
791    /// use `ToolDispatchOutcome::from(result)` for synchronous tools.
792    async fn dispatch(
793        &self,
794        call: ToolCallView<'_>,
795    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError>;
796
797    /// Execute a tool call with the current turn's typed dispatch context.
798    ///
799    /// Most tools do not need turn-local context and inherit the plain
800    /// `dispatch` behavior. Context-sensitive surfaces override this method
801    /// rather than reaching into session history or prompt text.
802    async fn dispatch_with_context(
803        &self,
804        call: ToolCallView<'_>,
805        _context: &ToolDispatchContext,
806    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
807        self.dispatch(call).await
808    }
809
810    /// Execute a previously resolved plan without re-selecting its mode.
811    ///
812    /// The default is deliberately a one-way lowering for Fast calls only.
813    /// Streaming and Detached require an explicit mode owner; silently sending
814    /// either through ordinary dispatch would erase their liveness, output,
815    /// restart, and idempotency contracts.
816    async fn dispatch_resolved_with_context(
817        &self,
818        call: ToolCallView<'_>,
819        context: &ToolDispatchContext,
820        plan: &crate::ResolvedToolExecutionPlan,
821    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
822        match plan.mode() {
823            crate::ToolExecutionMode::Fast => self.dispatch_with_context(call, context).await,
824            crate::ToolExecutionMode::Streaming | crate::ToolExecutionMode::Detached => {
825                Err(crate::error::ToolError::unavailable(
826                    call.name,
827                    crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
828                ))
829            }
830        }
831    }
832
833    /// Poll for external tool updates from background operations (e.g. async MCP loading).
834    ///
835    /// The default implementation returns an empty update. Implementations that
836    /// support background tool loading (like `McpRouterAdapter`) override this
837    /// to drain completed results and report pending servers.
838    async fn poll_external_updates(&self) -> ExternalToolUpdate {
839        ExternalToolUpdate::default()
840    }
841
842    /// Snapshot the live external tool-surface machine state, if supported.
843    ///
844    /// This is a hidden diagnostic surface for MeerkatMachine mapping work.
845    /// Dispatchers that do not own dynamic external tool mutation should
846    /// return `None`.
847    fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
848        None
849    }
850
851    /// Query which optional bindings this dispatcher supports.
852    fn capabilities(&self) -> DispatcherCapabilities {
853        DispatcherCapabilities::default()
854    }
855
856    /// Bind a session-canonical ops registry into this dispatcher.
857    ///
858    /// Dispatchers that emit session-visible `AsyncOpRef`s must route those
859    /// operation IDs into the bound registry. Under the identity-first Mob
860    /// regime the owner binding passed here is the canonical bridge session
861    /// binding, even though many compatibility surfaces still spell it
862    /// `session_id`. Default returns Unsupported.
863    fn bind_ops_lifecycle(
864        self: Arc<Self>,
865        _registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
866        _owner_bridge_session_id: crate::types::SessionId,
867    ) -> Result<BindOutcome, OpsLifecycleBindError> {
868        Err(OpsLifecycleBindError::Unsupported)
869    }
870
871    /// Return the completion enrichment provider, if available.
872    ///
873    /// Dispatchers with shell job management return a provider that maps
874    /// operation IDs to display details (job ID, status detail string).
875    fn completion_enrichment(
876        &self,
877    ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
878        None
879    }
880
881    /// Bind a session-scoped MCP server lifecycle handle (Phase 5G / T5g).
882    ///
883    /// Dispatchers that manage per-server MCP handshake lifecycle (like
884    /// `McpRouterAdapter`) use the handle to mirror connection state into
885    /// the session's MeerkatMachine DSL. The default implementation is a
886    /// no-op for dispatchers that have no MCP handshake to route.
887    fn bind_mcp_server_lifecycle_handle(
888        &self,
889        _handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
890    ) {
891    }
892
893    /// Bind the session-canonical external tool-surface handle.
894    ///
895    /// MCP dispatchers use this to route add/remove/reload/call lifecycle
896    /// semantics through the session's MeerkatMachine DSL instead of their
897    /// standalone compatibility projection. The default implementation is a
898    /// no-op for dispatchers that do not own dynamic external tool surfaces.
899    fn bind_external_tool_surface_handle(
900        &self,
901        _handle: Arc<dyn crate::handles::ExternalToolSurfaceHandle>,
902    ) {
903    }
904}
905
906/// Resolve a plan against the exact live root dispatcher allocation.
907///
908/// The returned plan retains an ephemeral `Arc` lease to that allocation.
909/// This makes reconstruction and allocator address reuse unforgeable without
910/// serializing process-local authority or conflating it with durable job
911/// fencing.
912pub fn resolve_tool_execution_plan_fenced<T: AgentToolDispatcher + ?Sized + 'static>(
913    dispatcher: &Arc<T>,
914    call: ToolCallView<'_>,
915    dispatch_context: &ToolDispatchContext,
916    resolution_context: &crate::ToolExecutionResolutionContext,
917) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
918    let before = dispatcher.execution_binding_fingerprint(call.name)?;
919    let plan = dispatcher.resolve_execution_plan(call, dispatch_context, resolution_context)?;
920    if dispatcher.execution_binding_fingerprint(call.name)? != before {
921        return Err(crate::ToolExecutionResolutionError::Unavailable {
922            tool_name: call.name.to_string(),
923            reason: crate::ToolUnavailableReason::ExecutionOwnerChanged,
924        });
925    }
926    let witness = crate::ToolExecutionOwnerWitness::new("root-dispatcher", call.name, before)
927        .map_err(crate::ToolExecutionResolutionError::from)?;
928    plan.with_owner_witness(witness)?
929        .bind_root_dispatch(Arc::clone(dispatcher), call)
930}
931
932/// Dispatch a plan only through the exact root allocation and exact canonical
933/// call identity used during resolution.
934pub async fn dispatch_tool_execution_plan_fenced<T: AgentToolDispatcher + ?Sized + 'static>(
935    dispatcher: &Arc<T>,
936    call: ToolCallView<'_>,
937    context: &ToolDispatchContext,
938    plan: &crate::ResolvedToolExecutionPlan,
939) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
940    plan.validate_root_dispatch(dispatcher, call)?;
941    let witness = plan.owner_witness("root-dispatcher").ok_or_else(|| {
942        crate::error::ToolError::unavailable(
943            call.name,
944            crate::ToolUnavailableReason::ExecutionOwnerChanged,
945        )
946    })?;
947    if witness.binding_fingerprint() != &dispatcher.execution_binding_fingerprint(call.name)? {
948        return Err(crate::error::ToolError::unavailable(
949            call.name,
950            crate::ToolUnavailableReason::ExecutionOwnerChanged,
951        ));
952    }
953    match plan.kind() {
954        crate::ResolvedExecutionKind::Streaming(policy) => {
955            let absolute_timeout = plan
956                .deadlines()
957                .effective_timeout()
958                .unwrap_or_else(|| policy.absolute_timeout());
959            crate::streaming_tool::supervise_streaming_tool(
960                call.name,
961                policy.inactivity_timeout(),
962                absolute_timeout,
963                |streaming| {
964                    let streaming_context = context.clone().with_streaming(streaming);
965                    async move {
966                        dispatcher
967                            .dispatch_resolved_with_context(call, &streaming_context, plan)
968                            .await
969                    }
970                },
971            )
972            .await
973        }
974        crate::ResolvedExecutionKind::Fast | crate::ResolvedExecutionKind::Detached(_) => {
975            dispatcher
976                .dispatch_resolved_with_context(call, context, plan)
977                .await
978        }
979    }
980}
981
982/// Compute whether the current exact catalog should stay inline or switch to deferred mode.
983pub fn select_tool_catalog_mode<T>(dispatcher: &T) -> ToolCatalogMode
984where
985    T: AgentToolDispatcher + ?Sized,
986{
987    let capabilities = dispatcher.tool_catalog_capabilities();
988    if !capabilities.exact_catalog {
989        return ToolCatalogMode::Inline;
990    }
991    let pending_sources = dispatcher.pending_catalog_sources();
992    let catalog = dispatcher.tool_catalog();
993    select_catalog_mode_from_snapshot(
994        capabilities.exact_catalog,
995        catalog.as_ref(),
996        pending_sources.as_ref(),
997    )
998}
999
1000/// Compute whether the catalog control plane should be composed for this
1001/// dispatcher, even if the current adaptive snapshot remains inline.
1002pub fn should_compose_tool_catalog_control_plane<T>(dispatcher: &T) -> bool
1003where
1004    T: AgentToolDispatcher + ?Sized,
1005{
1006    let capabilities = dispatcher.tool_catalog_capabilities();
1007    if !capabilities.exact_catalog {
1008        return false;
1009    }
1010    if capabilities.may_require_catalog_control_plane {
1011        return true;
1012    }
1013
1014    let pending_sources = dispatcher.pending_catalog_sources();
1015    if !pending_sources.is_empty() {
1016        return true;
1017    }
1018
1019    let catalog = dispatcher.tool_catalog();
1020    deferred_session_entry_count(catalog.as_ref()) > 0
1021}
1022
1023/// Error from [`AgentToolDispatcher::bind_ops_lifecycle`].
1024#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
1025pub enum OpsLifecycleBindError {
1026    #[error("ops lifecycle binding is unsupported")]
1027    Unsupported,
1028    #[error("dispatcher has shared ownership and cannot be rebound")]
1029    SharedOwnership,
1030}
1031
1032/// A tool dispatcher that filters tools based on a policy
1033///
1034/// Legacy tool lists are filtered once at construction time based on the
1035/// allowed_tools list. Exact-catalog dispatchers keep catalog callability live.
1036/// The inner dispatcher is used for actual dispatch, but only allowed tools are
1037/// exposed via tools() and dispatch() returns AccessDenied for filtered tools.
1038pub struct FilteredToolDispatcher<T: AgentToolDispatcher + ?Sized> {
1039    inner: Arc<T>,
1040    allowed_tools: ToolNameSet,
1041    /// Pre-computed filtered tool list for non-exact dispatchers.
1042    filtered_tools: Arc<[Arc<ToolDef>]>,
1043}
1044
1045impl<T: AgentToolDispatcher + ?Sized> FilteredToolDispatcher<T> {
1046    pub fn new<I, N>(inner: Arc<T>, allowed_tools: I) -> Self
1047    where
1048        I: IntoIterator<Item = N>,
1049        N: Into<ToolName>,
1050    {
1051        let allowed_set: ToolNameSet = allowed_tools
1052            .into_iter()
1053            .map(Into::into)
1054            .collect::<ToolNameSet>();
1055
1056        let filtered: Vec<Arc<ToolDef>> = if inner.tool_catalog_capabilities().exact_catalog {
1057            inner
1058                .tool_catalog()
1059                .iter()
1060                .filter(|entry| entry.currently_callable())
1061                .map(|entry| Arc::clone(&entry.tool))
1062                .filter(|t| allowed_set.contains(t.name.as_str()))
1063                .collect()
1064        } else {
1065            inner
1066                .tools()
1067                .iter()
1068                .filter(|t| allowed_set.contains(t.name.as_str()))
1069                .map(Arc::clone)
1070                .collect()
1071        };
1072
1073        Self {
1074            inner,
1075            allowed_tools: allowed_set,
1076            filtered_tools: filtered.into(),
1077        }
1078    }
1079}
1080
1081#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1082#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1083impl<T: AgentToolDispatcher + ?Sized + 'static> AgentToolDispatcher for FilteredToolDispatcher<T> {
1084    fn tools(&self) -> Arc<[Arc<ToolDef>]> {
1085        if self.inner.tool_catalog_capabilities().exact_catalog {
1086            return self
1087                .inner
1088                .tool_catalog()
1089                .iter()
1090                .filter(|entry| entry.currently_callable())
1091                .map(|entry| Arc::clone(&entry.tool))
1092                .filter(|tool| self.allowed_tools.contains(tool.name.as_str()))
1093                .collect::<Vec<_>>()
1094                .into();
1095        }
1096        Arc::clone(&self.filtered_tools)
1097    }
1098
1099    async fn dispatch(
1100        &self,
1101        call: ToolCallView<'_>,
1102    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1103        self.dispatch_with_context(call, &ToolDispatchContext::default())
1104            .await
1105    }
1106
1107    async fn dispatch_with_context(
1108        &self,
1109        call: ToolCallView<'_>,
1110        context: &ToolDispatchContext,
1111    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1112        if !self.allowed_tools.contains(call.name) {
1113            let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
1114                self.inner
1115                    .tool_catalog()
1116                    .iter()
1117                    .any(|entry| entry.tool.name == call.name)
1118            } else {
1119                self.inner.tools().iter().any(|tool| tool.name == call.name)
1120            };
1121            if !inner_knows_tool {
1122                return Err(crate::error::ToolError::not_found(call.name));
1123            }
1124            return Err(crate::error::ToolError::access_denied(call.name));
1125        }
1126        self.inner.dispatch_with_context(call, context).await
1127    }
1128
1129    async fn dispatch_resolved_with_context(
1130        &self,
1131        call: ToolCallView<'_>,
1132        context: &ToolDispatchContext,
1133        plan: &crate::ResolvedToolExecutionPlan,
1134    ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1135        if !self.allowed_tools.contains(call.name) {
1136            let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
1137                self.inner
1138                    .tool_catalog()
1139                    .iter()
1140                    .any(|entry| entry.tool.name == call.name)
1141            } else {
1142                self.inner.tools().iter().any(|tool| tool.name == call.name)
1143            };
1144            if !inner_knows_tool {
1145                return Err(crate::error::ToolError::not_found(call.name));
1146            }
1147            return Err(crate::error::ToolError::access_denied(call.name));
1148        }
1149        self.inner
1150            .dispatch_resolved_with_context(call, context, plan)
1151            .await
1152    }
1153
1154    fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
1155        self.inner.tool_catalog_capabilities()
1156    }
1157
1158    fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
1159        if !self.inner.tool_catalog_capabilities().exact_catalog {
1160            return self
1161                .tools()
1162                .iter()
1163                .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
1164                .collect::<Vec<_>>()
1165                .into();
1166        }
1167        self.inner
1168            .tool_catalog()
1169            .iter()
1170            .filter(|entry| self.allowed_tools.contains(entry.tool.name.as_str()))
1171            .cloned()
1172            .collect::<Vec<_>>()
1173            .into()
1174    }
1175
1176    fn execution_binding_fingerprint(
1177        &self,
1178        tool_name: &str,
1179    ) -> Result<crate::EphemeralToolBindingFingerprint, crate::ToolExecutionResolutionError> {
1180        let catalog = self.tool_catalog();
1181        let entry = catalog
1182            .iter()
1183            .find(|entry| entry.tool.name == tool_name)
1184            .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
1185                tool_name: tool_name.to_string(),
1186            })?;
1187        let child = self.inner.execution_binding_fingerprint(tool_name)?;
1188        Ok(crate::ephemeral_tool_catalog_binding_fingerprint(entry)
1189            .with_live_authority(0, 0)
1190            .with_dependency(&child))
1191    }
1192
1193    fn resolve_execution_plan(
1194        &self,
1195        call: ToolCallView<'_>,
1196        dispatch_context: &ToolDispatchContext,
1197        resolution_context: &crate::ToolExecutionResolutionContext,
1198    ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
1199        if !self.allowed_tools.contains(call.name) {
1200            let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
1201                self.inner
1202                    .tool_catalog()
1203                    .iter()
1204                    .any(|entry| entry.tool.name == call.name)
1205            } else {
1206                self.inner.tools().iter().any(|tool| tool.name == call.name)
1207            };
1208            return Err(if inner_knows_tool {
1209                crate::ToolExecutionResolutionError::AccessDenied {
1210                    tool_name: call.name.to_string(),
1211                }
1212            } else {
1213                crate::ToolExecutionResolutionError::NotFound {
1214                    tool_name: call.name.to_string(),
1215                }
1216            });
1217        }
1218
1219        let catalog = self.tool_catalog();
1220        let entry = catalog
1221            .iter()
1222            .find(|entry| entry.tool.name == call.name)
1223            .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
1224                tool_name: call.name.to_string(),
1225            })?;
1226        if let Some(reason) = entry.callability.unavailable_reason() {
1227            return Err(crate::ToolExecutionResolutionError::Unavailable {
1228                tool_name: call.name.to_string(),
1229                reason,
1230            });
1231        }
1232
1233        self.inner
1234            .resolve_execution_plan(call, dispatch_context, resolution_context)
1235    }
1236
1237    fn pending_catalog_sources(&self) -> Arc<[String]> {
1238        self.inner.pending_catalog_sources()
1239    }
1240
1241    async fn poll_external_updates(&self) -> ExternalToolUpdate {
1242        self.inner.poll_external_updates().await
1243    }
1244
1245    fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
1246        self.inner.external_tool_surface_snapshot()
1247    }
1248
1249    fn capabilities(&self) -> DispatcherCapabilities {
1250        self.inner.capabilities()
1251    }
1252
1253    fn bind_ops_lifecycle(
1254        self: Arc<Self>,
1255        registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
1256        owner_bridge_session_id: crate::types::SessionId,
1257    ) -> Result<BindOutcome, OpsLifecycleBindError> {
1258        let owned = Arc::try_unwrap(self).map_err(|_| OpsLifecycleBindError::SharedOwnership)?;
1259        if Arc::strong_count(&owned.inner) == 1 {
1260            let outcome = owned
1261                .inner
1262                .bind_ops_lifecycle(registry, owner_bridge_session_id)?;
1263            let bound = outcome.was_bound();
1264            let d = outcome.into_dispatcher();
1265            let allowed_tools = owned.allowed_tools.into_iter().collect::<Vec<_>>();
1266            Ok(if bound {
1267                BindOutcome::Bound(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
1268            } else {
1269                BindOutcome::Skipped(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
1270            })
1271        } else {
1272            Ok(BindOutcome::Skipped(Arc::new(FilteredToolDispatcher {
1273                inner: owned.inner,
1274                allowed_tools: owned.allowed_tools,
1275                filtered_tools: owned.filtered_tools,
1276            })))
1277        }
1278    }
1279
1280    fn completion_enrichment(
1281        &self,
1282    ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
1283        self.inner.completion_enrichment()
1284    }
1285
1286    fn bind_mcp_server_lifecycle_handle(
1287        &self,
1288        handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
1289    ) {
1290        self.inner.bind_mcp_server_lifecycle_handle(handle);
1291    }
1292
1293    fn bind_external_tool_surface_handle(
1294        &self,
1295        handle: Arc<dyn crate::handles::ExternalToolSurfaceHandle>,
1296    ) {
1297        self.inner.bind_external_tool_surface_handle(handle);
1298    }
1299}
1300
1301/// Trait for session stores
1302#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1303#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1304pub trait AgentSessionStore: Send + Sync {
1305    async fn save(&self, session: &Session) -> Result<(), AgentError>;
1306    async fn load(&self, id: &str) -> Result<Option<Session>, AgentError>;
1307}
1308
1309/// Runtime policy for inlining peer lifecycle updates into session context.
1310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1311pub enum InlinePeerNotificationPolicy {
1312    /// Always inline batched peer lifecycle updates.
1313    Always,
1314    /// Never inline batched peer lifecycle updates.
1315    Never,
1316    /// Inline only when post-drain peer count is at or below this threshold.
1317    AtMost(usize),
1318}
1319
1320/// Default inline threshold when no explicit value is configured.
1321pub const DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS: usize = 50;
1322
1323impl InlinePeerNotificationPolicy {
1324    /// Resolve policy from transport/build-layer config representation.
1325    pub fn try_from_raw(raw: Option<i32>) -> Result<Self, i32> {
1326        match raw {
1327            None => Ok(Self::AtMost(DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS)),
1328            Some(-1) => Ok(Self::Always),
1329            Some(0) => Ok(Self::Never),
1330            Some(v) if v > 0 => Ok(Self::AtMost(v as usize)),
1331            Some(v) => Err(v),
1332        }
1333    }
1334}
1335
1336/// Error returned when a comms runtime capability is not available.
1337#[derive(Debug, thiserror::Error)]
1338pub enum CommsCapabilityError {
1339    /// The runtime does not support this capability.
1340    #[error("comms capability not supported: {0}")]
1341    Unsupported(String),
1342}
1343
1344/// Trait for comms runtime that can be used with the agent
1345#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1346#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1347pub trait CommsRuntime: Send + Sync {
1348    /// Canonical runtime routing identity for this peer, if available.
1349    ///
1350    /// `PeerId` is the UUID-shaped routing key used by peer directories and
1351    /// trust stores. Implementations that only have the legacy string carrier
1352    /// may return a parsed UUID-shaped `public_key()` value; implementations
1353    /// with Ed25519 public keys should override this and return the pubkey-
1354    /// derived canonical [`PeerId`].
1355    fn peer_id(&self) -> Option<PeerId> {
1356        self.public_key()
1357            .as_deref()
1358            .and_then(|public_key| PeerId::parse(public_key).ok())
1359    }
1360
1361    /// Runtime-local transport/auth public key, if available.
1362    ///
1363    /// Returns an Ed25519 public key string in `ed25519:<base64>` format.
1364    /// This is not the canonical routing [`PeerId`]; use [`Self::peer_id`]
1365    /// for roster/projection identity and peer-directory lookups.
1366    fn public_key(&self) -> Option<String> {
1367        None
1368    }
1369
1370    /// Runtime-local Ed25519 public key bytes, if available.
1371    ///
1372    /// This is the typed form of [`Self::public_key`]. Trust installation
1373    /// paths that need to verify `PeerId`/pubkey consistency should prefer
1374    /// this method over reparsing the string carrier.
1375    fn public_key_bytes(&self) -> Option<[u8; 32]> {
1376        None
1377    }
1378
1379    /// Runtime-local canonical comms routing name, if available.
1380    ///
1381    /// This is the peer name used in trusted-peer descriptors and peer
1382    /// directories. It is separate from the advertised transport address so
1383    /// callers do not recover identity by parsing transport strings.
1384    fn comms_name(&self) -> Option<String> {
1385        None
1386    }
1387
1388    /// Runtime-local advertised comms address, if available.
1389    ///
1390    /// This is the canonical address the runtime expects peers to use when
1391    /// constructing a [`TrustedPeerDescriptor`]. Implementations that do not
1392    /// expose a stable advertised address can return `None`.
1393    fn advertised_address(&self) -> Option<String> {
1394        None
1395    }
1396
1397    /// Runtime-local bootstrap proof for the initial supervisor bind, if
1398    /// available.
1399    fn bridge_bootstrap_token(&self) -> Option<String> {
1400        None
1401    }
1402
1403    /// Apply a comms trust projection mutation authorized by generated
1404    /// machine/composition authority.
1405    ///
1406    /// This is the only mutable trust-store seam.
1407    async fn apply_trust_mutation(
1408        &self,
1409        _mutation: CommsTrustMutation,
1410    ) -> Result<CommsTrustMutationResult, SendError> {
1411        Err(SendError::Unsupported(
1412            "apply_trust_mutation not supported for this CommsRuntime".to_string(),
1413        ))
1414    }
1415
1416    /// Bind this target runtime to the generated MobMachine owner token whose
1417    /// trust handoffs may mutate mob-owned trust rows.
1418    ///
1419    /// Mob runtimes call this before submitting a generated mob trust mutation.
1420    /// Implementations must fail closed when they cannot remember and compare
1421    /// the owner token during [`Self::apply_trust_mutation`].
1422    async fn install_generated_mob_trust_owner(
1423        &self,
1424        _owner: Arc<dyn std::any::Any + Send + Sync>,
1425    ) -> Result<(), SendError> {
1426        Err(SendError::Unsupported(
1427            "generated mob trust owner binding not supported for this CommsRuntime".to_string(),
1428        ))
1429    }
1430
1431    /// Read-only preflight for binding this target runtime to a recovered
1432    /// MobMachine owner token.
1433    ///
1434    /// Resume uses this to validate every generated trust repair target before
1435    /// mutating any trust projection row. Implementations must not change the
1436    /// stored owner token here; [`Self::install_recovered_generated_mob_trust_owner`]
1437    /// performs the actual binding after the full batch has passed preflight.
1438    async fn validate_recovered_generated_mob_trust_owner(
1439        &self,
1440        _owner: Arc<dyn std::any::Any + Send + Sync>,
1441    ) -> Result<(), SendError> {
1442        Err(SendError::Unsupported(
1443            "recovered generated mob trust owner validation not supported for this CommsRuntime"
1444                .to_string(),
1445        ))
1446    }
1447
1448    /// Rebind this target runtime to the owner token of a recovered
1449    /// MobMachine authority.
1450    ///
1451    /// Recovery reconstructs generated authority from persisted machine state,
1452    /// which gives it a fresh process-local owner token. Implementations may
1453    /// bind this owner only when no generated MobMachine owner is already
1454    /// installed, or when it is the same owner token. They must fail closed
1455    /// rather than replacing a different live owner through recovery plumbing.
1456    async fn install_recovered_generated_mob_trust_owner(
1457        &self,
1458        _owner: Arc<dyn std::any::Any + Send + Sync>,
1459    ) -> Result<(), SendError> {
1460        Err(SendError::Unsupported(
1461            "recovered generated mob trust owner binding not supported for this CommsRuntime"
1462                .to_string(),
1463        ))
1464    }
1465
1466    /// Opaque host-acceptor registration material for reverse-lane demux
1467    /// composition (the runtime's identity pubkey, its ack-signing keypair,
1468    /// and its inbox sender), encoded by the concrete comms crate.
1469    ///
1470    /// A host that composes an acceptor demux in front of this runtime (so
1471    /// remote peers can dial one shared listener and be routed to this
1472    /// identity's inbox) decodes the payload where it holds the concrete
1473    /// comms dependency (`meerkat_comms::HostAcceptorRegistrationMaterial`).
1474    /// `None` means this runtime exposes no registration material and the
1475    /// composer must fail closed (no acceptor registration). The default is
1476    /// `None`; only the concrete comms runtime overrides it — the typed
1477    /// trait surface itself continues to expose no signing material.
1478    fn host_acceptor_registration_payload(&self) -> Option<Arc<dyn std::any::Any + Send + Sync>> {
1479        None
1480    }
1481
1482    /// Register a peer for admission-only trust without listing it in the
1483    /// directory.
1484    ///
1485    /// Used for control-plane edges — the canonical case is the supervisor
1486    /// bridge for session-backed mob members: lifecycle notifications
1487    /// (`mob.peer_added`, `mob.peer_retired`, …) must land at the member's
1488    /// inbox, but the supervisor must not appear as an ordinary sendable
1489    /// peer in `comms.peers` / REST / RPC / MCP. The admission gate consults
1490    /// both the public and private trust sets; `resolve_peer_directory()`
1491    /// consults only the public set.
1492    async fn add_private_trusted_peer(
1493        &self,
1494        _peer: TrustedPeerDescriptor,
1495    ) -> Result<(), SendError> {
1496        Err(SendError::Unsupported(
1497            "generated comms private trust mutation authority required".to_string(),
1498        ))
1499    }
1500
1501    /// Remove a previously registered private-trust edge by peer ID.
1502    ///
1503    /// Returns `true` if the edge was present and removed, `false` if it
1504    /// was not.
1505    async fn remove_private_trusted_peer(&self, _peer_id: &str) -> Result<bool, SendError> {
1506        Err(SendError::Unsupported(
1507            "generated comms private trust mutation authority required".to_string(),
1508        ))
1509    }
1510
1511    /// Install the host-owned outbound content-taint declaration.
1512    ///
1513    /// The declaration is host-set carrier config, not machine state: the
1514    /// host owns the "this session's content is tainted" fact and this
1515    /// runtime stamps it (inside the signed envelope region) on every
1516    /// outbound content-bearing send until changed. `None` clears the
1517    /// declaration (subsequent envelopes carry no claim — which receivers
1518    /// must never coalesce into `Clean`).
1519    ///
1520    /// The declaration is in-memory runtime state: a rebuilt runtime (e.g.
1521    /// a respawned mob member) starts with no declaration, which aligns
1522    /// with fresh-context taint semantics — hosts re-declare when their
1523    /// tracker re-marks the new context.
1524    ///
1525    /// Fails typed (never a silent no-op — silently dropping a security
1526    /// declaration would let tainted content ship with a clean-looking
1527    /// envelope) for runtimes that do not carry outbound comms.
1528    fn set_outbound_content_taint(
1529        &self,
1530        _taint: Option<crate::comms::SenderContentTaint>,
1531    ) -> Result<(), SendError> {
1532        Err(SendError::Unsupported(
1533            "outbound content-taint declaration not supported by this CommsRuntime".to_string(),
1534        ))
1535    }
1536
1537    /// Dispatch a canonical comms command.
1538    async fn send(&self, _cmd: CommsCommand) -> Result<SendReceipt, SendError> {
1539        Err(SendError::Unsupported(
1540            "send not implemented for this CommsRuntime".to_string(),
1541        ))
1542    }
1543
1544    #[doc(hidden)]
1545    fn stream(&self, scope: StreamScope) -> Result<EventStream, StreamError> {
1546        let scope_desc = match scope {
1547            StreamScope::Session(session_id) => format!("session {session_id}"),
1548            StreamScope::Interaction(interaction_id) => format!("interaction {}", interaction_id.0),
1549        };
1550        Err(StreamError::NotFound(scope_desc))
1551    }
1552
1553    /// List peers visible to this runtime.
1554    async fn peers(&self) -> Vec<PeerDirectoryEntry> {
1555        Vec::new()
1556    }
1557
1558    /// Count peers visible to this runtime.
1559    ///
1560    /// Implementations can override this to avoid materializing a full peer list.
1561    async fn peer_count(&self) -> usize {
1562        self.peers().await.len()
1563    }
1564
1565    #[doc(hidden)]
1566    async fn send_and_stream(
1567        &self,
1568        cmd: CommsCommand,
1569    ) -> Result<(SendReceipt, EventStream), SendAndStreamError> {
1570        let receipt = self.send(cmd).await?;
1571        Err(SendAndStreamError::StreamAttach {
1572            receipt,
1573            error: StreamError::Internal(
1574                "send_and_stream is not implemented for this runtime".to_string(),
1575            ),
1576        })
1577    }
1578
1579    /// Drain comms inbox and return messages formatted for the LLM
1580    async fn drain_messages(&self) -> Vec<String>;
1581    /// Get a notification when new messages arrive
1582    fn inbox_notify(&self) -> Arc<tokio::sync::Notify>;
1583    /// Returns true if a DISMISS signal was seen during the last `drain_messages` call.
1584    fn dismiss_received(&self) -> bool {
1585        false
1586    }
1587    /// Get an event injector for this runtime's inbox.
1588    ///
1589    /// Surfaces use this to push external events into the agent inbox.
1590    /// Returns `None` if the implementation doesn't support event injection.
1591    fn event_injector(&self) -> Option<Arc<dyn crate::EventInjector>> {
1592        None
1593    }
1594
1595    /// Internal runtime seam for interaction-scoped streaming.
1596    #[doc(hidden)]
1597    fn interaction_event_injector(
1598        &self,
1599    ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
1600        None
1601    }
1602
1603    /// Drain comms inbox and return structured interactions.
1604    ///
1605    /// Default implementation wraps `drain_messages()` results as `InteractionContent::Message`
1606    /// with generated IDs.
1607    async fn drain_inbox_interactions(&self) -> Vec<crate::interaction::InboxInteraction> {
1608        self.drain_messages()
1609            .await
1610            .into_iter()
1611            .map(|text| crate::interaction::InboxInteraction {
1612                objective_id: None,
1613                id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
1614                from_route: None,
1615                from: "unknown".into(),
1616                content: crate::interaction::InteractionContent::Message {
1617                    body: text.clone(),
1618                    blocks: None,
1619                },
1620                rendered_text: text,
1621                handling_mode: crate::types::HandlingMode::Queue,
1622                render_metadata: None,
1623                sender_taint: None,
1624            })
1625            .collect()
1626    }
1627
1628    /// Look up and remove a one-shot subscriber for the given interaction.
1629    ///
1630    /// Returns the event sender if a subscriber was registered (via `inject_with_subscription`).
1631    /// The entry is removed from the registry on lookup (one-shot).
1632    fn interaction_subscriber(
1633        &self,
1634        _id: &crate::interaction::InteractionId,
1635    ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1636        None
1637    }
1638
1639    /// Take and clear the one-shot sender for an interaction-scoped stream.
1640    fn take_interaction_stream_sender(
1641        &self,
1642        _id: &crate::interaction::InteractionId,
1643    ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1644        self.interaction_subscriber(_id)
1645    }
1646
1647    /// Signal that an interaction has reached a terminal state (complete or failed).
1648    ///
1649    /// Implementations should transition the reservation FSM to `Completed` and
1650    /// clean up registry entries. Called from the keep-alive loop after sending
1651    /// terminal events to the tap.
1652    fn mark_interaction_complete(&self, _id: &crate::interaction::InteractionId) {}
1653
1654    /// Signal that an interaction stream became unusable for an explicit,
1655    /// typed reason. Implementations with machine-owned stream lifecycle must
1656    /// drive `InteractionStreamAbandoned`; transport-only implementations may
1657    /// clean up their local projection directly.
1658    fn abandon_interaction_stream(
1659        &self,
1660        _id: &crate::interaction::InteractionId,
1661        _reason: crate::InteractionStreamAbandonReason,
1662    ) {
1663    }
1664
1665    /// Access the session's peer-interaction DSL handle (W1-A).
1666    ///
1667    /// Returns `None` for transport-only comms runtimes. A runtime that emits
1668    /// semantic peer request/response receipts must return `Some` after the
1669    /// surface installs machine authority.
1670    fn peer_interaction_handle(
1671        &self,
1672    ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1673        None
1674    }
1675
1676    /// Access peer request/response authority only when the runtime has the
1677    /// complete machine-owned lifecycle pair.
1678    ///
1679    /// Semantic peer request/response ingress requires both the peer
1680    /// interaction handle and the paired interaction-stream handle. The stream
1681    /// handle itself stays hidden behind runtime ownership; this witness lets
1682    /// authority boundaries fail closed instead of treating a lone peer handle
1683    /// as sufficient.
1684    fn peer_request_response_authority_handle(
1685        &self,
1686    ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1687        None
1688    }
1689
1690    /// Drain classified inbox interactions.
1691    ///
1692    /// Returns interactions with pre-computed classification from ingress.
1693    /// The host loop routes on the stored `PeerInputClass` instead of
1694    /// re-classifying after drain.
1695    ///
1696    /// Default returns `Unsupported`. Comms-enabled runtimes must override.
1697    async fn drain_classified_inbox_interactions(
1698        &self,
1699    ) -> Result<Vec<crate::interaction::ClassifiedInboxInteraction>, CommsCapabilityError> {
1700        Err(CommsCapabilityError::Unsupported(
1701            "drain_classified_inbox_interactions".to_string(),
1702        ))
1703    }
1704
1705    /// Receive at most one classified inbox interaction.
1706    ///
1707    /// Session-backed drain loops must use this cancellation-safe dequeue
1708    /// surface before awaiting admission. Removing a whole batch and then
1709    /// awaiting each admission can strand the unprocessed tail in task-local
1710    /// memory if the drain task is aborted or replaced.
1711    async fn try_recv_classified_inbox_interaction(
1712        &self,
1713    ) -> Result<Option<crate::interaction::ClassifiedInboxInteraction>, CommsCapabilityError> {
1714        Err(CommsCapabilityError::Unsupported(
1715            "try_recv_classified_inbox_interaction".to_string(),
1716        ))
1717    }
1718
1719    /// Drain canonical peer/event ingress candidates.
1720    ///
1721    /// This remains the live runtime drain bridge for call sites that consume
1722    /// the `PeerInputCandidate` noun directly. The underlying drain unit is
1723    /// identical to `ClassifiedInboxInteraction`, so the default
1724    /// implementation simply forwards the classified drain path.
1725    async fn drain_peer_input_candidates(&self) -> Vec<crate::interaction::PeerInputCandidate> {
1726        self.drain_classified_inbox_interactions()
1727            .await
1728            .unwrap_or_default()
1729    }
1730
1731    /// Snapshot the currently queued peer-ingress surface without draining it.
1732    ///
1733    /// This is a hidden diagnostic capability used while mapping the internal
1734    /// MeerkatMachine boundary onto existing comms ownership.
1735    async fn peer_ingress_queue_snapshot(
1736        &self,
1737    ) -> Result<crate::interaction::PeerIngressQueueSnapshot, CommsCapabilityError> {
1738        Err(CommsCapabilityError::Unsupported(
1739            "peer_ingress_queue_snapshot".to_string(),
1740        ))
1741    }
1742
1743    /// Snapshot the current peer runtime surface for MeerkatMachine mapping.
1744    ///
1745    /// This extends the queued ingress snapshot with the local trust membership
1746    /// that governs peer admission.
1747    async fn peer_ingress_runtime_snapshot(
1748        &self,
1749    ) -> Result<crate::interaction::PeerIngressRuntimeSnapshot, CommsCapabilityError> {
1750        Err(CommsCapabilityError::Unsupported(
1751            "peer_ingress_runtime_snapshot".to_string(),
1752        ))
1753    }
1754
1755    /// Snapshot only the public trust projection owned by generated public
1756    /// peer authority.
1757    ///
1758    /// Private/control-plane trust edges are admitted by separate generated
1759    /// private authority and must not be reconciled or removed by public peer
1760    /// projection owners.
1761    async fn public_trusted_peer_projection_snapshot(
1762        &self,
1763    ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1764        Err(CommsCapabilityError::Unsupported(
1765            "public_trusted_peer_projection_snapshot".to_string(),
1766        ))
1767    }
1768
1769    /// Snapshot the public trust projection owned by one generated source.
1770    ///
1771    /// This is the behavior-authority read used by generated trust
1772    /// reconciliation. Compatibility/public snapshots may still union public
1773    /// rows for display, but generated removals must diff only against rows
1774    /// previously installed by the same generated owner.
1775    async fn trusted_peer_projection_snapshot_for_source(
1776        &self,
1777        _source_kind: crate::comms::GeneratedCommsTrustAuthoritySourceKind,
1778    ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1779        Err(CommsCapabilityError::Unsupported(
1780            "trusted_peer_projection_snapshot_for_source".to_string(),
1781        ))
1782    }
1783
1784    /// Get a notification that fires only for actionable peer input.
1785    ///
1786    /// Default returns `Unsupported`. Comms-enabled runtimes must override.
1787    /// Used by the factory to bridge into `WaitTool` interrupt.
1788    fn actionable_input_notify(&self) -> Result<Arc<tokio::sync::Notify>, CommsCapabilityError> {
1789        Err(CommsCapabilityError::Unsupported(
1790            "actionable_input_notify".to_string(),
1791        ))
1792    }
1793
1794    /// Stage a one-shot reply endpoint for a Response to a peer outside the
1795    /// trust store.
1796    ///
1797    /// This is the legacy uncorrelated compatibility seam. It is
1798    /// Response-only, one-shot, and trust-store-losing; callers may supply
1799    /// only a machine-authorized endpoint already held in runtime state.
1800    /// Neither a Request's `reply_endpoint` nor any decoded payload/sender
1801    /// address is authority for this method. New ingress response paths use
1802    /// [`Self::stage_correlated_reply_endpoint`] instead.
1803    ///
1804    /// Parameters are primitives because core cannot name the comms-crate
1805    /// newtypes (dependency direction). Default fails typed, not no-op:
1806    /// silently dropping a reply-repair staging would strand the remote
1807    /// sender in a timeout with no cause. Callers decide policy — reply
1808    /// drains treat `Unsupported` as "runtime has no staging capability" and
1809    /// proceed, since in-proc runtimes resolve via the ingress route anyway.
1810    async fn stage_declared_reply_endpoint(
1811        &self,
1812        _dest: PeerId,
1813        _signer_pubkey: [u8; 32],
1814        _declared_address: String,
1815    ) -> Result<(), SendError> {
1816        Err(SendError::Unsupported(
1817            "declared reply endpoint staging not supported".to_string(),
1818        ))
1819    }
1820
1821    /// Stage an authenticated one-shot endpoint for the Response correlated
1822    /// to `in_reply_to` from `dest`.
1823    ///
1824    /// Unlike the legacy uncorrelated staging seam above, this endpoint is
1825    /// keyed by both peer identity and request id and therefore takes
1826    /// precedence over durable trust only for that exact Response. This is
1827    /// the only Request-ingress callback seam. `signer_pubkey` must
1828    /// come from a signature-verified envelope and derive `dest` in the
1829    /// concrete runtime. `declared_endpoint` must be the classifier's
1830    /// source-confined TCP projection: kernel-observed source IP plus the
1831    /// signed, nonzero declared port. Arbitrary payload addresses,
1832    /// sender-selected hosts, UDS addresses, and open-auth ingress are never
1833    /// callback authority.
1834    async fn stage_correlated_reply_endpoint(
1835        &self,
1836        _dest: PeerId,
1837        _in_reply_to: crate::interaction::InteractionId,
1838        _signer_pubkey: [u8; 32],
1839        _declared_endpoint: crate::comms::PeerAddress,
1840    ) -> Result<(), SendError> {
1841        Err(SendError::Unsupported(
1842            "correlated reply endpoint staging not supported".to_string(),
1843        ))
1844    }
1845
1846    /// Idempotently discard a previously staged correlated endpoint.
1847    /// Responders call this when validation or response sending fails before
1848    /// the Router consumes the exact one-shot entry.
1849    async fn unstage_correlated_reply_endpoint(
1850        &self,
1851        _dest: PeerId,
1852        _in_reply_to: crate::interaction::InteractionId,
1853    ) -> Result<(), SendError> {
1854        Err(SendError::Unsupported(
1855            "correlated reply endpoint cleanup not supported".to_string(),
1856        ))
1857    }
1858
1859    /// One-shot reply waiter for an agent-blocking bridge request (member
1860    /// upcall lane). Consulted by the comms drain BEFORE session injection: a
1861    /// taken waiter receives the terminal Response candidate (typed
1862    /// terminality intact) and the candidate never becomes session input.
1863    ///
1864    /// Returns `Some(sender)` only for a live waiter. A tombstoned (timed
1865    /// out) waiter entry is consumed and `None` is returned — pair with
1866    /// [`Self::has_bridge_reply_waiter`] to distinguish "tombstone consumed"
1867    /// (discard the late reply) from "never registered" (ordinary session
1868    /// path). Default: no registry (a query, not a capability — absence of a
1869    /// waiter is the universal normal case).
1870    fn take_bridge_reply_waiter(
1871        &self,
1872        _in_reply_to: &crate::interaction::InteractionId,
1873    ) -> Option<tokio::sync::oneshot::Sender<crate::interaction::PeerInputCandidate>> {
1874        None
1875    }
1876
1877    /// True when a bridge-reply waiter entry (live or tombstoned) is
1878    /// registered for `in_reply_to`. See [`Self::take_bridge_reply_waiter`].
1879    fn has_bridge_reply_waiter(&self, _in_reply_to: &crate::interaction::InteractionId) -> bool {
1880        false
1881    }
1882}
1883
1884/// The main Agent struct
1885pub struct Agent<C, T, S>
1886where
1887    C: AgentLlmClient + ?Sized,
1888    T: AgentToolDispatcher + ?Sized,
1889    S: AgentSessionStore + ?Sized,
1890{
1891    config: AgentConfig,
1892    client: Arc<C>,
1893    tools: Arc<T>,
1894    tool_scope: ToolScope,
1895    store: Arc<S>,
1896    session: Session,
1897    budget: Budget,
1898    retry_policy: RetryPolicy,
1899    depth: u32,
1900    pub(super) comms_runtime: Option<Arc<dyn CommsRuntime>>,
1901    pub(super) hook_engine: Option<Arc<dyn HookEngine>>,
1902    pub(super) hook_run_overrides: HookRunOverrides,
1903    /// Optional context compaction strategy.
1904    pub(crate) compactor: Option<Arc<dyn crate::compact::Compactor>>,
1905    /// Optional host-supplied compaction summary curator. When present it
1906    /// produces the compaction summary instead of the summarization LLM call.
1907    pub(crate) compaction_curator: Option<Arc<dyn crate::compact::CompactionCurator>>,
1908    /// Input tokens from the last LLM response (for compaction trigger).
1909    pub(crate) last_input_tokens: u64,
1910    /// Session-scoped compaction cadence tracked across runs.
1911    pub(crate) compaction_cadence: SessionCompactionCadence,
1912    /// Machine-issued compaction check parked until the request has been fully
1913    /// composed, blob-hydrated, tool-scoped, and provider-lowered.
1914    pub(crate) pending_compaction_boundary_index: Option<u64>,
1915    /// Exact pressure witness attached to the parked compaction check.
1916    pub(crate) pending_compaction_request_pressure: Option<crate::ProviderRequestPressure>,
1917    /// Pre-compaction pressure retained until the rebuilt request proves that
1918    /// compaction both decreased the body and brought it below the hard cap.
1919    pub(crate) post_compaction_pressure_check: Option<crate::ProviderRequestPressure>,
1920    /// Optional memory store for indexing compaction discards.
1921    pub(crate) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
1922    /// Runtime-owned resultful handoff for durable transcript+memory
1923    /// compaction pairs. Absent on standalone paths.
1924    pub(crate) compaction_commit_coordinator:
1925        Option<Arc<dyn crate::memory::CompactionCommitCoordinator>>,
1926    /// Typed lifecycle for the current transcript-rewrite + staged-memory
1927    /// transaction. Runtime reconciliation advances this to commit-only before
1928    /// touching the memory store; abort is legal only while runtime commit is
1929    /// still pending.
1930    pub(crate) compaction_transaction: Option<CompactionTransaction>,
1931    /// Deterministic projection identity installed immediately before the
1932    /// durable stage await. A hard interrupt can drop that await before a
1933    /// receipt reaches the transaction owner, so cleanup must retain the exact
1934    /// identity rather than infer empty RuntimeStore authority.
1935    pub(crate) in_flight_compaction_stage: Option<crate::memory::CompactionProjectionId>,
1936    /// Optional skill engine for per-turn `/skill-ref` activation.
1937    pub(crate) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
1938    /// Skill references to resolve and inject for the next turn.
1939    /// Set by surfaces before calling `run()`, consumed on run start.
1940    pub pending_skill_references: Option<Vec<crate::skills::SkillKey>>,
1941    /// Per-interaction event tap for streaming events to subscribers.
1942    pub(crate) event_tap: crate::event_tap::EventTap,
1943    /// Request-only exact-boundary context coordinator for this live actor.
1944    pub(crate) transient_turn_context_state: crate::session::TransientTurnContextStateHandle,
1945    /// Optional default event channel configured at build time.
1946    /// Used by run methods when no per-call event channel is provided.
1947    pub(crate) default_event_tx: Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>>,
1948    /// Optional session checkpointer for keep-alive persistence.
1949    ///
1950    /// Wired by `AgentBuilder::with_checkpointer`, installed by
1951    /// `PersistentSessionService`, and consumed only by active-run persistence.
1952    pub(crate) checkpointer: Option<Arc<dyn crate::SessionCheckpointer>>,
1953    /// Latest successful provisional physical write for the active run.
1954    ///
1955    /// This is actor-local transport state, never Session domain state. The
1956    /// actor removes it before each checkpoint await and installs only the
1957    /// exact returned successor.
1958    pub(crate) latest_run_checkpoint_receipt: Option<crate::RunCheckpointReceipt>,
1959    /// Optional blob store used to hydrate image refs at execution seams.
1960    pub(crate) blob_store: Option<Arc<dyn crate::BlobStore>>,
1961    /// Original error detail preserved from `terminalize_fatal_error` so
1962    /// `build_result` can include the actual failure message (e.g. the API
1963    /// error body) instead of only the generic terminal-cause description.
1964    pub(crate) terminal_error_detail: Option<String>,
1965    /// Structured metadata captured from that concrete error before the
1966    /// public result is normalized into `AgentError::TerminalFailure`.
1967    pub(crate) terminal_error_metadata: Option<crate::TurnErrorMetadata>,
1968    /// True once the current run has accepted `RunCompleted` hooks.
1969    pub(crate) run_completed_hooks_applied: bool,
1970    /// True once the current run's public `RunCompleted` event has been
1971    /// emitted. Extraction may continue afterward as a separate post-run phase.
1972    pub(crate) run_completed_event_emitted: bool,
1973    /// Comms intents that should be silently injected into the session
1974    /// without triggering an LLM turn. Matched against `InteractionContent::Request.intent`.
1975    #[allow(dead_code)] // Used by comms_impl when comms feature is enabled
1976    pub(crate) silent_comms_intents: Vec<String>,
1977    /// Optional shared lifecycle registry for async operations.
1978    pub(crate) ops_lifecycle: Option<Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>>,
1979    /// Optional completion feed for cursor-based completion delivery.
1980    pub(crate) completion_feed: Option<Arc<dyn crate::completion_feed::CompletionFeed>>,
1981    /// Shared epoch cursor state for runtime-backed cursor writeback.
1982    pub(crate) epoch_cursor_state: Option<Arc<crate::runtime_epoch::EpochCursorState>>,
1983    /// Local cursor into the completion feed — only the agent boundary advances this.
1984    pub(crate) applied_cursor: crate::completion_feed::CompletionSeq,
1985    /// Optional enrichment provider for completion display details.
1986    pub(crate) completion_enrichment:
1987        Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>>,
1988    /// Shared effective mob authority handle. Owned by the agent, passed to
1989    /// mob tools at construction for authorization reads. Updated by
1990    /// `apply_session_effects` after each tool batch as a derived projection
1991    /// of the canonical `session.build_state().mob_tool_authority_context`.
1992    pub(crate) mob_authority_handle:
1993        Option<Arc<std::sync::RwLock<crate::service::MobToolAuthorityContext>>>,
1994    /// Runtime-backed turn-state handle, provided by the session runtime bindings.
1995    pub(crate) turn_state_handle: Option<Arc<dyn crate::TurnStateHandle>>,
1996    /// Runtime-backed model-routing authority. Sticky fallback commits route
1997    /// through this handle in the compensated client/auth/machine transaction.
1998    pub(crate) model_routing_handle: Option<Arc<dyn crate::handles::ModelRoutingHandle>>,
1999    /// Runtime-owned durable sticky-fallback transaction coordinator.
2000    /// Standalone agents leave this absent and consume staged machine commits
2001    /// synchronously in-process.
2002    pub(crate) sticky_model_fallback_commit_coordinator:
2003        Option<Arc<dyn crate::handles::StickyModelFallbackCommitCoordinator>>,
2004    /// Saga state retained across cancellation while the supervised durable
2005    /// sticky-fallback transaction is in flight.
2006    pub(crate) pending_sticky_model_fallback_activation:
2007        Option<state::PendingStickyModelFallbackActivation>,
2008    /// Async operation references staged behind an external callback boundary.
2009    /// They are registered with the fresh continuation run before it can call
2010    /// the provider, preserving Barrier versus Detached semantics.
2011    pub(crate) pending_callback_async_ops: Option<Vec<crate::ops::AsyncOpRef>>,
2012    /// Effective model registry captured by the construction pipeline.
2013    /// Fallback profile and limit truth is freshly resolved through this exact
2014    /// registry before it can reach the routing machine.
2015    pub(crate) effective_model_registry: Option<Arc<crate::ModelRegistry>>,
2016    /// Registry-minted facts for the active model. This replaces client-local
2017    /// capability/limit projections as the durable source used by later turns.
2018    pub(crate) active_model_profile: Option<crate::ModelProfileWitness>,
2019    /// True when the runtime control plane must stamp execution kind metadata.
2020    pub(crate) runtime_execution_kind_required: bool,
2021    /// Typed execution intent for the current run, when this turn is owned by
2022    /// the runtime control plane rather than a direct surface call.
2023    pub(crate) runtime_execution_kind: Option<crate::lifecycle::RuntimeExecutionKind>,
2024    /// Exact per-call witness that the core turn machine admitted a runtime
2025    /// run. A completed future alone is not sufficient evidence: preflight
2026    /// failures can return before `StartConversationRun` and must never reuse
2027    /// the previous turn's terminal snapshot.
2028    pub(crate) runtime_started_run_id: Option<crate::lifecycle::RunId>,
2029    /// Machine-terminal failure observed for the exact runtime run above.
2030    /// Kept separate from the public `AgentError` so direct session surfaces
2031    /// preserve their original typed errors while the runtime can commit a
2032    /// failed-but-applied turn atomically.
2033    pub(crate) runtime_terminal_failure_witness:
2034        Option<Result<crate::TurnErrorMetadata, crate::error::AgentError>>,
2035    /// Stable transcript identity for the active runtime-owned turn.
2036    pub(crate) active_transcript_identity: Option<crate::types::TranscriptMessageIdentity>,
2037    /// Request-only host context for the active runtime-owned logical turn.
2038    ///
2039    /// This value is never appended to `session`; request composition projects
2040    /// it immediately before the admitted conversational user message.
2041    pub(crate) active_turn_request_contexts:
2042        Vec<crate::lifecycle::run_primitive::TurnRequestContext>,
2043    /// Runtime-backed external tool-surface diagnostic handle, when provided
2044    /// by the session runtime bindings.
2045    pub(crate) external_tool_surface_handle: Option<Arc<dyn crate::ExternalToolSurfaceHandle>>,
2046    /// Runtime-backed auth lease handle (Phase 1.5-rev).
2047    pub(crate) auth_lease_handle: Option<crate::handles::GeneratedAuthLeaseHandle>,
2048    /// Runtime-backed MCP server lifecycle handle (Phase 5G / T5g). When set,
2049    /// the agent loop reads `pending_server_ids()` at each CallingLlm boundary
2050    /// to decide whether to emit the `[MCP_PENDING]` system notice.
2051    pub(crate) mcp_server_lifecycle_handle:
2052        Option<Arc<dyn crate::handles::McpServerLifecycleHandle>>,
2053    /// Producer end of the typed cancel-after-boundary command channel.
2054    ///
2055    /// Retained so [`Agent::cancel_after_boundary_handle`] can hand cloned
2056    /// senders to the surface that requests boundary-only cancellation. The
2057    /// agent never sends on this end itself; it only drains the matching
2058    /// receiver at turn boundaries.
2059    pub(crate) cancel_after_boundary_tx: CancelAfterBoundarySender,
2060    /// Consumer end of the typed cancel-after-boundary command channel.
2061    ///
2062    /// Drained (non-blocking) at each turn boundary by
2063    /// `observe_cancel_after_boundary_request`, replacing the previous
2064    /// `.swap`-polled `AtomicBool`. A delivered [`CancelAfterBoundaryCommand`]
2065    /// is observed at most once per boundary, mirroring the prior edge
2066    /// semantics.
2067    pub(crate) cancel_after_boundary_rx:
2068        tokio::sync::mpsc::UnboundedReceiver<CancelAfterBoundaryCommand>,
2069    /// Optional resolver for model-specific operational defaults (e.g., call timeout).
2070    /// Consulted at each LLM call for hot-swap-aware profile default resolution.
2071    pub(crate) model_defaults_resolver:
2072        Option<Arc<dyn crate::model_defaults::ModelOperationalDefaultsResolver>>,
2073    /// Explicit call-timeout override from the build/config composition seam.
2074    /// Takes precedence over profile-derived defaults.
2075    pub(crate) call_timeout_override: crate::config::CallTimeoutOverride,
2076    /// Structured-output extraction state carried into RunResult.
2077    pub(crate) extraction_state: extraction::ExtractionState,
2078    /// Last published hidden deferred-catalog names.
2079    pub(crate) last_hidden_deferred_catalog_names: BTreeSet<crate::types::ToolName>,
2080    /// Last published pending catalog sources.
2081    pub(crate) last_pending_catalog_sources: BTreeSet<String>,
2082    /// Dispatch-time projection of the current turn input for contextual tools.
2083    pub(crate) tool_dispatch_context: ToolDispatchContext,
2084    /// Runtime-owned dispatch metadata for this turn.
2085    pub(crate) turn_tool_dispatch_metadata: BTreeMap<String, serde_json::Value>,
2086    /// Typed tool-execution policy (per-call timeouts + concurrency bound)
2087    /// applied to the normal LLM-driven tool dispatch loop. Populated by the
2088    /// composition seam via `AgentBuilder::with_tools_config`; defaults to
2089    /// `ToolsConfig::default()` for standalone/test construction.
2090    pub(crate) tools_config: crate::config::ToolsConfig,
2091}
2092
2093#[derive(Clone)]
2094pub(crate) struct CompactionRollbackState {
2095    pub(crate) rollback_session: Session,
2096    pub(crate) rollback_last_input_tokens: u64,
2097    pub(crate) rollback_compaction_cadence: SessionCompactionCadence,
2098}
2099
2100pub(crate) enum CompactionTransactionPhase {
2101    AwaitingRuntimeCommit(Box<CompactionRollbackState>),
2102    RuntimeCommitted { bookkeeping_complete: bool },
2103    AbortPending { cadence_persist_pending: bool },
2104}
2105
2106pub(crate) struct CompactionTransaction {
2107    pub(crate) phase: CompactionTransactionPhase,
2108    pub(crate) projections: Vec<crate::memory::CompactionProjectionId>,
2109}
2110
2111#[cfg(test)]
2112#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2113mod tests {
2114    use super::{
2115        AgentToolDispatcher, CommsRuntime, DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS,
2116        FilteredToolDispatcher, InlinePeerNotificationPolicy, ToolDispatchContext,
2117    };
2118    use crate::comms::{
2119        PeerAddress, PeerId, PeerName, PeerTransport, SendError, TrustedPeerDescriptor,
2120    };
2121    use crate::types::{ContentBlock, ContentInput, ToolCallView, ToolDef, ToolResult};
2122    use async_trait::async_trait;
2123    use serde_json::json;
2124    use std::sync::Arc;
2125    use tokio::sync::Notify;
2126
2127    struct NoopCommsRuntime {
2128        notify: Arc<Notify>,
2129    }
2130
2131    struct ContextAwareToolDispatcher;
2132
2133    struct ExactExecutionDispatcher {
2134        catalog: Arc<[crate::ToolCatalogEntry]>,
2135    }
2136
2137    struct HybridExecutionDispatcher {
2138        catalog: Arc<[crate::ToolCatalogEntry]>,
2139    }
2140
2141    struct StreamingExecutionDispatcher {
2142        catalog: Arc<[crate::ToolCatalogEntry]>,
2143        saw_streaming_context: Arc<std::sync::atomic::AtomicBool>,
2144    }
2145
2146    struct IdenticalMutationDispatcher {
2147        tool: ToolDef,
2148        epoch: std::sync::atomic::AtomicU64,
2149        mutate_on_resolve: bool,
2150    }
2151
2152    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2153    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2154    impl AgentToolDispatcher for ContextAwareToolDispatcher {
2155        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2156            Arc::from([Arc::new(ToolDef {
2157                name: "inspect_context".into(),
2158                description: "inspect context".to_string(),
2159                input_schema: json!({"type": "object"}),
2160                provenance: None,
2161            })])
2162        }
2163
2164        async fn dispatch(
2165            &self,
2166            call: ToolCallView<'_>,
2167        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2168            Ok(ToolResult::new(
2169                call.id.to_string(),
2170                json!({"saw_context_image": false}).to_string(),
2171                false,
2172            )
2173            .into())
2174        }
2175
2176        async fn dispatch_with_context(
2177            &self,
2178            call: ToolCallView<'_>,
2179            context: &ToolDispatchContext,
2180        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2181            let saw_context_image = context
2182                .current_turn()
2183                .and_then(|turn| turn.image_ref(0))
2184                .and_then(|image_ref| context.current_turn_image(image_ref))
2185                .is_some();
2186            Ok(ToolResult::new(
2187                call.id.to_string(),
2188                json!({"saw_context_image": saw_context_image}).to_string(),
2189                false,
2190            )
2191            .into())
2192        }
2193    }
2194
2195    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2196    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2197    impl AgentToolDispatcher for ExactExecutionDispatcher {
2198        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2199            self.catalog
2200                .iter()
2201                .filter(|entry| entry.currently_callable())
2202                .map(|entry| Arc::clone(&entry.tool))
2203                .collect::<Vec<_>>()
2204                .into()
2205        }
2206
2207        fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2208            crate::ToolCatalogCapabilities {
2209                exact_catalog: true,
2210                may_require_catalog_control_plane: false,
2211            }
2212        }
2213
2214        fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2215            Arc::clone(&self.catalog)
2216        }
2217
2218        async fn dispatch(
2219            &self,
2220            call: ToolCallView<'_>,
2221        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2222            Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2223        }
2224    }
2225
2226    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2227    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2228    impl AgentToolDispatcher for HybridExecutionDispatcher {
2229        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2230            self.catalog
2231                .iter()
2232                .filter(|entry| entry.currently_callable())
2233                .map(|entry| Arc::clone(&entry.tool))
2234                .collect::<Vec<_>>()
2235                .into()
2236        }
2237
2238        fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2239            crate::ToolCatalogCapabilities {
2240                exact_catalog: true,
2241                may_require_catalog_control_plane: false,
2242            }
2243        }
2244
2245        fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2246            Arc::clone(&self.catalog)
2247        }
2248
2249        fn resolve_execution_plan(
2250            &self,
2251            call: ToolCallView<'_>,
2252            _dispatch_context: &ToolDispatchContext,
2253            resolution_context: &crate::ToolExecutionResolutionContext,
2254        ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2255            let entry = self
2256                .catalog
2257                .iter()
2258                .find(|entry| entry.tool.name == call.name)
2259                .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
2260                    tool_name: call.name.to_string(),
2261                })?;
2262            let arguments: serde_json::Value =
2263                serde_json::from_str(call.args.get()).map_err(|error| {
2264                    crate::ToolExecutionResolutionError::InvalidArguments {
2265                        tool_name: call.name.to_string(),
2266                        reason: error.to_string(),
2267                    }
2268                })?;
2269            let mode = if arguments["run_detached"] == true {
2270                crate::ToolExecutionMode::Detached
2271            } else {
2272                crate::ToolExecutionMode::Fast
2273            };
2274            entry
2275                .execution
2276                .resolve(mode, resolution_context.deadlines().clone())
2277                .map_err(crate::ToolExecutionResolutionError::from)
2278        }
2279
2280        async fn dispatch(
2281            &self,
2282            call: ToolCallView<'_>,
2283        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2284            Ok(ToolResult::new(
2285                call.id.to_string(),
2286                json!({"owner": "filtered-hybrid-owner"}).to_string(),
2287                false,
2288            )
2289            .into())
2290        }
2291
2292        async fn dispatch_resolved_with_context(
2293            &self,
2294            call: ToolCallView<'_>,
2295            _context: &ToolDispatchContext,
2296            plan: &crate::ResolvedToolExecutionPlan,
2297        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2298            if plan.mode() != crate::ToolExecutionMode::Detached {
2299                return Err(crate::ToolError::execution_failed(
2300                    "test detached owner received the wrong plan",
2301                ));
2302            }
2303            self.dispatch(call).await
2304        }
2305    }
2306
2307    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2308    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2309    impl AgentToolDispatcher for StreamingExecutionDispatcher {
2310        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2311            self.catalog
2312                .iter()
2313                .map(|entry| Arc::clone(&entry.tool))
2314                .collect::<Vec<_>>()
2315                .into()
2316        }
2317
2318        fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2319            crate::ToolCatalogCapabilities {
2320                exact_catalog: true,
2321                may_require_catalog_control_plane: false,
2322            }
2323        }
2324
2325        fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2326            Arc::clone(&self.catalog)
2327        }
2328
2329        async fn dispatch(
2330            &self,
2331            call: ToolCallView<'_>,
2332        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2333            Err(crate::ToolError::unavailable(
2334                call.name,
2335                crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2336            ))
2337        }
2338
2339        async fn dispatch_resolved_with_context(
2340            &self,
2341            call: ToolCallView<'_>,
2342            context: &ToolDispatchContext,
2343            plan: &crate::ResolvedToolExecutionPlan,
2344        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2345            if plan.mode() != crate::ToolExecutionMode::Streaming {
2346                return Err(crate::ToolError::execution_failed(
2347                    "streaming owner received a non-streaming plan",
2348                ));
2349            }
2350            let streaming = context.streaming().ok_or_else(|| {
2351                crate::ToolError::unavailable(
2352                    call.name,
2353                    crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2354                )
2355            })?;
2356            streaming
2357                .progress()
2358                .try_report(
2359                    crate::ToolProgressFrame::message("accepted through wrapper")
2360                        .map_err(|error| crate::ToolError::other(error.to_string()))?,
2361                )
2362                .map_err(|error| crate::ToolError::other(error.to_string()))?;
2363            self.saw_streaming_context
2364                .store(true, std::sync::atomic::Ordering::SeqCst);
2365            Ok(ToolResult::new(call.id.to_string(), "stream complete".to_string(), false).into())
2366        }
2367    }
2368
2369    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2370    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2371    impl AgentToolDispatcher for IdenticalMutationDispatcher {
2372        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2373            Arc::from([Arc::new(self.tool.clone())])
2374        }
2375
2376        fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2377            crate::ToolCatalogCapabilities {
2378                exact_catalog: true,
2379                may_require_catalog_control_plane: false,
2380            }
2381        }
2382
2383        fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2384            Arc::from([crate::ToolCatalogEntry::session_inline(
2385                Arc::new(self.tool.clone()),
2386                true,
2387            )])
2388        }
2389
2390        fn execution_binding_epoch(&self, _tool_name: &str) -> u64 {
2391            self.epoch.load(std::sync::atomic::Ordering::SeqCst)
2392        }
2393
2394        fn resolve_execution_plan(
2395            &self,
2396            _call: ToolCallView<'_>,
2397            _dispatch_context: &ToolDispatchContext,
2398            resolution_context: &crate::ToolExecutionResolutionContext,
2399        ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2400            let plan = crate::ToolExecutionContract::default()
2401                .resolve_default(resolution_context.deadlines().clone())
2402                .map_err(crate::ToolExecutionResolutionError::from)?;
2403            if self.mutate_on_resolve {
2404                self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2405            }
2406            Ok(plan)
2407        }
2408
2409        async fn dispatch(
2410            &self,
2411            call: ToolCallView<'_>,
2412        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2413            Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2414        }
2415    }
2416
2417    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2418    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2419    impl CommsRuntime for NoopCommsRuntime {
2420        async fn drain_messages(&self) -> Vec<String> {
2421            Vec::new()
2422        }
2423
2424        fn inbox_notify(&self) -> std::sync::Arc<Notify> {
2425            self.notify.clone()
2426        }
2427    }
2428
2429    #[tokio::test]
2430    async fn test_comms_runtime_trait_defaults_hide_unimplemented_features() {
2431        let runtime = NoopCommsRuntime {
2432            notify: Arc::new(Notify::new()),
2433        };
2434        assert!(<NoopCommsRuntime as CommsRuntime>::public_key(&runtime).is_none());
2435        // The only mutable trust seam is apply_trust_mutation; without a
2436        // generated handoff it fails closed.
2437        let peer = TrustedPeerDescriptor {
2438            peer_id: PeerId::new(),
2439            name: PeerName::new("peer-a").expect("valid peer name"),
2440            address: PeerAddress::new(PeerTransport::Inproc, "peer-a"),
2441            pubkey: [0u8; 32],
2442        };
2443        let result =
2444            <NoopCommsRuntime as CommsRuntime>::add_private_trusted_peer(&runtime, peer).await;
2445        assert!(matches!(result, Err(SendError::Unsupported(_))));
2446    }
2447
2448    /// T-12: bridge-reply waiter + declared-reply-endpoint trait defaults.
2449    /// `take_bridge_reply_waiter` → None (no registry),
2450    /// `has_bridge_reply_waiter` → false, and
2451    /// `stage_declared_reply_endpoint` fails typed (never a silent no-op) so
2452    /// a caller cannot mistake a dropped security-relevant repair for success.
2453    #[tokio::test]
2454    async fn test_comms_runtime_bridge_reply_defaults() {
2455        let runtime = NoopCommsRuntime {
2456            notify: Arc::new(Notify::new()),
2457        };
2458        let interaction_id = crate::interaction::InteractionId(uuid::Uuid::new_v4());
2459        assert!(
2460            <NoopCommsRuntime as CommsRuntime>::take_bridge_reply_waiter(&runtime, &interaction_id)
2461                .is_none()
2462        );
2463        assert!(
2464            !<NoopCommsRuntime as CommsRuntime>::has_bridge_reply_waiter(&runtime, &interaction_id)
2465        );
2466        let staged = <NoopCommsRuntime as CommsRuntime>::stage_declared_reply_endpoint(
2467            &runtime,
2468            PeerId::new(),
2469            [0x11u8; 32],
2470            "tcp://127.0.0.1:1".to_string(),
2471        )
2472        .await;
2473        assert!(matches!(staged, Err(SendError::Unsupported(_))));
2474    }
2475
2476    #[tokio::test]
2477    async fn filtered_tool_dispatcher_preserves_dispatch_context() {
2478        let dispatcher =
2479            FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), ["inspect_context"]);
2480        let args = serde_json::value::RawValue::from_string("{}".to_string())
2481            .expect("empty object should be valid JSON");
2482        let call = ToolCallView {
2483            id: "ctx-1",
2484            name: "inspect_context",
2485            args: &args,
2486        };
2487        let context = ToolDispatchContext::from_current_turn_input(&ContentInput::Blocks(vec![
2488            ContentBlock::Image {
2489                media_type: "image/png".to_string(),
2490                data: "abc".into(),
2491            },
2492        ]));
2493
2494        let outcome = dispatcher
2495            .dispatch_with_context(call, &context)
2496            .await
2497            .expect("filtered wrapper should dispatch");
2498        let payload: serde_json::Value =
2499            serde_json::from_str(&outcome.result.text_content()).expect("tool result JSON");
2500        assert_eq!(payload["saw_context_image"], true);
2501    }
2502
2503    #[test]
2504    fn default_execution_plan_resolver_uses_exact_catalog_contract() {
2505        use crate::{
2506            DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2507            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2508            ToolExecutionMode, ToolExecutionResolutionContext,
2509        };
2510        use std::collections::BTreeSet;
2511        use std::time::Duration;
2512
2513        let detached = DetachedToolExecutionPolicy::new(
2514            RunnerIdentity::new("homecore.security_scan", "v1").unwrap(),
2515            RestartClass::NonResumable,
2516            IdempotencyScope::InteractionAndArguments,
2517            Duration::from_secs(10),
2518        )
2519        .unwrap();
2520        let contract = ToolExecutionContract::new(
2521            BTreeSet::from([ToolExecutionMode::Detached]),
2522            ToolExecutionMode::Detached,
2523            None,
2524            Some(detached),
2525        )
2526        .unwrap();
2527        let dispatcher = ExactExecutionDispatcher {
2528            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2529                Arc::new(ToolDef::new(
2530                    "security_scan",
2531                    "scan",
2532                    json!({"type": "object"}),
2533                )),
2534                true,
2535            )
2536            .with_execution_contract(contract)]),
2537        };
2538        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2539        let call = ToolCallView {
2540            id: "call-1",
2541            name: "security_scan",
2542            args: &args,
2543        };
2544        let resolution = ToolExecutionResolutionContext::new(
2545            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2546                ToolDeadlineOwner::CoreToolDispatch,
2547                Duration::from_secs(600),
2548            )])
2549            .unwrap(),
2550        );
2551
2552        let plan = dispatcher
2553            .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2554            .expect("declared plan resolves");
2555
2556        assert_eq!(plan.mode(), ToolExecutionMode::Detached);
2557        assert_eq!(
2558            plan.deadlines().effective_timeout(),
2559            Some(Duration::from_secs(10))
2560        );
2561        assert_eq!(
2562            plan.deadlines().winner().map(|winner| winner.owner()),
2563            Some(ToolDeadlineOwner::DetachedSubmission)
2564        );
2565    }
2566
2567    #[tokio::test]
2568    async fn default_resolved_dispatch_refuses_detached_plan_before_ordinary_dispatch() {
2569        use crate::{
2570            DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2571            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2572            ToolExecutionMode, ToolExecutionResolutionContext,
2573        };
2574        use std::collections::BTreeSet;
2575        use std::time::Duration;
2576
2577        let detached = DetachedToolExecutionPolicy::new(
2578            RunnerIdentity::new("detached.owner", "v1").unwrap(),
2579            RestartClass::NonResumable,
2580            IdempotencyScope::ToolCall,
2581            Duration::from_secs(10),
2582        )
2583        .unwrap();
2584        let contract = ToolExecutionContract::new(
2585            BTreeSet::from([ToolExecutionMode::Detached]),
2586            ToolExecutionMode::Detached,
2587            None,
2588            Some(detached),
2589        )
2590        .unwrap();
2591        let dispatcher = ExactExecutionDispatcher {
2592            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2593                Arc::new(ToolDef::new(
2594                    "security_scan",
2595                    "scan",
2596                    json!({"type": "object"}),
2597                )),
2598                true,
2599            )
2600            .with_execution_contract(contract)]),
2601        };
2602        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2603        let call = ToolCallView {
2604            id: "detached-call",
2605            name: "security_scan",
2606            args: &args,
2607        };
2608        let context = ToolDispatchContext::default();
2609        let resolution = ToolExecutionResolutionContext::new(
2610            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2611                ToolDeadlineOwner::CoreToolDispatch,
2612                Duration::from_secs(600),
2613            )])
2614            .unwrap(),
2615        );
2616        let plan = dispatcher
2617            .resolve_execution_plan(call, &context, &resolution)
2618            .expect("detached plan resolves");
2619
2620        let error = dispatcher
2621            .dispatch_resolved_with_context(call, &context, &plan)
2622            .await
2623            .expect_err("the default dispatcher must not lower detached work to dispatch()");
2624
2625        assert!(matches!(
2626            error,
2627            crate::ToolError::Unavailable {
2628                reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2629                ..
2630            }
2631        ));
2632    }
2633
2634    #[tokio::test]
2635    async fn fenced_streaming_dispatch_mints_context_and_filtered_wrapper_preserves_it() {
2636        use crate::{
2637            StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2638            ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2639            ToolExecutionResolutionContext,
2640        };
2641        use std::collections::BTreeSet;
2642        use std::time::Duration;
2643
2644        let contract = ToolExecutionContract::new(
2645            BTreeSet::from([ToolExecutionMode::Streaming]),
2646            ToolExecutionMode::Streaming,
2647            Some(
2648                StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2649                    .unwrap(),
2650            ),
2651            None,
2652        )
2653        .unwrap();
2654        let saw_streaming_context = Arc::new(std::sync::atomic::AtomicBool::new(false));
2655        let owner = StreamingExecutionDispatcher {
2656            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2657                Arc::new(ToolDef::new(
2658                    "stream_scan",
2659                    "stream scan",
2660                    json!({"type": "object"}),
2661                )),
2662                true,
2663            )
2664            .with_execution_contract(contract)]),
2665            saw_streaming_context: Arc::clone(&saw_streaming_context),
2666        };
2667        let dispatcher = Arc::new(FilteredToolDispatcher::new(
2668            Arc::new(owner),
2669            ["stream_scan"],
2670        ));
2671        let filtered_catalog = dispatcher.tool_catalog();
2672        assert_eq!(
2673            filtered_catalog[0].execution.default_mode(),
2674            ToolExecutionMode::Streaming
2675        );
2676        let filtered_policy = filtered_catalog[0]
2677            .execution
2678            .streaming_policy()
2679            .expect("wrapper preserves the streaming registration");
2680        assert_eq!(filtered_policy.inactivity_timeout(), Duration::from_secs(5));
2681        assert_eq!(filtered_policy.absolute_timeout(), Duration::from_secs(30));
2682        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2683        let call = ToolCallView {
2684            id: "stream-call",
2685            name: "stream_scan",
2686            args: &args,
2687        };
2688        let context = ToolDispatchContext::default();
2689        assert!(
2690            context.streaming().is_none(),
2691            "callers cannot pre-mint the supervised streaming context"
2692        );
2693        let resolution = ToolExecutionResolutionContext::new(
2694            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2695                ToolDeadlineOwner::CoreToolDispatch,
2696                Duration::from_secs(60),
2697            )])
2698            .unwrap(),
2699        );
2700        let plan =
2701            crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2702                .expect("streaming plan resolves through wrapper");
2703
2704        let outcome =
2705            crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2706                .await
2707                .expect("streaming dispatch completes");
2708
2709        assert_eq!(outcome.result.text_content(), "stream complete");
2710        assert!(
2711            saw_streaming_context.load(std::sync::atomic::Ordering::SeqCst),
2712            "the wrapper must preserve the exact supervised context"
2713        );
2714    }
2715
2716    #[tokio::test]
2717    async fn declared_streaming_without_a_mode_owner_fails_closed_before_plain_dispatch() {
2718        use crate::{
2719            StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2720            ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2721            ToolExecutionResolutionContext,
2722        };
2723        use std::collections::BTreeSet;
2724        use std::time::Duration;
2725
2726        let contract = ToolExecutionContract::new(
2727            BTreeSet::from([ToolExecutionMode::Streaming]),
2728            ToolExecutionMode::Streaming,
2729            Some(
2730                StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2731                    .unwrap(),
2732            ),
2733            None,
2734        )
2735        .unwrap();
2736        let dispatcher = Arc::new(ExactExecutionDispatcher {
2737            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2738                Arc::new(ToolDef::new(
2739                    "ownerless_stream",
2740                    "ownerless",
2741                    json!({"type": "object"}),
2742                )),
2743                true,
2744            )
2745            .with_execution_contract(contract)]),
2746        });
2747        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2748        let call = ToolCallView {
2749            id: "ownerless-call",
2750            name: "ownerless_stream",
2751            args: &args,
2752        };
2753        let context = ToolDispatchContext::default();
2754        let resolution = ToolExecutionResolutionContext::new(
2755            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2756                ToolDeadlineOwner::CoreToolDispatch,
2757                Duration::from_secs(60),
2758            )])
2759            .unwrap(),
2760        );
2761        let plan =
2762            crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2763                .expect("declaration resolves");
2764
2765        let error = crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2766            .await
2767            .expect_err("missing streaming owner must fail closed");
2768        assert!(matches!(
2769            error,
2770            crate::ToolError::Unavailable {
2771                reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2772                ..
2773            }
2774        ));
2775    }
2776
2777    #[test]
2778    fn filtered_execution_plan_resolver_rejects_policy_denied_tool() {
2779        use crate::{
2780            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2781            ToolExecutionResolutionContext, ToolExecutionResolutionError,
2782        };
2783        use std::time::Duration;
2784
2785        let dispatcher =
2786            FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), Vec::<String>::new());
2787        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2788        let call = ToolCallView {
2789            id: "call-hidden",
2790            name: "inspect_context",
2791            args: &args,
2792        };
2793        let resolution = ToolExecutionResolutionContext::new(
2794            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2795                ToolDeadlineOwner::CoreToolDispatch,
2796                Duration::from_secs(600),
2797            )])
2798            .unwrap(),
2799        );
2800
2801        let error = dispatcher
2802            .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2803            .expect_err("hidden tools must not resolve");
2804
2805        assert_eq!(
2806            error,
2807            ToolExecutionResolutionError::AccessDenied {
2808                tool_name: "inspect_context".to_string(),
2809            }
2810        );
2811    }
2812
2813    #[tokio::test]
2814    async fn filtered_execution_plan_forwards_hybrid_resolution_to_visible_owner() {
2815        use crate::{
2816            DetachedToolExecutionPolicy, IdempotencyScope, ResolvedExecutionKind, RestartClass,
2817            RunnerIdentity, ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2818            ToolExecutionContract, ToolExecutionMode, ToolExecutionResolutionContext,
2819        };
2820        use std::collections::BTreeSet;
2821        use std::time::Duration;
2822
2823        let detached = DetachedToolExecutionPolicy::new(
2824            RunnerIdentity::new("filtered-hybrid-owner", "v1").unwrap(),
2825            RestartClass::NonResumable,
2826            IdempotencyScope::InteractionAndArguments,
2827            Duration::from_secs(10),
2828        )
2829        .unwrap();
2830        let contract = ToolExecutionContract::new(
2831            BTreeSet::from([ToolExecutionMode::Fast, ToolExecutionMode::Detached]),
2832            ToolExecutionMode::Fast,
2833            None,
2834            Some(detached),
2835        )
2836        .unwrap();
2837        let dispatcher = FilteredToolDispatcher::new(
2838            Arc::new(HybridExecutionDispatcher {
2839                catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2840                    Arc::new(ToolDef::new(
2841                        "hybrid_scan",
2842                        "filtered-hybrid-owner catalog",
2843                        json!({"type": "object"}),
2844                    )),
2845                    true,
2846                )
2847                .with_execution_contract(contract)]),
2848            }),
2849            ["hybrid_scan"],
2850        );
2851        let args = serde_json::value::RawValue::from_string(r#"{"run_detached":true}"#.to_string())
2852            .unwrap();
2853        let call = ToolCallView {
2854            id: "call-hybrid",
2855            name: "hybrid_scan",
2856            args: &args,
2857        };
2858        let resolution = ToolExecutionResolutionContext::new(
2859            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2860                ToolDeadlineOwner::CoreToolDispatch,
2861                Duration::from_secs(600),
2862            )])
2863            .unwrap(),
2864        );
2865
2866        let catalog = dispatcher.tool_catalog();
2867        assert_eq!(catalog[0].execution.default_mode(), ToolExecutionMode::Fast);
2868        assert_eq!(catalog[0].tool.description, "filtered-hybrid-owner catalog");
2869
2870        let plan = dispatcher
2871            .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2872            .expect("visible hybrid tool should delegate plan resolution");
2873        dispatcher
2874            .validate_resolved_execution_plan(call, &resolution, &plan)
2875            .expect("hybrid-selected advertised mode must validate");
2876        let ResolvedExecutionKind::Detached(policy) = plan.kind() else {
2877            panic!("hybrid resolver should select its non-default detached mode");
2878        };
2879        assert_eq!(policy.runner().name(), "filtered-hybrid-owner");
2880
2881        let outcome = dispatcher
2882            .dispatch_resolved_with_context(call, &ToolDispatchContext::default(), &plan)
2883            .await
2884            .expect("visible hybrid tool should preserve resolved dispatch");
2885        let payload: serde_json::Value =
2886            serde_json::from_str(&outcome.result.text_content()).unwrap();
2887        assert_eq!(payload["owner"], "filtered-hybrid-owner");
2888    }
2889
2890    #[test]
2891    fn root_validation_rejects_plan_outside_live_advertised_contract() {
2892        use crate::{
2893            DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2894            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2895            ToolExecutionContractError, ToolExecutionMode, ToolExecutionResolutionContext,
2896            ToolExecutionResolutionError,
2897        };
2898        use std::collections::BTreeSet;
2899        use std::time::Duration;
2900
2901        let dispatcher = ExactExecutionDispatcher {
2902            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2903                Arc::new(ToolDef::new(
2904                    "fast_only",
2905                    "fast only",
2906                    json!({"type": "object"}),
2907                )),
2908                true,
2909            )]),
2910        };
2911        let detached = DetachedToolExecutionPolicy::new(
2912            RunnerIdentity::new("dishonest.owner", "v1").unwrap(),
2913            RestartClass::NonResumable,
2914            IdempotencyScope::ToolCall,
2915            Duration::from_secs(10),
2916        )
2917        .unwrap();
2918        let dishonest_contract = ToolExecutionContract::new(
2919            BTreeSet::from([ToolExecutionMode::Detached]),
2920            ToolExecutionMode::Detached,
2921            None,
2922            Some(detached),
2923        )
2924        .unwrap();
2925        let resolution = ToolExecutionResolutionContext::new(
2926            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2927                ToolDeadlineOwner::CoreToolDispatch,
2928                Duration::from_secs(600),
2929            )])
2930            .unwrap(),
2931        );
2932        let plan = dishonest_contract
2933            .resolve_default(resolution.deadlines().clone())
2934            .unwrap();
2935        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2936        let call = ToolCallView {
2937            id: "dishonest-plan",
2938            name: "fast_only",
2939            args: &args,
2940        };
2941
2942        assert_eq!(
2943            dispatcher.validate_resolved_execution_plan(call, &resolution, &plan),
2944            Err(ToolExecutionResolutionError::Contract(
2945                ToolExecutionContractError::RequestedModeUnsupported {
2946                    requested_mode: ToolExecutionMode::Detached,
2947                }
2948            ))
2949        );
2950    }
2951
2952    #[tokio::test]
2953    async fn universal_root_fence_accepts_rebuilt_equivalent_catalog_arcs() {
2954        use crate::{
2955            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2956            ToolExecutionResolutionContext,
2957        };
2958        use std::time::Duration;
2959
2960        let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
2961            tool: ToolDef::new("rebuilt", "rebuilt", json!({"type": "object"})),
2962            epoch: std::sync::atomic::AtomicU64::new(0),
2963            mutate_on_resolve: false,
2964        });
2965        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2966        let call = ToolCallView {
2967            id: "rebuilt-arcs",
2968            name: "rebuilt",
2969            args: &args,
2970        };
2971        let resolution = ToolExecutionResolutionContext::new(
2972            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2973                ToolDeadlineOwner::CoreToolDispatch,
2974                Duration::from_secs(600),
2975            )])
2976            .unwrap(),
2977        );
2978
2979        let plan = crate::resolve_tool_execution_plan_fenced(
2980            &dispatcher,
2981            call,
2982            &ToolDispatchContext::default(),
2983            &resolution,
2984        )
2985        .expect("equivalent rebuilt catalog projections resolve");
2986        crate::dispatch_tool_execution_plan_fenced(
2987            &dispatcher,
2988            call,
2989            &ToolDispatchContext::default(),
2990            &plan,
2991        )
2992        .await
2993        .expect("equivalent rebuilt catalog projections dispatch");
2994    }
2995
2996    #[tokio::test]
2997    async fn universal_root_fence_binds_canonical_call_identity() {
2998        use crate::{
2999            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3000            ToolExecutionResolutionContext, ToolUnavailableReason,
3001        };
3002        use std::time::Duration;
3003
3004        let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
3005            tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
3006            epoch: std::sync::atomic::AtomicU64::new(0),
3007            mutate_on_resolve: false,
3008        });
3009        let resolved_args =
3010            serde_json::value::RawValue::from_string(r#"{"a":1,"b":2}"#.to_string()).unwrap();
3011        let equivalent_args =
3012            serde_json::value::RawValue::from_string(r#"{ "b": 2, "a": 1 }"#.to_string()).unwrap();
3013        let changed_args =
3014            serde_json::value::RawValue::from_string(r#"{"a":1,"b":3}"#.to_string()).unwrap();
3015        let resolved_call = ToolCallView {
3016            id: "bound-call",
3017            name: "bound",
3018            args: &resolved_args,
3019        };
3020        let resolution = ToolExecutionResolutionContext::new(
3021            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3022                ToolDeadlineOwner::CoreToolDispatch,
3023                Duration::from_secs(600),
3024            )])
3025            .unwrap(),
3026        );
3027        let plan = crate::resolve_tool_execution_plan_fenced(
3028            &dispatcher,
3029            resolved_call,
3030            &ToolDispatchContext::default(),
3031            &resolution,
3032        )
3033        .unwrap();
3034
3035        crate::dispatch_tool_execution_plan_fenced(
3036            &dispatcher,
3037            ToolCallView {
3038                args: &equivalent_args,
3039                ..resolved_call
3040            },
3041            &ToolDispatchContext::default(),
3042            &plan,
3043        )
3044        .await
3045        .expect("canonical JSON-equivalent arguments preserve call identity");
3046
3047        let error = crate::dispatch_tool_execution_plan_fenced(
3048            &dispatcher,
3049            ToolCallView {
3050                args: &changed_args,
3051                ..resolved_call
3052            },
3053            &ToolDispatchContext::default(),
3054            &plan,
3055        )
3056        .await
3057        .expect_err("different arguments must not dispatch under the old plan");
3058        assert!(matches!(
3059            error,
3060            crate::ToolError::Unavailable {
3061                reason: ToolUnavailableReason::ExecutionOwnerChanged,
3062                ..
3063            }
3064        ));
3065    }
3066
3067    #[tokio::test]
3068    async fn universal_root_fence_rejects_fresh_dispatcher_reconstruction() {
3069        use crate::{
3070            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3071            ToolExecutionResolutionContext, ToolUnavailableReason,
3072        };
3073        use std::time::Duration;
3074
3075        let make_dispatcher = || -> Arc<dyn AgentToolDispatcher> {
3076            Arc::new(IdenticalMutationDispatcher {
3077                tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
3078                epoch: std::sync::atomic::AtomicU64::new(0),
3079                mutate_on_resolve: false,
3080            })
3081        };
3082        let original = make_dispatcher();
3083        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3084        let call = ToolCallView {
3085            id: "reconstructed",
3086            name: "bound",
3087            args: &args,
3088        };
3089        let resolution = ToolExecutionResolutionContext::new(
3090            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3091                ToolDeadlineOwner::CoreToolDispatch,
3092                Duration::from_secs(600),
3093            )])
3094            .unwrap(),
3095        );
3096        let plan = crate::resolve_tool_execution_plan_fenced(
3097            &original,
3098            call,
3099            &ToolDispatchContext::default(),
3100            &resolution,
3101        )
3102        .unwrap();
3103        let reconstructed = make_dispatcher();
3104
3105        let error = crate::dispatch_tool_execution_plan_fenced(
3106            &reconstructed,
3107            call,
3108            &ToolDispatchContext::default(),
3109            &plan,
3110        )
3111        .await
3112        .expect_err("fresh reconstruction must never reproduce ephemeral root authority");
3113        assert!(matches!(
3114            error,
3115            crate::ToolError::Unavailable {
3116                reason: ToolUnavailableReason::ExecutionOwnerChanged,
3117                ..
3118            }
3119        ));
3120    }
3121
3122    #[test]
3123    fn universal_root_fence_rejects_direct_identical_metadata_replacement() {
3124        use crate::{
3125            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3126            ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3127        };
3128        use std::time::Duration;
3129
3130        let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
3131            tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3132            epoch: std::sync::atomic::AtomicU64::new(0),
3133            mutate_on_resolve: true,
3134        });
3135        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3136        let call = ToolCallView {
3137            id: "direct-identical-replacement",
3138            name: "moving",
3139            args: &args,
3140        };
3141        let resolution = ToolExecutionResolutionContext::new(
3142            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3143                ToolDeadlineOwner::CoreToolDispatch,
3144                Duration::from_secs(600),
3145            )])
3146            .unwrap(),
3147        );
3148
3149        assert!(matches!(
3150            crate::resolve_tool_execution_plan_fenced(
3151                &dispatcher,
3152                call,
3153                &ToolDispatchContext::default(),
3154                &resolution,
3155            ),
3156            Err(ToolExecutionResolutionError::Unavailable {
3157                reason: ToolUnavailableReason::ExecutionOwnerChanged,
3158                ..
3159            })
3160        ));
3161    }
3162
3163    #[test]
3164    fn filtered_wrapper_composes_inner_live_binding_epoch() {
3165        use crate::{
3166            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3167            ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3168        };
3169        use std::time::Duration;
3170
3171        let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(FilteredToolDispatcher::new(
3172            Arc::new(IdenticalMutationDispatcher {
3173                tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3174                epoch: std::sync::atomic::AtomicU64::new(0),
3175                mutate_on_resolve: true,
3176            }),
3177            ["moving"],
3178        ));
3179        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3180        let call = ToolCallView {
3181            id: "filtered-identical-replacement",
3182            name: "moving",
3183            args: &args,
3184        };
3185        let resolution = ToolExecutionResolutionContext::new(
3186            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3187                ToolDeadlineOwner::CoreToolDispatch,
3188                Duration::from_secs(600),
3189            )])
3190            .unwrap(),
3191        );
3192
3193        assert!(matches!(
3194            crate::resolve_tool_execution_plan_fenced(
3195                &dispatcher,
3196                call,
3197                &ToolDispatchContext::default(),
3198                &resolution,
3199            ),
3200            Err(ToolExecutionResolutionError::Unavailable {
3201                reason: ToolUnavailableReason::ExecutionOwnerChanged,
3202                ..
3203            })
3204        ));
3205    }
3206
3207    #[test]
3208    fn test_inline_peer_notification_policy_from_raw() {
3209        assert_eq!(
3210            InlinePeerNotificationPolicy::try_from_raw(None),
3211            Ok(InlinePeerNotificationPolicy::AtMost(
3212                DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS
3213            ))
3214        );
3215        assert_eq!(
3216            InlinePeerNotificationPolicy::try_from_raw(Some(-1)),
3217            Ok(InlinePeerNotificationPolicy::Always)
3218        );
3219        assert_eq!(
3220            InlinePeerNotificationPolicy::try_from_raw(Some(0)),
3221            Ok(InlinePeerNotificationPolicy::Never)
3222        );
3223        assert_eq!(
3224            InlinePeerNotificationPolicy::try_from_raw(Some(25)),
3225            Ok(InlinePeerNotificationPolicy::AtMost(25))
3226        );
3227        assert_eq!(
3228            InlinePeerNotificationPolicy::try_from_raw(Some(-42)),
3229            Err(-42)
3230        );
3231    }
3232
3233    /// UNIT-002: DetachedOpCompletion serializes without operation_id.
3234    /// The app-facing control noun is job_id (CONTRACT-003).
3235    #[test]
3236    fn unit_002_detached_op_completion_has_no_operation_id() {
3237        use crate::agent::DetachedOpCompletion;
3238        use crate::ops_lifecycle::{OperationKind, OperationStatus};
3239
3240        let completion = DetachedOpCompletion {
3241            job_id: "j_test".into(),
3242            kind: OperationKind::BackgroundToolOp,
3243            status: OperationStatus::Completed,
3244            terminal_outcome: None,
3245            display_name: "test cmd".into(),
3246            detail: "ok".into(),
3247            elapsed_ms: None,
3248        };
3249        #[allow(clippy::unwrap_used)]
3250        let json = serde_json::to_value(&completion).unwrap();
3251        assert!(
3252            json.get("operation_id").is_none(),
3253            "operation_id must not appear in serialized DetachedOpCompletion (CONTRACT-003)"
3254        );
3255        assert!(
3256            json.get("job_id").is_some(),
3257            "job_id must be the app-facing control noun"
3258        );
3259    }
3260}