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    /// Drain canonical peer/event ingress candidates.
1706    ///
1707    /// This remains the live runtime drain bridge for call sites that consume
1708    /// the `PeerInputCandidate` noun directly. The underlying drain unit is
1709    /// identical to `ClassifiedInboxInteraction`, so the default
1710    /// implementation simply forwards the classified drain path.
1711    async fn drain_peer_input_candidates(&self) -> Vec<crate::interaction::PeerInputCandidate> {
1712        self.drain_classified_inbox_interactions()
1713            .await
1714            .unwrap_or_default()
1715    }
1716
1717    /// Snapshot the currently queued peer-ingress surface without draining it.
1718    ///
1719    /// This is a hidden diagnostic capability used while mapping the internal
1720    /// MeerkatMachine boundary onto existing comms ownership.
1721    async fn peer_ingress_queue_snapshot(
1722        &self,
1723    ) -> Result<crate::interaction::PeerIngressQueueSnapshot, CommsCapabilityError> {
1724        Err(CommsCapabilityError::Unsupported(
1725            "peer_ingress_queue_snapshot".to_string(),
1726        ))
1727    }
1728
1729    /// Snapshot the current peer runtime surface for MeerkatMachine mapping.
1730    ///
1731    /// This extends the queued ingress snapshot with the local trust membership
1732    /// that governs peer admission.
1733    async fn peer_ingress_runtime_snapshot(
1734        &self,
1735    ) -> Result<crate::interaction::PeerIngressRuntimeSnapshot, CommsCapabilityError> {
1736        Err(CommsCapabilityError::Unsupported(
1737            "peer_ingress_runtime_snapshot".to_string(),
1738        ))
1739    }
1740
1741    /// Snapshot only the public trust projection owned by generated public
1742    /// peer authority.
1743    ///
1744    /// Private/control-plane trust edges are admitted by separate generated
1745    /// private authority and must not be reconciled or removed by public peer
1746    /// projection owners.
1747    async fn public_trusted_peer_projection_snapshot(
1748        &self,
1749    ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1750        Err(CommsCapabilityError::Unsupported(
1751            "public_trusted_peer_projection_snapshot".to_string(),
1752        ))
1753    }
1754
1755    /// Snapshot the public trust projection owned by one generated source.
1756    ///
1757    /// This is the behavior-authority read used by generated trust
1758    /// reconciliation. Compatibility/public snapshots may still union public
1759    /// rows for display, but generated removals must diff only against rows
1760    /// previously installed by the same generated owner.
1761    async fn trusted_peer_projection_snapshot_for_source(
1762        &self,
1763        _source_kind: crate::comms::GeneratedCommsTrustAuthoritySourceKind,
1764    ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1765        Err(CommsCapabilityError::Unsupported(
1766            "trusted_peer_projection_snapshot_for_source".to_string(),
1767        ))
1768    }
1769
1770    /// Get a notification that fires only for actionable peer input.
1771    ///
1772    /// Default returns `Unsupported`. Comms-enabled runtimes must override.
1773    /// Used by the factory to bridge into `WaitTool` interrupt.
1774    fn actionable_input_notify(&self) -> Result<Arc<tokio::sync::Notify>, CommsCapabilityError> {
1775        Err(CommsCapabilityError::Unsupported(
1776            "actionable_input_notify".to_string(),
1777        ))
1778    }
1779
1780    /// Stage a one-shot reply endpoint for a Response to a peer outside the
1781    /// trust store.
1782    ///
1783    /// This is the legacy uncorrelated compatibility seam. It is
1784    /// Response-only, one-shot, and trust-store-losing; callers may supply
1785    /// only a machine-authorized endpoint already held in runtime state.
1786    /// Neither a Request's `reply_endpoint` nor any decoded payload/sender
1787    /// address is authority for this method. New ingress response paths use
1788    /// [`Self::stage_correlated_reply_endpoint`] instead.
1789    ///
1790    /// Parameters are primitives because core cannot name the comms-crate
1791    /// newtypes (dependency direction). Default fails typed, not no-op:
1792    /// silently dropping a reply-repair staging would strand the remote
1793    /// sender in a timeout with no cause. Callers decide policy — reply
1794    /// drains treat `Unsupported` as "runtime has no staging capability" and
1795    /// proceed, since in-proc runtimes resolve via the ingress route anyway.
1796    async fn stage_declared_reply_endpoint(
1797        &self,
1798        _dest: PeerId,
1799        _signer_pubkey: [u8; 32],
1800        _declared_address: String,
1801    ) -> Result<(), SendError> {
1802        Err(SendError::Unsupported(
1803            "declared reply endpoint staging not supported".to_string(),
1804        ))
1805    }
1806
1807    /// Stage an authenticated one-shot endpoint for the Response correlated
1808    /// to `in_reply_to` from `dest`.
1809    ///
1810    /// Unlike the legacy uncorrelated staging seam above, this endpoint is
1811    /// keyed by both peer identity and request id and therefore takes
1812    /// precedence over durable trust only for that exact Response. This is
1813    /// the only Request-ingress callback seam. `signer_pubkey` must
1814    /// come from a signature-verified envelope and derive `dest` in the
1815    /// concrete runtime. `declared_endpoint` must be the classifier's
1816    /// source-confined TCP projection: kernel-observed source IP plus the
1817    /// signed, nonzero declared port. Arbitrary payload addresses,
1818    /// sender-selected hosts, UDS addresses, and open-auth ingress are never
1819    /// callback authority.
1820    async fn stage_correlated_reply_endpoint(
1821        &self,
1822        _dest: PeerId,
1823        _in_reply_to: crate::interaction::InteractionId,
1824        _signer_pubkey: [u8; 32],
1825        _declared_endpoint: crate::comms::PeerAddress,
1826    ) -> Result<(), SendError> {
1827        Err(SendError::Unsupported(
1828            "correlated reply endpoint staging not supported".to_string(),
1829        ))
1830    }
1831
1832    /// Idempotently discard a previously staged correlated endpoint.
1833    /// Responders call this when validation or response sending fails before
1834    /// the Router consumes the exact one-shot entry.
1835    async fn unstage_correlated_reply_endpoint(
1836        &self,
1837        _dest: PeerId,
1838        _in_reply_to: crate::interaction::InteractionId,
1839    ) -> Result<(), SendError> {
1840        Err(SendError::Unsupported(
1841            "correlated reply endpoint cleanup not supported".to_string(),
1842        ))
1843    }
1844
1845    /// One-shot reply waiter for an agent-blocking bridge request (member
1846    /// upcall lane). Consulted by the comms drain BEFORE session injection: a
1847    /// taken waiter receives the terminal Response candidate (typed
1848    /// terminality intact) and the candidate never becomes session input.
1849    ///
1850    /// Returns `Some(sender)` only for a live waiter. A tombstoned (timed
1851    /// out) waiter entry is consumed and `None` is returned — pair with
1852    /// [`Self::has_bridge_reply_waiter`] to distinguish "tombstone consumed"
1853    /// (discard the late reply) from "never registered" (ordinary session
1854    /// path). Default: no registry (a query, not a capability — absence of a
1855    /// waiter is the universal normal case).
1856    fn take_bridge_reply_waiter(
1857        &self,
1858        _in_reply_to: &crate::interaction::InteractionId,
1859    ) -> Option<tokio::sync::oneshot::Sender<crate::interaction::PeerInputCandidate>> {
1860        None
1861    }
1862
1863    /// True when a bridge-reply waiter entry (live or tombstoned) is
1864    /// registered for `in_reply_to`. See [`Self::take_bridge_reply_waiter`].
1865    fn has_bridge_reply_waiter(&self, _in_reply_to: &crate::interaction::InteractionId) -> bool {
1866        false
1867    }
1868}
1869
1870/// The main Agent struct
1871pub struct Agent<C, T, S>
1872where
1873    C: AgentLlmClient + ?Sized,
1874    T: AgentToolDispatcher + ?Sized,
1875    S: AgentSessionStore + ?Sized,
1876{
1877    config: AgentConfig,
1878    client: Arc<C>,
1879    tools: Arc<T>,
1880    tool_scope: ToolScope,
1881    store: Arc<S>,
1882    session: Session,
1883    budget: Budget,
1884    retry_policy: RetryPolicy,
1885    depth: u32,
1886    pub(super) comms_runtime: Option<Arc<dyn CommsRuntime>>,
1887    pub(super) hook_engine: Option<Arc<dyn HookEngine>>,
1888    pub(super) hook_run_overrides: HookRunOverrides,
1889    /// Optional context compaction strategy.
1890    pub(crate) compactor: Option<Arc<dyn crate::compact::Compactor>>,
1891    /// Optional host-supplied compaction summary curator. When present it
1892    /// produces the compaction summary instead of the summarization LLM call.
1893    pub(crate) compaction_curator: Option<Arc<dyn crate::compact::CompactionCurator>>,
1894    /// Input tokens from the last LLM response (for compaction trigger).
1895    pub(crate) last_input_tokens: u64,
1896    /// Session-scoped compaction cadence tracked across runs.
1897    pub(crate) compaction_cadence: SessionCompactionCadence,
1898    /// Machine-issued compaction check parked until the request has been fully
1899    /// composed, blob-hydrated, tool-scoped, and provider-lowered.
1900    pub(crate) pending_compaction_boundary_index: Option<u64>,
1901    /// Exact pressure witness attached to the parked compaction check.
1902    pub(crate) pending_compaction_request_pressure: Option<crate::ProviderRequestPressure>,
1903    /// Pre-compaction pressure retained until the rebuilt request proves that
1904    /// compaction both decreased the body and brought it below the hard cap.
1905    pub(crate) post_compaction_pressure_check: Option<crate::ProviderRequestPressure>,
1906    /// Optional memory store for indexing compaction discards.
1907    pub(crate) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
1908    /// Runtime-owned resultful handoff for durable transcript+memory
1909    /// compaction pairs. Absent on standalone paths.
1910    pub(crate) compaction_commit_coordinator:
1911        Option<Arc<dyn crate::memory::CompactionCommitCoordinator>>,
1912    /// Typed lifecycle for the current transcript-rewrite + staged-memory
1913    /// transaction. Runtime reconciliation advances this to commit-only before
1914    /// touching the memory store; abort is legal only while runtime commit is
1915    /// still pending.
1916    pub(crate) compaction_transaction: Option<CompactionTransaction>,
1917    /// Deterministic projection identity installed immediately before the
1918    /// durable stage await. A hard interrupt can drop that await before a
1919    /// receipt reaches the transaction owner, so cleanup must retain the exact
1920    /// identity rather than infer empty RuntimeStore authority.
1921    pub(crate) in_flight_compaction_stage: Option<crate::memory::CompactionProjectionId>,
1922    /// Optional skill engine for per-turn `/skill-ref` activation.
1923    pub(crate) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
1924    /// Skill references to resolve and inject for the next turn.
1925    /// Set by surfaces before calling `run()`, consumed on run start.
1926    pub pending_skill_references: Option<Vec<crate::skills::SkillKey>>,
1927    /// Per-interaction event tap for streaming events to subscribers.
1928    pub(crate) event_tap: crate::event_tap::EventTap,
1929    /// Request-only exact-boundary context coordinator for this live actor.
1930    pub(crate) transient_turn_context_state: crate::session::TransientTurnContextStateHandle,
1931    /// Optional default event channel configured at build time.
1932    /// Used by run methods when no per-call event channel is provided.
1933    pub(crate) default_event_tx: Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>>,
1934    /// Optional session checkpointer for keep-alive persistence.
1935    ///
1936    /// Wired by `AgentBuilder::with_checkpointer`, installed by
1937    /// `PersistentSessionService`, and consumed only by active-run persistence.
1938    pub(crate) checkpointer: Option<Arc<dyn crate::SessionCheckpointer>>,
1939    /// Latest successful provisional physical write for the active run.
1940    ///
1941    /// This is actor-local transport state, never Session domain state. The
1942    /// actor removes it before each checkpoint await and installs only the
1943    /// exact returned successor.
1944    pub(crate) latest_run_checkpoint_receipt: Option<crate::RunCheckpointReceipt>,
1945    /// Optional blob store used to hydrate image refs at execution seams.
1946    pub(crate) blob_store: Option<Arc<dyn crate::BlobStore>>,
1947    /// Original error detail preserved from `terminalize_fatal_error` so
1948    /// `build_result` can include the actual failure message (e.g. the API
1949    /// error body) instead of only the generic terminal-cause description.
1950    pub(crate) terminal_error_detail: Option<String>,
1951    /// Structured metadata captured from that concrete error before the
1952    /// public result is normalized into `AgentError::TerminalFailure`.
1953    pub(crate) terminal_error_metadata: Option<crate::TurnErrorMetadata>,
1954    /// True once the current run has accepted `RunCompleted` hooks.
1955    pub(crate) run_completed_hooks_applied: bool,
1956    /// True once the current run's public `RunCompleted` event has been
1957    /// emitted. Extraction may continue afterward as a separate post-run phase.
1958    pub(crate) run_completed_event_emitted: bool,
1959    /// Comms intents that should be silently injected into the session
1960    /// without triggering an LLM turn. Matched against `InteractionContent::Request.intent`.
1961    #[allow(dead_code)] // Used by comms_impl when comms feature is enabled
1962    pub(crate) silent_comms_intents: Vec<String>,
1963    /// Optional shared lifecycle registry for async operations.
1964    pub(crate) ops_lifecycle: Option<Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>>,
1965    /// Optional completion feed for cursor-based completion delivery.
1966    pub(crate) completion_feed: Option<Arc<dyn crate::completion_feed::CompletionFeed>>,
1967    /// Shared epoch cursor state for runtime-backed cursor writeback.
1968    pub(crate) epoch_cursor_state: Option<Arc<crate::runtime_epoch::EpochCursorState>>,
1969    /// Local cursor into the completion feed — only the agent boundary advances this.
1970    pub(crate) applied_cursor: crate::completion_feed::CompletionSeq,
1971    /// Optional enrichment provider for completion display details.
1972    pub(crate) completion_enrichment:
1973        Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>>,
1974    /// Shared effective mob authority handle. Owned by the agent, passed to
1975    /// mob tools at construction for authorization reads. Updated by
1976    /// `apply_session_effects` after each tool batch as a derived projection
1977    /// of the canonical `session.build_state().mob_tool_authority_context`.
1978    pub(crate) mob_authority_handle:
1979        Option<Arc<std::sync::RwLock<crate::service::MobToolAuthorityContext>>>,
1980    /// Runtime-backed turn-state handle, provided by the session runtime bindings.
1981    pub(crate) turn_state_handle: Option<Arc<dyn crate::TurnStateHandle>>,
1982    /// Runtime-backed model-routing authority. Sticky fallback commits route
1983    /// through this handle in the compensated client/auth/machine transaction.
1984    pub(crate) model_routing_handle: Option<Arc<dyn crate::handles::ModelRoutingHandle>>,
1985    /// Runtime-owned durable sticky-fallback transaction coordinator.
1986    /// Standalone agents leave this absent and consume staged machine commits
1987    /// synchronously in-process.
1988    pub(crate) sticky_model_fallback_commit_coordinator:
1989        Option<Arc<dyn crate::handles::StickyModelFallbackCommitCoordinator>>,
1990    /// Saga state retained across cancellation while the supervised durable
1991    /// sticky-fallback transaction is in flight.
1992    pub(crate) pending_sticky_model_fallback_activation:
1993        Option<state::PendingStickyModelFallbackActivation>,
1994    /// Async operation references staged behind an external callback boundary.
1995    /// They are registered with the fresh continuation run before it can call
1996    /// the provider, preserving Barrier versus Detached semantics.
1997    pub(crate) pending_callback_async_ops: Option<Vec<crate::ops::AsyncOpRef>>,
1998    /// Effective model registry captured by the construction pipeline.
1999    /// Fallback profile and limit truth is freshly resolved through this exact
2000    /// registry before it can reach the routing machine.
2001    pub(crate) effective_model_registry: Option<Arc<crate::ModelRegistry>>,
2002    /// Registry-minted facts for the active model. This replaces client-local
2003    /// capability/limit projections as the durable source used by later turns.
2004    pub(crate) active_model_profile: Option<crate::ModelProfileWitness>,
2005    /// True when the runtime control plane must stamp execution kind metadata.
2006    pub(crate) runtime_execution_kind_required: bool,
2007    /// Typed execution intent for the current run, when this turn is owned by
2008    /// the runtime control plane rather than a direct surface call.
2009    pub(crate) runtime_execution_kind: Option<crate::lifecycle::RuntimeExecutionKind>,
2010    /// Exact per-call witness that the core turn machine admitted a runtime
2011    /// run. A completed future alone is not sufficient evidence: preflight
2012    /// failures can return before `StartConversationRun` and must never reuse
2013    /// the previous turn's terminal snapshot.
2014    pub(crate) runtime_started_run_id: Option<crate::lifecycle::RunId>,
2015    /// Machine-terminal failure observed for the exact runtime run above.
2016    /// Kept separate from the public `AgentError` so direct session surfaces
2017    /// preserve their original typed errors while the runtime can commit a
2018    /// failed-but-applied turn atomically.
2019    pub(crate) runtime_terminal_failure_witness:
2020        Option<Result<crate::TurnErrorMetadata, crate::error::AgentError>>,
2021    /// Stable transcript identity for the active runtime-owned turn.
2022    pub(crate) active_transcript_identity: Option<crate::types::TranscriptMessageIdentity>,
2023    /// Request-only host context for the active runtime-owned logical turn.
2024    ///
2025    /// This value is never appended to `session`; request composition projects
2026    /// it immediately before the admitted conversational user message.
2027    pub(crate) active_turn_request_contexts:
2028        Vec<crate::lifecycle::run_primitive::TurnRequestContext>,
2029    /// Runtime-backed external tool-surface diagnostic handle, when provided
2030    /// by the session runtime bindings.
2031    pub(crate) external_tool_surface_handle: Option<Arc<dyn crate::ExternalToolSurfaceHandle>>,
2032    /// Runtime-backed auth lease handle (Phase 1.5-rev).
2033    pub(crate) auth_lease_handle: Option<crate::handles::GeneratedAuthLeaseHandle>,
2034    /// Runtime-backed MCP server lifecycle handle (Phase 5G / T5g). When set,
2035    /// the agent loop reads `pending_server_ids()` at each CallingLlm boundary
2036    /// to decide whether to emit the `[MCP_PENDING]` system notice.
2037    pub(crate) mcp_server_lifecycle_handle:
2038        Option<Arc<dyn crate::handles::McpServerLifecycleHandle>>,
2039    /// Producer end of the typed cancel-after-boundary command channel.
2040    ///
2041    /// Retained so [`Agent::cancel_after_boundary_handle`] can hand cloned
2042    /// senders to the surface that requests boundary-only cancellation. The
2043    /// agent never sends on this end itself; it only drains the matching
2044    /// receiver at turn boundaries.
2045    pub(crate) cancel_after_boundary_tx: CancelAfterBoundarySender,
2046    /// Consumer end of the typed cancel-after-boundary command channel.
2047    ///
2048    /// Drained (non-blocking) at each turn boundary by
2049    /// `observe_cancel_after_boundary_request`, replacing the previous
2050    /// `.swap`-polled `AtomicBool`. A delivered [`CancelAfterBoundaryCommand`]
2051    /// is observed at most once per boundary, mirroring the prior edge
2052    /// semantics.
2053    pub(crate) cancel_after_boundary_rx:
2054        tokio::sync::mpsc::UnboundedReceiver<CancelAfterBoundaryCommand>,
2055    /// Optional resolver for model-specific operational defaults (e.g., call timeout).
2056    /// Consulted at each LLM call for hot-swap-aware profile default resolution.
2057    pub(crate) model_defaults_resolver:
2058        Option<Arc<dyn crate::model_defaults::ModelOperationalDefaultsResolver>>,
2059    /// Explicit call-timeout override from the build/config composition seam.
2060    /// Takes precedence over profile-derived defaults.
2061    pub(crate) call_timeout_override: crate::config::CallTimeoutOverride,
2062    /// Structured-output extraction state carried into RunResult.
2063    pub(crate) extraction_state: extraction::ExtractionState,
2064    /// Last published hidden deferred-catalog names.
2065    pub(crate) last_hidden_deferred_catalog_names: BTreeSet<crate::types::ToolName>,
2066    /// Last published pending catalog sources.
2067    pub(crate) last_pending_catalog_sources: BTreeSet<String>,
2068    /// Dispatch-time projection of the current turn input for contextual tools.
2069    pub(crate) tool_dispatch_context: ToolDispatchContext,
2070    /// Runtime-owned dispatch metadata for this turn.
2071    pub(crate) turn_tool_dispatch_metadata: BTreeMap<String, serde_json::Value>,
2072    /// Typed tool-execution policy (per-call timeouts + concurrency bound)
2073    /// applied to the normal LLM-driven tool dispatch loop. Populated by the
2074    /// composition seam via `AgentBuilder::with_tools_config`; defaults to
2075    /// `ToolsConfig::default()` for standalone/test construction.
2076    pub(crate) tools_config: crate::config::ToolsConfig,
2077}
2078
2079#[derive(Clone)]
2080pub(crate) struct CompactionRollbackState {
2081    pub(crate) rollback_session: Session,
2082    pub(crate) rollback_last_input_tokens: u64,
2083    pub(crate) rollback_compaction_cadence: SessionCompactionCadence,
2084}
2085
2086pub(crate) enum CompactionTransactionPhase {
2087    AwaitingRuntimeCommit(Box<CompactionRollbackState>),
2088    RuntimeCommitted { bookkeeping_complete: bool },
2089    AbortPending { cadence_persist_pending: bool },
2090}
2091
2092pub(crate) struct CompactionTransaction {
2093    pub(crate) phase: CompactionTransactionPhase,
2094    pub(crate) projections: Vec<crate::memory::CompactionProjectionId>,
2095}
2096
2097#[cfg(test)]
2098#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2099mod tests {
2100    use super::{
2101        AgentToolDispatcher, CommsRuntime, DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS,
2102        FilteredToolDispatcher, InlinePeerNotificationPolicy, ToolDispatchContext,
2103    };
2104    use crate::comms::{
2105        PeerAddress, PeerId, PeerName, PeerTransport, SendError, TrustedPeerDescriptor,
2106    };
2107    use crate::types::{ContentBlock, ContentInput, ToolCallView, ToolDef, ToolResult};
2108    use async_trait::async_trait;
2109    use serde_json::json;
2110    use std::sync::Arc;
2111    use tokio::sync::Notify;
2112
2113    struct NoopCommsRuntime {
2114        notify: Arc<Notify>,
2115    }
2116
2117    struct ContextAwareToolDispatcher;
2118
2119    struct ExactExecutionDispatcher {
2120        catalog: Arc<[crate::ToolCatalogEntry]>,
2121    }
2122
2123    struct HybridExecutionDispatcher {
2124        catalog: Arc<[crate::ToolCatalogEntry]>,
2125    }
2126
2127    struct StreamingExecutionDispatcher {
2128        catalog: Arc<[crate::ToolCatalogEntry]>,
2129        saw_streaming_context: Arc<std::sync::atomic::AtomicBool>,
2130    }
2131
2132    struct IdenticalMutationDispatcher {
2133        tool: ToolDef,
2134        epoch: std::sync::atomic::AtomicU64,
2135        mutate_on_resolve: bool,
2136    }
2137
2138    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2139    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2140    impl AgentToolDispatcher for ContextAwareToolDispatcher {
2141        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2142            Arc::from([Arc::new(ToolDef {
2143                name: "inspect_context".into(),
2144                description: "inspect context".to_string(),
2145                input_schema: json!({"type": "object"}),
2146                provenance: None,
2147            })])
2148        }
2149
2150        async fn dispatch(
2151            &self,
2152            call: ToolCallView<'_>,
2153        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2154            Ok(ToolResult::new(
2155                call.id.to_string(),
2156                json!({"saw_context_image": false}).to_string(),
2157                false,
2158            )
2159            .into())
2160        }
2161
2162        async fn dispatch_with_context(
2163            &self,
2164            call: ToolCallView<'_>,
2165            context: &ToolDispatchContext,
2166        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2167            let saw_context_image = context
2168                .current_turn()
2169                .and_then(|turn| turn.image_ref(0))
2170                .and_then(|image_ref| context.current_turn_image(image_ref))
2171                .is_some();
2172            Ok(ToolResult::new(
2173                call.id.to_string(),
2174                json!({"saw_context_image": saw_context_image}).to_string(),
2175                false,
2176            )
2177            .into())
2178        }
2179    }
2180
2181    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2182    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2183    impl AgentToolDispatcher for ExactExecutionDispatcher {
2184        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2185            self.catalog
2186                .iter()
2187                .filter(|entry| entry.currently_callable())
2188                .map(|entry| Arc::clone(&entry.tool))
2189                .collect::<Vec<_>>()
2190                .into()
2191        }
2192
2193        fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2194            crate::ToolCatalogCapabilities {
2195                exact_catalog: true,
2196                may_require_catalog_control_plane: false,
2197            }
2198        }
2199
2200        fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2201            Arc::clone(&self.catalog)
2202        }
2203
2204        async fn dispatch(
2205            &self,
2206            call: ToolCallView<'_>,
2207        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2208            Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2209        }
2210    }
2211
2212    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2213    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2214    impl AgentToolDispatcher for HybridExecutionDispatcher {
2215        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2216            self.catalog
2217                .iter()
2218                .filter(|entry| entry.currently_callable())
2219                .map(|entry| Arc::clone(&entry.tool))
2220                .collect::<Vec<_>>()
2221                .into()
2222        }
2223
2224        fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2225            crate::ToolCatalogCapabilities {
2226                exact_catalog: true,
2227                may_require_catalog_control_plane: false,
2228            }
2229        }
2230
2231        fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2232            Arc::clone(&self.catalog)
2233        }
2234
2235        fn resolve_execution_plan(
2236            &self,
2237            call: ToolCallView<'_>,
2238            _dispatch_context: &ToolDispatchContext,
2239            resolution_context: &crate::ToolExecutionResolutionContext,
2240        ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2241            let entry = self
2242                .catalog
2243                .iter()
2244                .find(|entry| entry.tool.name == call.name)
2245                .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
2246                    tool_name: call.name.to_string(),
2247                })?;
2248            let arguments: serde_json::Value =
2249                serde_json::from_str(call.args.get()).map_err(|error| {
2250                    crate::ToolExecutionResolutionError::InvalidArguments {
2251                        tool_name: call.name.to_string(),
2252                        reason: error.to_string(),
2253                    }
2254                })?;
2255            let mode = if arguments["run_detached"] == true {
2256                crate::ToolExecutionMode::Detached
2257            } else {
2258                crate::ToolExecutionMode::Fast
2259            };
2260            entry
2261                .execution
2262                .resolve(mode, resolution_context.deadlines().clone())
2263                .map_err(crate::ToolExecutionResolutionError::from)
2264        }
2265
2266        async fn dispatch(
2267            &self,
2268            call: ToolCallView<'_>,
2269        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2270            Ok(ToolResult::new(
2271                call.id.to_string(),
2272                json!({"owner": "filtered-hybrid-owner"}).to_string(),
2273                false,
2274            )
2275            .into())
2276        }
2277
2278        async fn dispatch_resolved_with_context(
2279            &self,
2280            call: ToolCallView<'_>,
2281            _context: &ToolDispatchContext,
2282            plan: &crate::ResolvedToolExecutionPlan,
2283        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2284            if plan.mode() != crate::ToolExecutionMode::Detached {
2285                return Err(crate::ToolError::execution_failed(
2286                    "test detached owner received the wrong plan",
2287                ));
2288            }
2289            self.dispatch(call).await
2290        }
2291    }
2292
2293    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2294    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2295    impl AgentToolDispatcher for StreamingExecutionDispatcher {
2296        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2297            self.catalog
2298                .iter()
2299                .map(|entry| Arc::clone(&entry.tool))
2300                .collect::<Vec<_>>()
2301                .into()
2302        }
2303
2304        fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2305            crate::ToolCatalogCapabilities {
2306                exact_catalog: true,
2307                may_require_catalog_control_plane: false,
2308            }
2309        }
2310
2311        fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2312            Arc::clone(&self.catalog)
2313        }
2314
2315        async fn dispatch(
2316            &self,
2317            call: ToolCallView<'_>,
2318        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2319            Err(crate::ToolError::unavailable(
2320                call.name,
2321                crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2322            ))
2323        }
2324
2325        async fn dispatch_resolved_with_context(
2326            &self,
2327            call: ToolCallView<'_>,
2328            context: &ToolDispatchContext,
2329            plan: &crate::ResolvedToolExecutionPlan,
2330        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2331            if plan.mode() != crate::ToolExecutionMode::Streaming {
2332                return Err(crate::ToolError::execution_failed(
2333                    "streaming owner received a non-streaming plan",
2334                ));
2335            }
2336            let streaming = context.streaming().ok_or_else(|| {
2337                crate::ToolError::unavailable(
2338                    call.name,
2339                    crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2340                )
2341            })?;
2342            streaming
2343                .progress()
2344                .try_report(
2345                    crate::ToolProgressFrame::message("accepted through wrapper")
2346                        .map_err(|error| crate::ToolError::other(error.to_string()))?,
2347                )
2348                .map_err(|error| crate::ToolError::other(error.to_string()))?;
2349            self.saw_streaming_context
2350                .store(true, std::sync::atomic::Ordering::SeqCst);
2351            Ok(ToolResult::new(call.id.to_string(), "stream complete".to_string(), false).into())
2352        }
2353    }
2354
2355    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2356    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2357    impl AgentToolDispatcher for IdenticalMutationDispatcher {
2358        fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2359            Arc::from([Arc::new(self.tool.clone())])
2360        }
2361
2362        fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2363            crate::ToolCatalogCapabilities {
2364                exact_catalog: true,
2365                may_require_catalog_control_plane: false,
2366            }
2367        }
2368
2369        fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2370            Arc::from([crate::ToolCatalogEntry::session_inline(
2371                Arc::new(self.tool.clone()),
2372                true,
2373            )])
2374        }
2375
2376        fn execution_binding_epoch(&self, _tool_name: &str) -> u64 {
2377            self.epoch.load(std::sync::atomic::Ordering::SeqCst)
2378        }
2379
2380        fn resolve_execution_plan(
2381            &self,
2382            _call: ToolCallView<'_>,
2383            _dispatch_context: &ToolDispatchContext,
2384            resolution_context: &crate::ToolExecutionResolutionContext,
2385        ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2386            let plan = crate::ToolExecutionContract::default()
2387                .resolve_default(resolution_context.deadlines().clone())
2388                .map_err(crate::ToolExecutionResolutionError::from)?;
2389            if self.mutate_on_resolve {
2390                self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2391            }
2392            Ok(plan)
2393        }
2394
2395        async fn dispatch(
2396            &self,
2397            call: ToolCallView<'_>,
2398        ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2399            Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2400        }
2401    }
2402
2403    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2404    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2405    impl CommsRuntime for NoopCommsRuntime {
2406        async fn drain_messages(&self) -> Vec<String> {
2407            Vec::new()
2408        }
2409
2410        fn inbox_notify(&self) -> std::sync::Arc<Notify> {
2411            self.notify.clone()
2412        }
2413    }
2414
2415    #[tokio::test]
2416    async fn test_comms_runtime_trait_defaults_hide_unimplemented_features() {
2417        let runtime = NoopCommsRuntime {
2418            notify: Arc::new(Notify::new()),
2419        };
2420        assert!(<NoopCommsRuntime as CommsRuntime>::public_key(&runtime).is_none());
2421        // The only mutable trust seam is apply_trust_mutation; without a
2422        // generated handoff it fails closed.
2423        let peer = TrustedPeerDescriptor {
2424            peer_id: PeerId::new(),
2425            name: PeerName::new("peer-a").expect("valid peer name"),
2426            address: PeerAddress::new(PeerTransport::Inproc, "peer-a"),
2427            pubkey: [0u8; 32],
2428        };
2429        let result =
2430            <NoopCommsRuntime as CommsRuntime>::add_private_trusted_peer(&runtime, peer).await;
2431        assert!(matches!(result, Err(SendError::Unsupported(_))));
2432    }
2433
2434    /// T-12: bridge-reply waiter + declared-reply-endpoint trait defaults.
2435    /// `take_bridge_reply_waiter` → None (no registry),
2436    /// `has_bridge_reply_waiter` → false, and
2437    /// `stage_declared_reply_endpoint` fails typed (never a silent no-op) so
2438    /// a caller cannot mistake a dropped security-relevant repair for success.
2439    #[tokio::test]
2440    async fn test_comms_runtime_bridge_reply_defaults() {
2441        let runtime = NoopCommsRuntime {
2442            notify: Arc::new(Notify::new()),
2443        };
2444        let interaction_id = crate::interaction::InteractionId(uuid::Uuid::new_v4());
2445        assert!(
2446            <NoopCommsRuntime as CommsRuntime>::take_bridge_reply_waiter(&runtime, &interaction_id)
2447                .is_none()
2448        );
2449        assert!(
2450            !<NoopCommsRuntime as CommsRuntime>::has_bridge_reply_waiter(&runtime, &interaction_id)
2451        );
2452        let staged = <NoopCommsRuntime as CommsRuntime>::stage_declared_reply_endpoint(
2453            &runtime,
2454            PeerId::new(),
2455            [0x11u8; 32],
2456            "tcp://127.0.0.1:1".to_string(),
2457        )
2458        .await;
2459        assert!(matches!(staged, Err(SendError::Unsupported(_))));
2460    }
2461
2462    #[tokio::test]
2463    async fn filtered_tool_dispatcher_preserves_dispatch_context() {
2464        let dispatcher =
2465            FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), ["inspect_context"]);
2466        let args = serde_json::value::RawValue::from_string("{}".to_string())
2467            .expect("empty object should be valid JSON");
2468        let call = ToolCallView {
2469            id: "ctx-1",
2470            name: "inspect_context",
2471            args: &args,
2472        };
2473        let context = ToolDispatchContext::from_current_turn_input(&ContentInput::Blocks(vec![
2474            ContentBlock::Image {
2475                media_type: "image/png".to_string(),
2476                data: "abc".into(),
2477            },
2478        ]));
2479
2480        let outcome = dispatcher
2481            .dispatch_with_context(call, &context)
2482            .await
2483            .expect("filtered wrapper should dispatch");
2484        let payload: serde_json::Value =
2485            serde_json::from_str(&outcome.result.text_content()).expect("tool result JSON");
2486        assert_eq!(payload["saw_context_image"], true);
2487    }
2488
2489    #[test]
2490    fn default_execution_plan_resolver_uses_exact_catalog_contract() {
2491        use crate::{
2492            DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2493            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2494            ToolExecutionMode, ToolExecutionResolutionContext,
2495        };
2496        use std::collections::BTreeSet;
2497        use std::time::Duration;
2498
2499        let detached = DetachedToolExecutionPolicy::new(
2500            RunnerIdentity::new("homecore.security_scan", "v1").unwrap(),
2501            RestartClass::NonResumable,
2502            IdempotencyScope::InteractionAndArguments,
2503            Duration::from_secs(10),
2504        )
2505        .unwrap();
2506        let contract = ToolExecutionContract::new(
2507            BTreeSet::from([ToolExecutionMode::Detached]),
2508            ToolExecutionMode::Detached,
2509            None,
2510            Some(detached),
2511        )
2512        .unwrap();
2513        let dispatcher = ExactExecutionDispatcher {
2514            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2515                Arc::new(ToolDef::new(
2516                    "security_scan",
2517                    "scan",
2518                    json!({"type": "object"}),
2519                )),
2520                true,
2521            )
2522            .with_execution_contract(contract)]),
2523        };
2524        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2525        let call = ToolCallView {
2526            id: "call-1",
2527            name: "security_scan",
2528            args: &args,
2529        };
2530        let resolution = ToolExecutionResolutionContext::new(
2531            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2532                ToolDeadlineOwner::CoreToolDispatch,
2533                Duration::from_secs(600),
2534            )])
2535            .unwrap(),
2536        );
2537
2538        let plan = dispatcher
2539            .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2540            .expect("declared plan resolves");
2541
2542        assert_eq!(plan.mode(), ToolExecutionMode::Detached);
2543        assert_eq!(
2544            plan.deadlines().effective_timeout(),
2545            Some(Duration::from_secs(10))
2546        );
2547        assert_eq!(
2548            plan.deadlines().winner().map(|winner| winner.owner()),
2549            Some(ToolDeadlineOwner::DetachedSubmission)
2550        );
2551    }
2552
2553    #[tokio::test]
2554    async fn default_resolved_dispatch_refuses_detached_plan_before_ordinary_dispatch() {
2555        use crate::{
2556            DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2557            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2558            ToolExecutionMode, ToolExecutionResolutionContext,
2559        };
2560        use std::collections::BTreeSet;
2561        use std::time::Duration;
2562
2563        let detached = DetachedToolExecutionPolicy::new(
2564            RunnerIdentity::new("detached.owner", "v1").unwrap(),
2565            RestartClass::NonResumable,
2566            IdempotencyScope::ToolCall,
2567            Duration::from_secs(10),
2568        )
2569        .unwrap();
2570        let contract = ToolExecutionContract::new(
2571            BTreeSet::from([ToolExecutionMode::Detached]),
2572            ToolExecutionMode::Detached,
2573            None,
2574            Some(detached),
2575        )
2576        .unwrap();
2577        let dispatcher = ExactExecutionDispatcher {
2578            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2579                Arc::new(ToolDef::new(
2580                    "security_scan",
2581                    "scan",
2582                    json!({"type": "object"}),
2583                )),
2584                true,
2585            )
2586            .with_execution_contract(contract)]),
2587        };
2588        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2589        let call = ToolCallView {
2590            id: "detached-call",
2591            name: "security_scan",
2592            args: &args,
2593        };
2594        let context = ToolDispatchContext::default();
2595        let resolution = ToolExecutionResolutionContext::new(
2596            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2597                ToolDeadlineOwner::CoreToolDispatch,
2598                Duration::from_secs(600),
2599            )])
2600            .unwrap(),
2601        );
2602        let plan = dispatcher
2603            .resolve_execution_plan(call, &context, &resolution)
2604            .expect("detached plan resolves");
2605
2606        let error = dispatcher
2607            .dispatch_resolved_with_context(call, &context, &plan)
2608            .await
2609            .expect_err("the default dispatcher must not lower detached work to dispatch()");
2610
2611        assert!(matches!(
2612            error,
2613            crate::ToolError::Unavailable {
2614                reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2615                ..
2616            }
2617        ));
2618    }
2619
2620    #[tokio::test]
2621    async fn fenced_streaming_dispatch_mints_context_and_filtered_wrapper_preserves_it() {
2622        use crate::{
2623            StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2624            ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2625            ToolExecutionResolutionContext,
2626        };
2627        use std::collections::BTreeSet;
2628        use std::time::Duration;
2629
2630        let contract = ToolExecutionContract::new(
2631            BTreeSet::from([ToolExecutionMode::Streaming]),
2632            ToolExecutionMode::Streaming,
2633            Some(
2634                StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2635                    .unwrap(),
2636            ),
2637            None,
2638        )
2639        .unwrap();
2640        let saw_streaming_context = Arc::new(std::sync::atomic::AtomicBool::new(false));
2641        let owner = StreamingExecutionDispatcher {
2642            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2643                Arc::new(ToolDef::new(
2644                    "stream_scan",
2645                    "stream scan",
2646                    json!({"type": "object"}),
2647                )),
2648                true,
2649            )
2650            .with_execution_contract(contract)]),
2651            saw_streaming_context: Arc::clone(&saw_streaming_context),
2652        };
2653        let dispatcher = Arc::new(FilteredToolDispatcher::new(
2654            Arc::new(owner),
2655            ["stream_scan"],
2656        ));
2657        let filtered_catalog = dispatcher.tool_catalog();
2658        assert_eq!(
2659            filtered_catalog[0].execution.default_mode(),
2660            ToolExecutionMode::Streaming
2661        );
2662        let filtered_policy = filtered_catalog[0]
2663            .execution
2664            .streaming_policy()
2665            .expect("wrapper preserves the streaming registration");
2666        assert_eq!(filtered_policy.inactivity_timeout(), Duration::from_secs(5));
2667        assert_eq!(filtered_policy.absolute_timeout(), Duration::from_secs(30));
2668        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2669        let call = ToolCallView {
2670            id: "stream-call",
2671            name: "stream_scan",
2672            args: &args,
2673        };
2674        let context = ToolDispatchContext::default();
2675        assert!(
2676            context.streaming().is_none(),
2677            "callers cannot pre-mint the supervised streaming context"
2678        );
2679        let resolution = ToolExecutionResolutionContext::new(
2680            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2681                ToolDeadlineOwner::CoreToolDispatch,
2682                Duration::from_secs(60),
2683            )])
2684            .unwrap(),
2685        );
2686        let plan =
2687            crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2688                .expect("streaming plan resolves through wrapper");
2689
2690        let outcome =
2691            crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2692                .await
2693                .expect("streaming dispatch completes");
2694
2695        assert_eq!(outcome.result.text_content(), "stream complete");
2696        assert!(
2697            saw_streaming_context.load(std::sync::atomic::Ordering::SeqCst),
2698            "the wrapper must preserve the exact supervised context"
2699        );
2700    }
2701
2702    #[tokio::test]
2703    async fn declared_streaming_without_a_mode_owner_fails_closed_before_plain_dispatch() {
2704        use crate::{
2705            StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2706            ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2707            ToolExecutionResolutionContext,
2708        };
2709        use std::collections::BTreeSet;
2710        use std::time::Duration;
2711
2712        let contract = ToolExecutionContract::new(
2713            BTreeSet::from([ToolExecutionMode::Streaming]),
2714            ToolExecutionMode::Streaming,
2715            Some(
2716                StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2717                    .unwrap(),
2718            ),
2719            None,
2720        )
2721        .unwrap();
2722        let dispatcher = Arc::new(ExactExecutionDispatcher {
2723            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2724                Arc::new(ToolDef::new(
2725                    "ownerless_stream",
2726                    "ownerless",
2727                    json!({"type": "object"}),
2728                )),
2729                true,
2730            )
2731            .with_execution_contract(contract)]),
2732        });
2733        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2734        let call = ToolCallView {
2735            id: "ownerless-call",
2736            name: "ownerless_stream",
2737            args: &args,
2738        };
2739        let context = ToolDispatchContext::default();
2740        let resolution = ToolExecutionResolutionContext::new(
2741            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2742                ToolDeadlineOwner::CoreToolDispatch,
2743                Duration::from_secs(60),
2744            )])
2745            .unwrap(),
2746        );
2747        let plan =
2748            crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2749                .expect("declaration resolves");
2750
2751        let error = crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2752            .await
2753            .expect_err("missing streaming owner must fail closed");
2754        assert!(matches!(
2755            error,
2756            crate::ToolError::Unavailable {
2757                reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2758                ..
2759            }
2760        ));
2761    }
2762
2763    #[test]
2764    fn filtered_execution_plan_resolver_rejects_policy_denied_tool() {
2765        use crate::{
2766            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2767            ToolExecutionResolutionContext, ToolExecutionResolutionError,
2768        };
2769        use std::time::Duration;
2770
2771        let dispatcher =
2772            FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), Vec::<String>::new());
2773        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2774        let call = ToolCallView {
2775            id: "call-hidden",
2776            name: "inspect_context",
2777            args: &args,
2778        };
2779        let resolution = ToolExecutionResolutionContext::new(
2780            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2781                ToolDeadlineOwner::CoreToolDispatch,
2782                Duration::from_secs(600),
2783            )])
2784            .unwrap(),
2785        );
2786
2787        let error = dispatcher
2788            .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2789            .expect_err("hidden tools must not resolve");
2790
2791        assert_eq!(
2792            error,
2793            ToolExecutionResolutionError::AccessDenied {
2794                tool_name: "inspect_context".to_string(),
2795            }
2796        );
2797    }
2798
2799    #[tokio::test]
2800    async fn filtered_execution_plan_forwards_hybrid_resolution_to_visible_owner() {
2801        use crate::{
2802            DetachedToolExecutionPolicy, IdempotencyScope, ResolvedExecutionKind, RestartClass,
2803            RunnerIdentity, ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2804            ToolExecutionContract, ToolExecutionMode, ToolExecutionResolutionContext,
2805        };
2806        use std::collections::BTreeSet;
2807        use std::time::Duration;
2808
2809        let detached = DetachedToolExecutionPolicy::new(
2810            RunnerIdentity::new("filtered-hybrid-owner", "v1").unwrap(),
2811            RestartClass::NonResumable,
2812            IdempotencyScope::InteractionAndArguments,
2813            Duration::from_secs(10),
2814        )
2815        .unwrap();
2816        let contract = ToolExecutionContract::new(
2817            BTreeSet::from([ToolExecutionMode::Fast, ToolExecutionMode::Detached]),
2818            ToolExecutionMode::Fast,
2819            None,
2820            Some(detached),
2821        )
2822        .unwrap();
2823        let dispatcher = FilteredToolDispatcher::new(
2824            Arc::new(HybridExecutionDispatcher {
2825                catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2826                    Arc::new(ToolDef::new(
2827                        "hybrid_scan",
2828                        "filtered-hybrid-owner catalog",
2829                        json!({"type": "object"}),
2830                    )),
2831                    true,
2832                )
2833                .with_execution_contract(contract)]),
2834            }),
2835            ["hybrid_scan"],
2836        );
2837        let args = serde_json::value::RawValue::from_string(r#"{"run_detached":true}"#.to_string())
2838            .unwrap();
2839        let call = ToolCallView {
2840            id: "call-hybrid",
2841            name: "hybrid_scan",
2842            args: &args,
2843        };
2844        let resolution = ToolExecutionResolutionContext::new(
2845            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2846                ToolDeadlineOwner::CoreToolDispatch,
2847                Duration::from_secs(600),
2848            )])
2849            .unwrap(),
2850        );
2851
2852        let catalog = dispatcher.tool_catalog();
2853        assert_eq!(catalog[0].execution.default_mode(), ToolExecutionMode::Fast);
2854        assert_eq!(catalog[0].tool.description, "filtered-hybrid-owner catalog");
2855
2856        let plan = dispatcher
2857            .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2858            .expect("visible hybrid tool should delegate plan resolution");
2859        dispatcher
2860            .validate_resolved_execution_plan(call, &resolution, &plan)
2861            .expect("hybrid-selected advertised mode must validate");
2862        let ResolvedExecutionKind::Detached(policy) = plan.kind() else {
2863            panic!("hybrid resolver should select its non-default detached mode");
2864        };
2865        assert_eq!(policy.runner().name(), "filtered-hybrid-owner");
2866
2867        let outcome = dispatcher
2868            .dispatch_resolved_with_context(call, &ToolDispatchContext::default(), &plan)
2869            .await
2870            .expect("visible hybrid tool should preserve resolved dispatch");
2871        let payload: serde_json::Value =
2872            serde_json::from_str(&outcome.result.text_content()).unwrap();
2873        assert_eq!(payload["owner"], "filtered-hybrid-owner");
2874    }
2875
2876    #[test]
2877    fn root_validation_rejects_plan_outside_live_advertised_contract() {
2878        use crate::{
2879            DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2880            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2881            ToolExecutionContractError, ToolExecutionMode, ToolExecutionResolutionContext,
2882            ToolExecutionResolutionError,
2883        };
2884        use std::collections::BTreeSet;
2885        use std::time::Duration;
2886
2887        let dispatcher = ExactExecutionDispatcher {
2888            catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2889                Arc::new(ToolDef::new(
2890                    "fast_only",
2891                    "fast only",
2892                    json!({"type": "object"}),
2893                )),
2894                true,
2895            )]),
2896        };
2897        let detached = DetachedToolExecutionPolicy::new(
2898            RunnerIdentity::new("dishonest.owner", "v1").unwrap(),
2899            RestartClass::NonResumable,
2900            IdempotencyScope::ToolCall,
2901            Duration::from_secs(10),
2902        )
2903        .unwrap();
2904        let dishonest_contract = ToolExecutionContract::new(
2905            BTreeSet::from([ToolExecutionMode::Detached]),
2906            ToolExecutionMode::Detached,
2907            None,
2908            Some(detached),
2909        )
2910        .unwrap();
2911        let resolution = ToolExecutionResolutionContext::new(
2912            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2913                ToolDeadlineOwner::CoreToolDispatch,
2914                Duration::from_secs(600),
2915            )])
2916            .unwrap(),
2917        );
2918        let plan = dishonest_contract
2919            .resolve_default(resolution.deadlines().clone())
2920            .unwrap();
2921        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2922        let call = ToolCallView {
2923            id: "dishonest-plan",
2924            name: "fast_only",
2925            args: &args,
2926        };
2927
2928        assert_eq!(
2929            dispatcher.validate_resolved_execution_plan(call, &resolution, &plan),
2930            Err(ToolExecutionResolutionError::Contract(
2931                ToolExecutionContractError::RequestedModeUnsupported {
2932                    requested_mode: ToolExecutionMode::Detached,
2933                }
2934            ))
2935        );
2936    }
2937
2938    #[tokio::test]
2939    async fn universal_root_fence_accepts_rebuilt_equivalent_catalog_arcs() {
2940        use crate::{
2941            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2942            ToolExecutionResolutionContext,
2943        };
2944        use std::time::Duration;
2945
2946        let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
2947            tool: ToolDef::new("rebuilt", "rebuilt", json!({"type": "object"})),
2948            epoch: std::sync::atomic::AtomicU64::new(0),
2949            mutate_on_resolve: false,
2950        });
2951        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2952        let call = ToolCallView {
2953            id: "rebuilt-arcs",
2954            name: "rebuilt",
2955            args: &args,
2956        };
2957        let resolution = ToolExecutionResolutionContext::new(
2958            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2959                ToolDeadlineOwner::CoreToolDispatch,
2960                Duration::from_secs(600),
2961            )])
2962            .unwrap(),
2963        );
2964
2965        let plan = crate::resolve_tool_execution_plan_fenced(
2966            &dispatcher,
2967            call,
2968            &ToolDispatchContext::default(),
2969            &resolution,
2970        )
2971        .expect("equivalent rebuilt catalog projections resolve");
2972        crate::dispatch_tool_execution_plan_fenced(
2973            &dispatcher,
2974            call,
2975            &ToolDispatchContext::default(),
2976            &plan,
2977        )
2978        .await
2979        .expect("equivalent rebuilt catalog projections dispatch");
2980    }
2981
2982    #[tokio::test]
2983    async fn universal_root_fence_binds_canonical_call_identity() {
2984        use crate::{
2985            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2986            ToolExecutionResolutionContext, ToolUnavailableReason,
2987        };
2988        use std::time::Duration;
2989
2990        let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
2991            tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
2992            epoch: std::sync::atomic::AtomicU64::new(0),
2993            mutate_on_resolve: false,
2994        });
2995        let resolved_args =
2996            serde_json::value::RawValue::from_string(r#"{"a":1,"b":2}"#.to_string()).unwrap();
2997        let equivalent_args =
2998            serde_json::value::RawValue::from_string(r#"{ "b": 2, "a": 1 }"#.to_string()).unwrap();
2999        let changed_args =
3000            serde_json::value::RawValue::from_string(r#"{"a":1,"b":3}"#.to_string()).unwrap();
3001        let resolved_call = ToolCallView {
3002            id: "bound-call",
3003            name: "bound",
3004            args: &resolved_args,
3005        };
3006        let resolution = ToolExecutionResolutionContext::new(
3007            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3008                ToolDeadlineOwner::CoreToolDispatch,
3009                Duration::from_secs(600),
3010            )])
3011            .unwrap(),
3012        );
3013        let plan = crate::resolve_tool_execution_plan_fenced(
3014            &dispatcher,
3015            resolved_call,
3016            &ToolDispatchContext::default(),
3017            &resolution,
3018        )
3019        .unwrap();
3020
3021        crate::dispatch_tool_execution_plan_fenced(
3022            &dispatcher,
3023            ToolCallView {
3024                args: &equivalent_args,
3025                ..resolved_call
3026            },
3027            &ToolDispatchContext::default(),
3028            &plan,
3029        )
3030        .await
3031        .expect("canonical JSON-equivalent arguments preserve call identity");
3032
3033        let error = crate::dispatch_tool_execution_plan_fenced(
3034            &dispatcher,
3035            ToolCallView {
3036                args: &changed_args,
3037                ..resolved_call
3038            },
3039            &ToolDispatchContext::default(),
3040            &plan,
3041        )
3042        .await
3043        .expect_err("different arguments must not dispatch under the old plan");
3044        assert!(matches!(
3045            error,
3046            crate::ToolError::Unavailable {
3047                reason: ToolUnavailableReason::ExecutionOwnerChanged,
3048                ..
3049            }
3050        ));
3051    }
3052
3053    #[tokio::test]
3054    async fn universal_root_fence_rejects_fresh_dispatcher_reconstruction() {
3055        use crate::{
3056            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3057            ToolExecutionResolutionContext, ToolUnavailableReason,
3058        };
3059        use std::time::Duration;
3060
3061        let make_dispatcher = || -> Arc<dyn AgentToolDispatcher> {
3062            Arc::new(IdenticalMutationDispatcher {
3063                tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
3064                epoch: std::sync::atomic::AtomicU64::new(0),
3065                mutate_on_resolve: false,
3066            })
3067        };
3068        let original = make_dispatcher();
3069        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3070        let call = ToolCallView {
3071            id: "reconstructed",
3072            name: "bound",
3073            args: &args,
3074        };
3075        let resolution = ToolExecutionResolutionContext::new(
3076            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3077                ToolDeadlineOwner::CoreToolDispatch,
3078                Duration::from_secs(600),
3079            )])
3080            .unwrap(),
3081        );
3082        let plan = crate::resolve_tool_execution_plan_fenced(
3083            &original,
3084            call,
3085            &ToolDispatchContext::default(),
3086            &resolution,
3087        )
3088        .unwrap();
3089        let reconstructed = make_dispatcher();
3090
3091        let error = crate::dispatch_tool_execution_plan_fenced(
3092            &reconstructed,
3093            call,
3094            &ToolDispatchContext::default(),
3095            &plan,
3096        )
3097        .await
3098        .expect_err("fresh reconstruction must never reproduce ephemeral root authority");
3099        assert!(matches!(
3100            error,
3101            crate::ToolError::Unavailable {
3102                reason: ToolUnavailableReason::ExecutionOwnerChanged,
3103                ..
3104            }
3105        ));
3106    }
3107
3108    #[test]
3109    fn universal_root_fence_rejects_direct_identical_metadata_replacement() {
3110        use crate::{
3111            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3112            ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3113        };
3114        use std::time::Duration;
3115
3116        let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
3117            tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3118            epoch: std::sync::atomic::AtomicU64::new(0),
3119            mutate_on_resolve: true,
3120        });
3121        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3122        let call = ToolCallView {
3123            id: "direct-identical-replacement",
3124            name: "moving",
3125            args: &args,
3126        };
3127        let resolution = ToolExecutionResolutionContext::new(
3128            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3129                ToolDeadlineOwner::CoreToolDispatch,
3130                Duration::from_secs(600),
3131            )])
3132            .unwrap(),
3133        );
3134
3135        assert!(matches!(
3136            crate::resolve_tool_execution_plan_fenced(
3137                &dispatcher,
3138                call,
3139                &ToolDispatchContext::default(),
3140                &resolution,
3141            ),
3142            Err(ToolExecutionResolutionError::Unavailable {
3143                reason: ToolUnavailableReason::ExecutionOwnerChanged,
3144                ..
3145            })
3146        ));
3147    }
3148
3149    #[test]
3150    fn filtered_wrapper_composes_inner_live_binding_epoch() {
3151        use crate::{
3152            ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3153            ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3154        };
3155        use std::time::Duration;
3156
3157        let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(FilteredToolDispatcher::new(
3158            Arc::new(IdenticalMutationDispatcher {
3159                tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3160                epoch: std::sync::atomic::AtomicU64::new(0),
3161                mutate_on_resolve: true,
3162            }),
3163            ["moving"],
3164        ));
3165        let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3166        let call = ToolCallView {
3167            id: "filtered-identical-replacement",
3168            name: "moving",
3169            args: &args,
3170        };
3171        let resolution = ToolExecutionResolutionContext::new(
3172            ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3173                ToolDeadlineOwner::CoreToolDispatch,
3174                Duration::from_secs(600),
3175            )])
3176            .unwrap(),
3177        );
3178
3179        assert!(matches!(
3180            crate::resolve_tool_execution_plan_fenced(
3181                &dispatcher,
3182                call,
3183                &ToolDispatchContext::default(),
3184                &resolution,
3185            ),
3186            Err(ToolExecutionResolutionError::Unavailable {
3187                reason: ToolUnavailableReason::ExecutionOwnerChanged,
3188                ..
3189            })
3190        ));
3191    }
3192
3193    #[test]
3194    fn test_inline_peer_notification_policy_from_raw() {
3195        assert_eq!(
3196            InlinePeerNotificationPolicy::try_from_raw(None),
3197            Ok(InlinePeerNotificationPolicy::AtMost(
3198                DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS
3199            ))
3200        );
3201        assert_eq!(
3202            InlinePeerNotificationPolicy::try_from_raw(Some(-1)),
3203            Ok(InlinePeerNotificationPolicy::Always)
3204        );
3205        assert_eq!(
3206            InlinePeerNotificationPolicy::try_from_raw(Some(0)),
3207            Ok(InlinePeerNotificationPolicy::Never)
3208        );
3209        assert_eq!(
3210            InlinePeerNotificationPolicy::try_from_raw(Some(25)),
3211            Ok(InlinePeerNotificationPolicy::AtMost(25))
3212        );
3213        assert_eq!(
3214            InlinePeerNotificationPolicy::try_from_raw(Some(-42)),
3215            Err(-42)
3216        );
3217    }
3218
3219    /// UNIT-002: DetachedOpCompletion serializes without operation_id.
3220    /// The app-facing control noun is job_id (CONTRACT-003).
3221    #[test]
3222    fn unit_002_detached_op_completion_has_no_operation_id() {
3223        use crate::agent::DetachedOpCompletion;
3224        use crate::ops_lifecycle::{OperationKind, OperationStatus};
3225
3226        let completion = DetachedOpCompletion {
3227            job_id: "j_test".into(),
3228            kind: OperationKind::BackgroundToolOp,
3229            status: OperationStatus::Completed,
3230            terminal_outcome: None,
3231            display_name: "test cmd".into(),
3232            detail: "ok".into(),
3233            elapsed_ms: None,
3234        };
3235        #[allow(clippy::unwrap_used)]
3236        let json = serde_json::to_value(&completion).unwrap();
3237        assert!(
3238            json.get("operation_id").is_none(),
3239            "operation_id must not appear in serialized DetachedOpCompletion (CONTRACT-003)"
3240        );
3241        assert!(
3242            json.get("job_id").is_some(),
3243            "job_id must be the app-facing control noun"
3244        );
3245    }
3246}