1mod 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#[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#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
56#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
57pub trait AgentLlmClient: Send + Sync {
58 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 fn provider(&self) -> &'static str;
70
71 fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
77 Ok(CompiledSchema {
79 schema: output_schema.schema.as_value().clone(),
80 warnings: Vec::new(),
81 })
82 }
83}
84
85pub 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#[derive(Debug, Clone)]
127pub struct ExternalToolNotice {
128 pub server: String,
130 pub operation: crate::event::ToolConfigChangeOperation,
132 pub status: String,
134 pub tool_count: Option<usize>,
136}
137
138#[derive(Debug, Clone, Default)]
142pub struct ExternalToolUpdate {
143 pub notices: Vec<ExternalToolNotice>,
145 pub pending: Vec<String>,
147}
148
149#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
151#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
152pub trait AgentToolDispatcher: Send + Sync {
153 fn tools(&self) -> Arc<[Arc<ToolDef>]>;
155 async fn dispatch(&self, call: ToolCallView<'_>)
157 -> Result<ToolResult, crate::error::ToolError>;
158
159 async fn poll_external_updates(&self) -> ExternalToolUpdate {
165 ExternalToolUpdate::default()
166 }
167
168 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 fn supports_wait_interrupt(&self) -> bool {
191 false
192 }
193}
194
195pub struct FilteredToolDispatcher<T: AgentToolDispatcher + ?Sized> {
201 inner: Arc<T>,
202 allowed_tools: HashSet<String>,
203 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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum InlinePeerNotificationPolicy {
278 Always,
280 Never,
282 AtMost(usize),
284}
285
286pub const DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS: usize = 50;
288
289impl InlinePeerNotificationPolicy {
290 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#[derive(Debug, thiserror::Error)]
304pub enum CommsCapabilityError {
305 #[error("comms capability not supported: {0}")]
307 Unsupported(String),
308}
309
310#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
312#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
313pub trait CommsRuntime: Send + Sync {
314 fn public_key(&self) -> Option<String> {
318 None
319 }
320
321 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 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 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 async fn peers(&self) -> Vec<PeerDirectoryEntry> {
361 Vec::new()
362 }
363
364 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 async fn drain_messages(&self) -> Vec<String>;
387 fn inbox_notify(&self) -> Arc<tokio::sync::Notify>;
389 fn dismiss_received(&self) -> bool {
391 false
392 }
393 fn event_injector(&self) -> Option<Arc<dyn crate::EventInjector>> {
398 None
399 }
400
401 #[doc(hidden)]
403 fn interaction_event_injector(
404 &self,
405 ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
406 None
407 }
408
409 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 fn interaction_subscriber(
434 &self,
435 _id: &crate::interaction::InteractionId,
436 ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
437 None
438 }
439
440 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 fn mark_interaction_complete(&self, _id: &crate::interaction::InteractionId) {}
454
455 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 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
481pub 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 pub(crate) compactor: Option<Arc<dyn crate::compact::Compactor>>,
504 pub(crate) last_input_tokens: u64,
506 pub(crate) last_compaction_turn: Option<u32>,
508 pub(crate) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
510 pub(crate) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
512 pub pending_skill_references: Option<Vec<crate::skills::SkillKey>>,
515 pub(crate) event_tap: crate::event_tap::EventTap,
517 pub(crate) system_context_state:
519 Arc<std::sync::Mutex<crate::session::SessionSystemContextState>>,
520 pub(crate) default_event_tx: Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>>,
523 pub(crate) checkpointer: Option<Arc<dyn crate::checkpoint::SessionCheckpointer>>,
525 pub(crate) default_scoped_event_tx:
528 Option<tokio::sync::mpsc::Sender<crate::event::ScopedAgentEvent>>,
529 pub(crate) default_scope_path: Vec<crate::event::StreamScopeFrame>,
531 #[allow(dead_code)] pub(crate) silent_comms_intents: Vec<String>,
535 pub(crate) inline_peer_notification_policy: InlinePeerNotificationPolicy,
537 pub(crate) peer_notification_suppression_active: bool,
540 pub(crate) host_drain_active: bool,
544 pub(crate) runtime_input_sink: Option<Arc<dyn RuntimeInputSink>>,
548 pub(crate) extraction_mode: bool,
552 pub(crate) extraction_attempts: u32,
554 pub(crate) extraction_result: Option<serde_json::Value>,
556 pub(crate) extraction_schema_warnings: Option<Vec<crate::schema::SchemaWarning>>,
558 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 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}