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;
10mod runner;
11pub mod skills;
12mod state;
13
14pub use runner::RuntimeInputSink;
15
16use crate::budget::Budget;
17use crate::comms::{
18    CommsCommand, EventStream, PeerDirectoryEntry, SendAndStreamError, SendError, SendReceipt,
19    StreamError, StreamScope, TrustedPeerSpec,
20};
21use crate::config::{AgentConfig, HookRunOverrides};
22use crate::error::AgentError;
23use crate::hooks::HookEngine;
24use crate::retry::RetryPolicy;
25use crate::schema::{CompiledSchema, SchemaError};
26use crate::session::Session;
27use crate::state::LoopState;
28use crate::sub_agent::SubAgentManager;
29#[cfg(target_arch = "wasm32")]
30use crate::tokio;
31use crate::tool_scope::ToolScope;
32use crate::types::{
33    AssistantBlock, BlockAssistantMessage, Message, OutputSchema, StopReason, ToolCallView,
34    ToolDef, ToolResult, Usage,
35};
36use async_trait::async_trait;
37use serde_json::Value;
38use std::collections::HashSet;
39use std::sync::Arc;
40
41pub use builder::AgentBuilder;
42pub use runner::AgentRunner;
43
44/// Special error prefix to signal tool calls that must be routed externally.
45///
46/// DEPRECATED: Use `ToolError::CallbackPending` or `AgentError::CallbackPending` instead.
47/// This constant is kept for backward compatibility but will be removed in a future version.
48#[deprecated(
49    since = "0.2.0",
50    note = "Use ToolError::CallbackPending or AgentError::CallbackPending instead"
51)]
52pub const CALLBACK_TOOL_PREFIX: &str = "CALLBACK_TOOL_PENDING:";
53
54/// Trait for LLM clients that can be used with the agent
55#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
56#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
57pub trait AgentLlmClient: Send + Sync {
58    /// Stream a response from the LLM
59    async fn stream_response(
60        &self,
61        messages: &[Message],
62        tools: &[Arc<ToolDef>],
63        max_tokens: u32,
64        temperature: Option<f32>,
65        provider_params: Option<&Value>,
66    ) -> Result<LlmStreamResult, AgentError>;
67
68    /// Get the provider name
69    fn provider(&self) -> &'static str;
70
71    /// Compile an output schema for this provider.
72    ///
73    /// Default implementation normalizes the schema without provider-specific lowering.
74    /// Adapters override this to apply provider-specific transformations (e.g.,
75    /// Anthropic adds `additionalProperties: false`, Gemini strips unsupported keywords).
76    fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
77        // Default passthrough: normalized clone, no provider-specific lowering
78        Ok(CompiledSchema {
79            schema: output_schema.schema.as_value().clone(),
80            warnings: Vec::new(),
81        })
82    }
83}
84
85/// Result of streaming from the LLM
86pub struct LlmStreamResult {
87    blocks: Vec<AssistantBlock>,
88    stop_reason: StopReason,
89    usage: Usage,
90}
91
92impl LlmStreamResult {
93    pub fn new(blocks: Vec<AssistantBlock>, stop_reason: StopReason, usage: Usage) -> Self {
94        Self {
95            blocks,
96            stop_reason,
97            usage,
98        }
99    }
100
101    pub fn blocks(&self) -> &[AssistantBlock] {
102        &self.blocks
103    }
104    pub fn stop_reason(&self) -> StopReason {
105        self.stop_reason
106    }
107    pub fn usage(&self) -> &Usage {
108        &self.usage
109    }
110
111    pub fn into_message(self) -> BlockAssistantMessage {
112        BlockAssistantMessage {
113            blocks: self.blocks,
114            stop_reason: self.stop_reason,
115        }
116    }
117
118    pub fn into_parts(self) -> (Vec<AssistantBlock>, StopReason, Usage) {
119        (self.blocks, self.stop_reason, self.usage)
120    }
121}
122
123/// A notice about an externally-completed tool configuration change.
124///
125/// Produced by background MCP connection tasks and consumed by the agent loop.
126#[derive(Debug, Clone)]
127pub struct ExternalToolNotice {
128    /// Server or source name.
129    pub server: String,
130    /// What kind of operation completed.
131    pub operation: crate::event::ToolConfigChangeOperation,
132    /// Human-readable status (e.g. "activated", "failed").
133    pub status: String,
134    /// Number of tools provided (on success).
135    pub tool_count: Option<usize>,
136}
137
138/// Result of polling for external tool updates.
139///
140/// Returned by [`AgentToolDispatcher::poll_external_updates`].
141#[derive(Debug, Clone, Default)]
142pub struct ExternalToolUpdate {
143    /// Notices about completed background operations since last poll.
144    pub notices: Vec<ExternalToolNotice>,
145    /// Names of servers still connecting in the background.
146    pub pending: Vec<String>,
147}
148
149/// Trait for tool dispatchers
150#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
151#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
152pub trait AgentToolDispatcher: Send + Sync {
153    /// Get available tool definitions
154    fn tools(&self) -> Arc<[Arc<ToolDef>]>;
155    /// Execute a tool call
156    async fn dispatch(&self, call: ToolCallView<'_>)
157    -> Result<ToolResult, crate::error::ToolError>;
158
159    /// Poll for external tool updates from background operations (e.g. async MCP loading).
160    ///
161    /// The default implementation returns an empty update. Implementations that
162    /// support background tool loading (like `McpRouterAdapter`) override this
163    /// to drain completed results and report pending servers.
164    async fn poll_external_updates(&self) -> ExternalToolUpdate {
165        ExternalToolUpdate::default()
166    }
167
168    /// Bind a wait-interrupt receiver into this dispatcher, returning a rebound dispatcher.
169    ///
170    /// The consuming `Arc<Self>` receiver is required because some dispatchers
171    /// (e.g. `CompositeDispatcher`) hold non-Clone state. The factory calls
172    /// this once before comms gateway composition, transferring ownership.
173    ///
174    /// Default returns `Err(Unsupported)`. Dispatchers that contain a `WaitTool`
175    /// (e.g. `CompositeDispatcher`) override this to swap in an interrupt-aware
176    /// instance. `ToolGateway` and `FilteredToolDispatcher` forward to their
177    /// inner dispatchers.
178    fn bind_wait_interrupt(
179        self: Arc<Self>,
180        _rx: crate::wait_interrupt::WaitInterruptReceiver,
181    ) -> Result<Arc<dyn AgentToolDispatcher>, crate::wait_interrupt::WaitInterruptBindError> {
182        Err(crate::wait_interrupt::WaitInterruptBindError::Unsupported)
183    }
184
185    /// Whether this dispatcher supports wait interrupt binding.
186    ///
187    /// Non-consuming probe that callers check before calling the consuming
188    /// `bind_wait_interrupt()`. Default: `false`. Dispatchers that contain a
189    /// `WaitTool` (e.g. `CompositeDispatcher`) override this to return `true`.
190    fn supports_wait_interrupt(&self) -> bool {
191        false
192    }
193}
194
195/// A tool dispatcher that filters tools based on a policy
196///
197/// Tools are filtered once at construction time based on the allowed_tools list.
198/// The inner dispatcher is used for actual dispatch, but only allowed tools are
199/// exposed via tools() and dispatch() returns AccessDenied for filtered tools.
200pub struct FilteredToolDispatcher<T: AgentToolDispatcher + ?Sized> {
201    inner: Arc<T>,
202    allowed_tools: HashSet<String>,
203    /// Pre-computed filtered tool list (computed once at construction)
204    filtered_tools: Arc<[Arc<ToolDef>]>,
205}
206
207impl<T: AgentToolDispatcher + ?Sized> FilteredToolDispatcher<T> {
208    pub fn new(inner: Arc<T>, allowed_tools: Vec<String>) -> Self {
209        let allowed_set: HashSet<String> = allowed_tools.into_iter().collect();
210
211        // Filter tools once at construction - the tool registry is static for agent lifetime
212        let inner_tools = inner.tools();
213        let filtered: Vec<Arc<ToolDef>> = inner_tools
214            .iter()
215            .filter(|t| allowed_set.contains(t.name.as_str()))
216            .map(Arc::clone)
217            .collect();
218
219        Self {
220            inner,
221            allowed_tools: allowed_set,
222            filtered_tools: filtered.into(),
223        }
224    }
225}
226
227#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
228#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
229impl<T: AgentToolDispatcher + ?Sized + 'static> AgentToolDispatcher for FilteredToolDispatcher<T> {
230    fn tools(&self) -> Arc<[Arc<ToolDef>]> {
231        Arc::clone(&self.filtered_tools)
232    }
233
234    async fn dispatch(
235        &self,
236        call: ToolCallView<'_>,
237    ) -> Result<ToolResult, crate::error::ToolError> {
238        if !self.allowed_tools.contains(call.name) {
239            return Err(crate::error::ToolError::access_denied(call.name));
240        }
241        self.inner.dispatch(call).await
242    }
243
244    async fn poll_external_updates(&self) -> ExternalToolUpdate {
245        self.inner.poll_external_updates().await
246    }
247
248    fn bind_wait_interrupt(
249        self: Arc<Self>,
250        rx: crate::wait_interrupt::WaitInterruptReceiver,
251    ) -> Result<Arc<dyn AgentToolDispatcher>, crate::wait_interrupt::WaitInterruptBindError> {
252        let owned = Arc::try_unwrap(self)
253            .map_err(|_| crate::wait_interrupt::WaitInterruptBindError::SharedOwnership)?;
254        let rebound_inner = owned.inner.bind_wait_interrupt(rx)?;
255        Ok(Arc::new(FilteredToolDispatcher {
256            inner: rebound_inner,
257            allowed_tools: owned.allowed_tools,
258            filtered_tools: owned.filtered_tools,
259        }))
260    }
261
262    fn supports_wait_interrupt(&self) -> bool {
263        self.inner.supports_wait_interrupt()
264    }
265}
266
267/// Trait for session stores
268#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
269#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
270pub trait AgentSessionStore: Send + Sync {
271    async fn save(&self, session: &Session) -> Result<(), AgentError>;
272    async fn load(&self, id: &str) -> Result<Option<Session>, AgentError>;
273}
274
275/// Runtime policy for inlining peer lifecycle updates into session context.
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum InlinePeerNotificationPolicy {
278    /// Always inline batched peer lifecycle updates.
279    Always,
280    /// Never inline batched peer lifecycle updates.
281    Never,
282    /// Inline only when post-drain peer count is at or below this threshold.
283    AtMost(usize),
284}
285
286/// Default inline threshold when no explicit value is configured.
287pub const DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS: usize = 50;
288
289impl InlinePeerNotificationPolicy {
290    /// Resolve policy from transport/build-layer config representation.
291    pub fn try_from_raw(raw: Option<i32>) -> Result<Self, i32> {
292        match raw {
293            None => Ok(Self::AtMost(DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS)),
294            Some(-1) => Ok(Self::Always),
295            Some(0) => Ok(Self::Never),
296            Some(v) if v > 0 => Ok(Self::AtMost(v as usize)),
297            Some(v) => Err(v),
298        }
299    }
300}
301
302/// Error returned when a comms runtime capability is not available.
303#[derive(Debug, thiserror::Error)]
304pub enum CommsCapabilityError {
305    /// The runtime does not support this capability.
306    #[error("comms capability not supported: {0}")]
307    Unsupported(String),
308}
309
310/// Trait for comms runtime that can be used with the agent
311#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
312#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
313pub trait CommsRuntime: Send + Sync {
314    /// Runtime-local public key identifier, if available.
315    ///
316    /// Returns a peer ID string in `ed25519:<base64>` format.
317    fn public_key(&self) -> Option<String> {
318        None
319    }
320
321    /// Register a trusted peer for future peer sends.
322    ///
323    /// Runtimes that manage trust dynamically should accept this as a mutable
324    /// control-plane operation and return `SendError::Unsupported` if not
325    /// available.
326    async fn add_trusted_peer(&self, _peer: TrustedPeerSpec) -> Result<(), SendError> {
327        Err(SendError::Unsupported(
328            "add_trusted_peer not supported for this CommsRuntime".to_string(),
329        ))
330    }
331
332    /// Remove a previously trusted peer by peer ID.
333    ///
334    /// Returns `true` if the peer was found and removed, `false` if it
335    /// was not present. After removal, messages from this peer should be
336    /// rejected and `peers()` should no longer return it.
337    async fn remove_trusted_peer(&self, _peer_id: &str) -> Result<bool, SendError> {
338        Err(SendError::Unsupported(
339            "remove_trusted_peer not supported for this CommsRuntime".to_string(),
340        ))
341    }
342
343    /// Dispatch a canonical comms command.
344    async fn send(&self, _cmd: CommsCommand) -> Result<SendReceipt, SendError> {
345        Err(SendError::Unsupported(
346            "send not implemented for this CommsRuntime".to_string(),
347        ))
348    }
349
350    #[doc(hidden)]
351    fn stream(&self, scope: StreamScope) -> Result<EventStream, StreamError> {
352        let scope_desc = match scope {
353            StreamScope::Session(session_id) => format!("session {session_id}"),
354            StreamScope::Interaction(interaction_id) => format!("interaction {}", interaction_id.0),
355        };
356        Err(StreamError::NotFound(scope_desc))
357    }
358
359    /// List peers visible to this runtime.
360    async fn peers(&self) -> Vec<PeerDirectoryEntry> {
361        Vec::new()
362    }
363
364    /// Count peers visible to this runtime.
365    ///
366    /// Implementations can override this to avoid materializing a full peer list.
367    async fn peer_count(&self) -> usize {
368        self.peers().await.len()
369    }
370
371    #[doc(hidden)]
372    async fn send_and_stream(
373        &self,
374        cmd: CommsCommand,
375    ) -> Result<(SendReceipt, EventStream), SendAndStreamError> {
376        let receipt = self.send(cmd).await?;
377        Err(SendAndStreamError::StreamAttach {
378            receipt,
379            error: StreamError::Internal(
380                "send_and_stream is not implemented for this runtime".to_string(),
381            ),
382        })
383    }
384
385    /// Drain comms inbox and return messages formatted for the LLM
386    async fn drain_messages(&self) -> Vec<String>;
387    /// Get a notification when new messages arrive
388    fn inbox_notify(&self) -> Arc<tokio::sync::Notify>;
389    /// Returns true if a DISMISS signal was seen during the last `drain_messages` call.
390    fn dismiss_received(&self) -> bool {
391        false
392    }
393    /// Get an event injector for this runtime's inbox.
394    ///
395    /// Surfaces use this to push external events into the agent inbox.
396    /// Returns `None` if the implementation doesn't support event injection.
397    fn event_injector(&self) -> Option<Arc<dyn crate::EventInjector>> {
398        None
399    }
400
401    /// Internal runtime seam for interaction-scoped streaming.
402    #[doc(hidden)]
403    fn interaction_event_injector(
404        &self,
405    ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
406        None
407    }
408
409    /// Drain comms inbox and return structured interactions.
410    ///
411    /// Default implementation wraps `drain_messages()` results as `InteractionContent::Message`
412    /// with generated IDs.
413    async fn drain_inbox_interactions(&self) -> Vec<crate::interaction::InboxInteraction> {
414        self.drain_messages()
415            .await
416            .into_iter()
417            .map(|text| crate::interaction::InboxInteraction {
418                id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
419                from: "unknown".into(),
420                content: crate::interaction::InteractionContent::Message {
421                    body: text.clone(),
422                    blocks: None,
423                },
424                rendered_text: text,
425            })
426            .collect()
427    }
428
429    /// Look up and remove a one-shot subscriber for the given interaction.
430    ///
431    /// Returns the event sender if a subscriber was registered (via `inject_with_subscription`).
432    /// The entry is removed from the registry on lookup (one-shot).
433    fn interaction_subscriber(
434        &self,
435        _id: &crate::interaction::InteractionId,
436    ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
437        None
438    }
439
440    /// Take and clear the one-shot sender for an interaction-scoped stream.
441    fn take_interaction_stream_sender(
442        &self,
443        _id: &crate::interaction::InteractionId,
444    ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
445        self.interaction_subscriber(_id)
446    }
447
448    /// Signal that an interaction has reached a terminal state (complete or failed).
449    ///
450    /// Implementations should transition the reservation FSM to `Completed` and
451    /// clean up registry entries. Called from the host-mode loop after sending
452    /// terminal events to the tap.
453    fn mark_interaction_complete(&self, _id: &crate::interaction::InteractionId) {}
454
455    /// Drain classified inbox interactions.
456    ///
457    /// Returns interactions with pre-computed classification from ingress.
458    /// The host loop routes on the stored `PeerInputClass` instead of
459    /// re-classifying after drain.
460    ///
461    /// Default returns `Unsupported`. Comms-enabled runtimes must override.
462    async fn drain_classified_inbox_interactions(
463        &self,
464    ) -> Result<Vec<crate::interaction::ClassifiedInboxInteraction>, CommsCapabilityError> {
465        Err(CommsCapabilityError::Unsupported(
466            "drain_classified_inbox_interactions".to_string(),
467        ))
468    }
469
470    /// Get a notification that fires only for actionable peer input.
471    ///
472    /// Default returns `Unsupported`. Comms-enabled runtimes must override.
473    /// Used by the factory to bridge into `WaitTool` interrupt.
474    fn actionable_input_notify(&self) -> Result<Arc<tokio::sync::Notify>, CommsCapabilityError> {
475        Err(CommsCapabilityError::Unsupported(
476            "actionable_input_notify".to_string(),
477        ))
478    }
479}
480
481/// The main Agent struct
482pub struct Agent<C, T, S>
483where
484    C: AgentLlmClient + ?Sized,
485    T: AgentToolDispatcher + ?Sized,
486    S: AgentSessionStore + ?Sized,
487{
488    config: AgentConfig,
489    client: Arc<C>,
490    tools: Arc<T>,
491    tool_scope: ToolScope,
492    store: Arc<S>,
493    session: Session,
494    budget: Budget,
495    retry_policy: RetryPolicy,
496    state: LoopState,
497    sub_agent_manager: Arc<SubAgentManager>,
498    depth: u32,
499    pub(super) comms_runtime: Option<Arc<dyn CommsRuntime>>,
500    pub(super) hook_engine: Option<Arc<dyn HookEngine>>,
501    pub(super) hook_run_overrides: HookRunOverrides,
502    /// Optional context compaction strategy.
503    pub(crate) compactor: Option<Arc<dyn crate::compact::Compactor>>,
504    /// Input tokens from the last LLM response (for compaction trigger).
505    pub(crate) last_input_tokens: u64,
506    /// Turn number when compaction last occurred.
507    pub(crate) last_compaction_turn: Option<u32>,
508    /// Optional memory store for indexing compaction discards.
509    pub(crate) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
510    /// Optional skill engine for per-turn `/skill-ref` activation.
511    pub(crate) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
512    /// Skill references to resolve and inject for the next turn.
513    /// Set by surfaces before calling `run()`, consumed on run start.
514    pub pending_skill_references: Option<Vec<crate::skills::SkillKey>>,
515    /// Per-interaction event tap for streaming events to subscribers.
516    pub(crate) event_tap: crate::event_tap::EventTap,
517    /// Shared control state for runtime system-context appends.
518    pub(crate) system_context_state:
519        Arc<std::sync::Mutex<crate::session::SessionSystemContextState>>,
520    /// Optional default event channel configured at build time.
521    /// Used by run methods when no per-call event channel is provided.
522    pub(crate) default_event_tx: Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>>,
523    /// Optional session checkpointer for host-mode persistence.
524    pub(crate) checkpointer: Option<Arc<dyn crate::checkpoint::SessionCheckpointer>>,
525    /// Optional default scoped event channel configured at build time.
526    /// Used by nested sub-agent forwarding to emit attributed events.
527    pub(crate) default_scoped_event_tx:
528        Option<tokio::sync::mpsc::Sender<crate::event::ScopedAgentEvent>>,
529    /// Base scope path for nested scoped event forwarding.
530    pub(crate) default_scope_path: Vec<crate::event::StreamScopeFrame>,
531    /// Comms intents that should be silently injected into the session
532    /// without triggering an LLM turn. Matched against `InteractionContent::Request.intent`.
533    #[allow(dead_code)] // Used by comms_impl when comms feature is enabled
534    pub(crate) silent_comms_intents: Vec<String>,
535    /// Runtime policy for inline peer lifecycle context injection.
536    pub(crate) inline_peer_notification_policy: InlinePeerNotificationPolicy,
537    /// Whether peer lifecycle updates are currently suppressed due to threshold policy.
538    /// Used to inject suppression notice only on transition into suppressed mode.
539    pub(crate) peer_notification_suppression_active: bool,
540    /// When true, the host loop owns the inbox drain cycle.
541    /// `drain_comms_inbox()` becomes a no-op to avoid stealing
542    /// interaction-scoped messages through the legacy path.
543    pub(crate) host_drain_active: bool,
544    /// Optional sink for routing host-mode new-run work through the runtime.
545    /// When set, passthrough interactions and continuation runs use the sink
546    /// instead of calling `self.run()` directly.
547    pub(crate) runtime_input_sink: Option<Arc<dyn RuntimeInputSink>>,
548    /// True after the agentic loop completes when `output_schema` is set.
549    /// Causes the next `CallingLlm` iteration to use extraction parameters
550    /// (no tools, temperature 0.0, structured_output provider params).
551    pub(crate) extraction_mode: bool,
552    /// Number of extraction attempts so far (for retry logic).
553    pub(crate) extraction_attempts: u32,
554    /// Populated on successful extraction validation — carried into RunResult.
555    pub(crate) extraction_result: Option<serde_json::Value>,
556    /// Schema warnings from compilation — carried into RunResult.
557    pub(crate) extraction_schema_warnings: Option<Vec<crate::schema::SchemaWarning>>,
558    /// Last validation error (for retry prompt).
559    pub(crate) extraction_last_error: Option<String>,
560}
561
562#[cfg(test)]
563mod tests {
564    use super::{
565        CommsRuntime, DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS, InlinePeerNotificationPolicy,
566    };
567    use crate::comms::{SendError, TrustedPeerSpec};
568    use async_trait::async_trait;
569    use std::sync::Arc;
570    use tokio::sync::Notify;
571
572    struct NoopCommsRuntime {
573        notify: Arc<Notify>,
574    }
575
576    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
577    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
578    impl CommsRuntime for NoopCommsRuntime {
579        async fn drain_messages(&self) -> Vec<String> {
580            Vec::new()
581        }
582
583        fn inbox_notify(&self) -> std::sync::Arc<Notify> {
584            self.notify.clone()
585        }
586    }
587
588    #[tokio::test]
589    async fn test_comms_runtime_trait_defaults_hide_unimplemented_features() {
590        let runtime = NoopCommsRuntime {
591            notify: Arc::new(Notify::new()),
592        };
593        assert!(<NoopCommsRuntime as CommsRuntime>::public_key(&runtime).is_none());
594        let peer = TrustedPeerSpec {
595            name: "peer-a".to_string(),
596            peer_id: "ed25519:test".to_string(),
597            address: "inproc://peer-a".to_string(),
598        };
599        let result = <NoopCommsRuntime as CommsRuntime>::add_trusted_peer(&runtime, peer).await;
600        assert!(matches!(result, Err(SendError::Unsupported(_))));
601    }
602
603    #[tokio::test]
604    async fn test_remove_trusted_peer_default_unsupported() {
605        let runtime = NoopCommsRuntime {
606            notify: Arc::new(Notify::new()),
607        };
608        let result =
609            <NoopCommsRuntime as CommsRuntime>::remove_trusted_peer(&runtime, "ed25519:test").await;
610        assert!(matches!(result, Err(SendError::Unsupported(_))));
611    }
612
613    #[test]
614    fn test_inline_peer_notification_policy_from_raw() {
615        assert_eq!(
616            InlinePeerNotificationPolicy::try_from_raw(None),
617            Ok(InlinePeerNotificationPolicy::AtMost(
618                DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS
619            ))
620        );
621        assert_eq!(
622            InlinePeerNotificationPolicy::try_from_raw(Some(-1)),
623            Ok(InlinePeerNotificationPolicy::Always)
624        );
625        assert_eq!(
626            InlinePeerNotificationPolicy::try_from_raw(Some(0)),
627            Ok(InlinePeerNotificationPolicy::Never)
628        );
629        assert_eq!(
630            InlinePeerNotificationPolicy::try_from_raw(Some(25)),
631            Ok(InlinePeerNotificationPolicy::AtMost(25))
632        );
633        assert_eq!(
634            InlinePeerNotificationPolicy::try_from_raw(Some(-42)),
635            Err(-42)
636        );
637    }
638
639    #[test]
640    fn test_filtered_dispatcher_bind_wait_interrupt_forwards() {
641        use super::{AgentToolDispatcher, FilteredToolDispatcher};
642        use crate::error::ToolError;
643        use crate::types::{ToolCallView, ToolDef, ToolResult};
644        use serde_json::json;
645
646        struct MockTool;
647
648        #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
649        #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
650        impl AgentToolDispatcher for MockTool {
651            fn tools(&self) -> Arc<[Arc<ToolDef>]> {
652                vec![Arc::new(ToolDef {
653                    name: "test".to_string(),
654                    description: "test tool".to_string(),
655                    input_schema: json!({"type": "object", "properties": {}, "required": []}),
656                })]
657                .into()
658            }
659            async fn dispatch(&self, _call: ToolCallView<'_>) -> Result<ToolResult, ToolError> {
660                Err(ToolError::not_found("test"))
661            }
662        }
663
664        let inner: Arc<dyn AgentToolDispatcher> = Arc::new(MockTool);
665        let filtered = Arc::new(FilteredToolDispatcher::new(inner, vec!["test".to_string()]));
666
667        let (_tx, rx) = tokio::sync::watch::channel(None::<crate::wait_interrupt::WaitInterrupt>);
668
669        // MockTool returns Unsupported (default); FilteredToolDispatcher
670        // forwards to inner which also returns Unsupported.
671        let result = filtered.bind_wait_interrupt(rx);
672        assert!(matches!(
673            result,
674            Err(crate::wait_interrupt::WaitInterruptBindError::Unsupported)
675        ));
676    }
677
678    #[test]
679    #[allow(clippy::panic)]
680    fn test_filtered_dispatcher_bind_wait_interrupt_shared_ownership() {
681        use super::{AgentToolDispatcher, FilteredToolDispatcher};
682        use crate::error::ToolError;
683        use crate::types::{ToolCallView, ToolDef, ToolResult};
684        use serde_json::json;
685
686        struct MockTool;
687
688        #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
689        #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
690        impl AgentToolDispatcher for MockTool {
691            fn tools(&self) -> Arc<[Arc<ToolDef>]> {
692                vec![Arc::new(ToolDef {
693                    name: "test".to_string(),
694                    description: "test tool".to_string(),
695                    input_schema: json!({"type": "object", "properties": {}, "required": []}),
696                })]
697                .into()
698            }
699            async fn dispatch(&self, _call: ToolCallView<'_>) -> Result<ToolResult, ToolError> {
700                Err(ToolError::not_found("test"))
701            }
702        }
703
704        let inner: Arc<dyn AgentToolDispatcher> = Arc::new(MockTool);
705        let filtered = Arc::new(FilteredToolDispatcher::new(inner, vec!["test".to_string()]));
706        let _clone = Arc::clone(&filtered);
707
708        let (_tx, rx) = tokio::sync::watch::channel(None::<crate::wait_interrupt::WaitInterrupt>);
709        match filtered.bind_wait_interrupt(rx) {
710            Err(crate::wait_interrupt::WaitInterruptBindError::SharedOwnership) => {}
711            Ok(_) => panic!("expected SharedOwnership error, got Ok"),
712            Err(e) => panic!("expected SharedOwnership, got {e:?}"),
713        }
714    }
715}