Skip to main content

meerkat_core/
agent.rs

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