1use crate::budget::{Budget, BudgetLimits};
4use crate::config::{AgentConfig, CallTimeoutOverride, HookRunOverrides};
5use crate::hooks::HookEngine;
6use crate::model_defaults::ModelOperationalDefaultsResolver;
7use crate::ops::ConcurrencyLimits;
8#[cfg(not(target_arch = "wasm32"))]
9use crate::prompt::SystemPromptConfig;
10use crate::retry::RetryPolicy;
11use crate::session::{SESSION_TOOL_VISIBILITY_STATE_KEY, Session, SessionToolVisibilityState};
12#[cfg(target_arch = "wasm32")]
13use crate::tokio;
14use crate::tool_catalog::{ToolCatalogDeferredEligibility, ToolCatalogMode, ToolPlaneClass};
15use crate::tool_scope::{
16 EXTERNAL_TOOL_FILTER_METADATA_KEY, INHERITED_TOOL_FILTER_METADATA_KEY,
17 LocalToolVisibilityOwner, ToolFilter, ToolScope, ToolVisibilityOwner,
18 validate_inherited_filter_witnesses,
19};
20use crate::types::{Message, OutputSchema};
21use serde_json::Value;
22#[cfg(meerkat_internal_agent_factory_build)]
23use std::any::Any;
24#[cfg(meerkat_internal_agent_factory_build)]
25use std::future::Future;
26#[cfg(meerkat_internal_agent_factory_build)]
27use std::pin::Pin;
28use std::sync::Arc;
29use tokio::sync::mpsc;
30
31use super::{
32 Agent, AgentLlmClient, AgentSessionStore, AgentToolDispatcher, CommsRuntime,
33 select_tool_catalog_mode,
34};
35
36#[derive(Default)]
38pub struct AgentBuilder {
39 pub(super) config: AgentConfig,
40 pub(super) system_prompt: Option<String>,
41 pub(super) budget_limits: Option<BudgetLimits>,
42 pub(super) retry_policy: RetryPolicy,
43 pub(super) session: Option<Session>,
44 pub(super) concurrency_limits: ConcurrencyLimits,
45 pub(super) depth: u32,
46 pub(super) comms_runtime: Option<Arc<dyn CommsRuntime>>,
47 pub(super) hook_engine: Option<Arc<dyn HookEngine>>,
48 pub(super) hook_run_overrides: HookRunOverrides,
49 pub(super) compactor: Option<Arc<dyn crate::compact::Compactor>>,
50 pub(super) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
51 pub(super) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
52 pub(super) checkpointer: Option<Arc<dyn crate::checkpoint::SessionCheckpointer>>,
53 pub(super) blob_store: Option<Arc<dyn crate::BlobStore>>,
54 pub(super) silent_comms_intents: Vec<String>,
55 pub(super) ops_lifecycle: Option<Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>>,
56 pub(super) completion_feed: Option<Arc<dyn crate::completion_feed::CompletionFeed>>,
57 pub(super) completion_enrichment:
58 Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>>,
59 pub(super) max_inline_peer_notifications: Option<i32>,
60 pub(super) event_tap: Option<crate::event_tap::EventTap>,
61 pub(super) default_event_tx: Option<mpsc::Sender<crate::event::AgentEvent>>,
62 pub(super) model_defaults_resolver: Option<Arc<dyn ModelOperationalDefaultsResolver>>,
63 pub(super) call_timeout_override: CallTimeoutOverride,
64 pub(super) epoch_cursor_state: Option<Arc<crate::runtime_epoch::EpochCursorState>>,
65 pub(super) tool_visibility_owner: Option<Arc<dyn ToolVisibilityOwner>>,
66 pub(super) turn_state_handle: Option<Arc<dyn crate::TurnStateHandle>>,
67 pub(super) runtime_execution_kind_required: bool,
68 #[allow(dead_code)]
69 pub(super) runtime_execution_kind: Option<crate::lifecycle::RuntimeExecutionKind>,
70 pub(super) external_tool_surface_handle: Option<Arc<dyn crate::ExternalToolSurfaceHandle>>,
71 pub(super) auth_lease_handle: Option<Arc<dyn crate::handles::AuthLeaseHandle>>,
72 pub(super) mcp_server_lifecycle_handle:
73 Option<Arc<dyn crate::handles::McpServerLifecycleHandle>>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
79pub enum AgentBuildPolicyError {
80 #[error("factory policy build requires an explicit session")]
81 MissingSession,
82 #[error("factory policy build requires session metadata")]
83 MissingSessionMetadata,
84 #[error("factory policy build requires session build state metadata")]
85 MissingSessionBuildState,
86 #[error("factory policy build requires a runtime turn-state handle")]
87 MissingTurnStateHandle,
88 #[error("runtime-backed agent build requires a canonical tool visibility owner")]
89 MissingToolVisibilityOwner,
90 #[error("runtime-backed agent build received legacy inherited tool visibility metadata")]
91 LegacyInheritedToolFilterMetadata,
92 #[error("runtime-backed agent build received inherited tool visibility without witnesses")]
93 MissingInheritedToolVisibilityWitnesses,
94 #[error("factory policy build requires the canonical factory bridge token")]
95 InvalidFactoryBridgeToken,
96 #[error("failed to restore canonical tool visibility state: {message}")]
97 ToolVisibilityRestore { message: String },
98 #[error("failed to persist canonical tool visibility state during restore: {message}")]
99 ToolVisibilityPersist { message: String },
100}
101
102#[cfg(not(target_arch = "wasm32"))]
103#[cfg(meerkat_internal_agent_factory_build)]
104type AgentFactoryBuildFuture = Pin<
105 Box<
106 dyn Future<
107 Output = Result<
108 Agent<dyn AgentLlmClient, dyn AgentToolDispatcher, dyn AgentSessionStore>,
109 AgentBuildPolicyError,
110 >,
111 > + Send,
112 >,
113>;
114
115#[cfg(target_arch = "wasm32")]
116#[cfg(meerkat_internal_agent_factory_build)]
117type AgentFactoryBuildFuture = Pin<
118 Box<
119 dyn Future<
120 Output = Result<
121 Agent<dyn AgentLlmClient, dyn AgentToolDispatcher, dyn AgentSessionStore>,
122 AgentBuildPolicyError,
123 >,
124 >,
125 >,
126>;
127
128#[cfg(meerkat_internal_agent_factory_build)]
129#[allow(improper_ctypes, unsafe_code)]
130unsafe extern "Rust" {
131 #[link_name = concat!(
132 "__meerkat_agent_factory_policy_bridge_token_is_valid_v1_",
133 env!("MEERKAT_AGENT_FACTORY_POLICY_BRIDGE_SYMBOL_SUFFIX")
134 )]
135 fn facade_agent_factory_policy_bridge_token_is_valid(
136 factory_bridge_token: &(dyn Any + Send + Sync),
137 ) -> bool;
138}
139
140#[cfg(meerkat_internal_agent_factory_build)]
141#[allow(improper_ctypes_definitions, unsafe_code)]
142#[unsafe(export_name = concat!(
143 "__meerkat_agent_factory_policy_build_v3_",
144 env!("MEERKAT_AGENT_FACTORY_POLICY_BRIDGE_SYMBOL_SUFFIX")
145))]
146pub(crate) unsafe extern "Rust" fn exported_agent_factory_policy_build(
147 factory_bridge_token: &'static (dyn Any + Send + Sync),
148 builder: AgentBuilder,
149 client: Arc<dyn AgentLlmClient>,
150 tools: Arc<dyn AgentToolDispatcher>,
151 store: Arc<dyn AgentSessionStore>,
152) -> AgentFactoryBuildFuture {
153 Box::pin(async move {
154 validate_factory_bridge_token(factory_bridge_token)?;
155 builder.validate_factory_policy()?;
156 builder.build_inner(client, tools, store).await
157 })
158}
159
160#[cfg(meerkat_internal_agent_factory_build)]
161fn validate_factory_bridge_token(
162 token: &(dyn Any + Send + Sync),
163) -> Result<(), AgentBuildPolicyError> {
164 #[allow(unsafe_code)]
169 let is_valid = unsafe { facade_agent_factory_policy_bridge_token_is_valid(token) };
170 if is_valid {
171 Ok(())
172 } else {
173 Err(AgentBuildPolicyError::InvalidFactoryBridgeToken)
174 }
175}
176
177impl AgentBuilder {
178 pub fn new() -> Self {
180 Self {
181 config: AgentConfig::default(),
182 system_prompt: None,
183 budget_limits: None,
184 retry_policy: RetryPolicy::default(),
185 session: None,
186 concurrency_limits: ConcurrencyLimits::default(),
187 depth: 0,
188 comms_runtime: None,
189 hook_engine: None,
190 hook_run_overrides: HookRunOverrides::default(),
191 compactor: None,
192 memory_store: None,
193 skill_engine: None,
194 checkpointer: None,
195 blob_store: None,
196 silent_comms_intents: Vec::new(),
197 ops_lifecycle: None,
198 completion_feed: None,
199 completion_enrichment: None,
200 max_inline_peer_notifications: None,
201 event_tap: None,
202 default_event_tx: None,
203 model_defaults_resolver: None,
204 call_timeout_override: CallTimeoutOverride::default(),
205 epoch_cursor_state: None,
206 tool_visibility_owner: None,
207 turn_state_handle: None,
208 runtime_execution_kind_required: false,
209 runtime_execution_kind: None,
210 external_tool_surface_handle: None,
211 auth_lease_handle: None,
212 mcp_server_lifecycle_handle: None,
213 }
214 }
215
216 pub fn concurrency_limits(mut self, limits: ConcurrencyLimits) -> Self {
218 self.concurrency_limits = limits;
219 self
220 }
221
222 pub fn depth(mut self, depth: u32) -> Self {
224 self.depth = depth;
225 self
226 }
227
228 pub fn model(mut self, model: impl Into<String>) -> Self {
230 self.config.model = model.into();
231 self
232 }
233
234 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
236 self.system_prompt = Some(prompt.into());
237 self
238 }
239
240 pub fn max_tokens_per_turn(mut self, tokens: u32) -> Self {
242 self.config.max_tokens_per_turn = tokens;
243 self
244 }
245
246 pub fn temperature(mut self, temp: f32) -> Self {
248 self.config.temperature = Some(temp);
249 self
250 }
251
252 pub fn budget(mut self, limits: BudgetLimits) -> Self {
254 self.budget_limits = Some(limits);
255 self
256 }
257
258 pub fn provider_params(mut self, params: Value) -> Self {
260 self.config.provider_params = Some(params);
261 self
262 }
263
264 pub fn provider_tool_defaults(mut self, defaults: Value) -> Self {
266 self.config.provider_tool_defaults = Some(defaults);
267 self
268 }
269
270 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
272 self.retry_policy = policy;
273 self
274 }
275
276 pub fn output_schema(mut self, schema: OutputSchema) -> Self {
278 self.config.output_schema = Some(schema);
279 self
280 }
281
282 pub fn memory_store(mut self, store: Arc<dyn crate::memory::MemoryStore>) -> Self {
284 self.memory_store = Some(store);
285 self
286 }
287
288 pub fn structured_output_retries(mut self, retries: u32) -> Self {
290 self.config.structured_output_retries = retries;
291 self
292 }
293
294 pub fn resume_session(mut self, session: Session) -> Self {
296 self.session = Some(session);
297 self
298 }
299
300 pub fn with_comms_runtime(mut self, runtime: Arc<dyn CommsRuntime>) -> Self {
302 self.comms_runtime = Some(runtime);
303 self
304 }
305
306 pub fn with_hook_engine(mut self, hook_engine: Arc<dyn HookEngine>) -> Self {
308 self.hook_engine = Some(hook_engine);
309 self
310 }
311
312 pub fn with_hook_run_overrides(mut self, overrides: HookRunOverrides) -> Self {
314 self.hook_run_overrides = overrides;
315 self
316 }
317
318 pub fn compactor(mut self, compactor: Arc<dyn crate::compact::Compactor>) -> Self {
320 self.compactor = Some(compactor);
321 self
322 }
323
324 #[cfg(test)]
330 #[allow(clippy::panic)]
331 pub async fn build_standalone<C, T, S>(
332 self,
333 client: Arc<C>,
334 tools: Arc<T>,
335 store: Arc<S>,
336 ) -> Agent<C, T, S>
337 where
338 C: AgentLlmClient + ?Sized,
339 T: AgentToolDispatcher + ?Sized,
340 S: AgentSessionStore + ?Sized,
341 {
342 match self.try_build_standalone(client, tools, store).await {
343 Ok(agent) => agent,
344 Err(err) => panic!("standalone agent build failed: {err}"),
345 }
346 }
347
348 #[cfg(test)]
350 pub async fn try_build_standalone<C, T, S>(
351 self,
352 client: Arc<C>,
353 tools: Arc<T>,
354 store: Arc<S>,
355 ) -> Result<Agent<C, T, S>, AgentBuildPolicyError>
356 where
357 C: AgentLlmClient + ?Sized,
358 T: AgentToolDispatcher + ?Sized,
359 S: AgentSessionStore + ?Sized,
360 {
361 self.build_inner(client, tools, store).await
362 }
363
364 #[cfg(meerkat_internal_agent_factory_build)]
365 fn validate_factory_policy(&self) -> Result<(), AgentBuildPolicyError> {
366 let session = self
367 .session
368 .as_ref()
369 .ok_or(AgentBuildPolicyError::MissingSession)?;
370 if session.session_metadata().is_none() {
371 return Err(AgentBuildPolicyError::MissingSessionMetadata);
372 }
373 if session.build_state().is_none() {
374 return Err(AgentBuildPolicyError::MissingSessionBuildState);
375 }
376 if self.turn_state_handle.is_none() {
377 return Err(AgentBuildPolicyError::MissingTurnStateHandle);
378 }
379 if self.requires_explicit_runtime_tool_visibility_owner()
380 && self.tool_visibility_owner.is_none()
381 {
382 return Err(AgentBuildPolicyError::MissingToolVisibilityOwner);
383 }
384 Ok(())
385 }
386
387 fn requires_explicit_runtime_tool_visibility_owner(&self) -> bool {
388 self.runtime_execution_kind_required || self.epoch_cursor_state.is_some()
393 }
394
395 #[allow(dead_code)]
396 async fn build_inner<C, T, S>(
397 self,
398 client: Arc<C>,
399 tools: Arc<T>,
400 store: Arc<S>,
401 ) -> Result<Agent<C, T, S>, AgentBuildPolicyError>
402 where
403 C: AgentLlmClient + ?Sized,
404 T: AgentToolDispatcher + ?Sized,
405 S: AgentSessionStore + ?Sized,
406 {
407 let runtime_tool_visibility_owner_required =
408 self.requires_explicit_runtime_tool_visibility_owner();
409 let tool_visibility_owner = match self.tool_visibility_owner.clone() {
410 Some(owner) => owner,
411 None if runtime_tool_visibility_owner_required => {
412 return Err(AgentBuildPolicyError::MissingToolVisibilityOwner);
413 }
414 None => Arc::new(LocalToolVisibilityOwner::new()),
415 };
416 let mut session = self.session.unwrap_or_default();
417 let discarded_runtime_steer_context = session.discard_transient_runtime_steer_context();
418 if discarded_runtime_steer_context > 0 {
419 tracing::debug!(
420 discarded_runtime_steer_context,
421 "discarded transient runtime steer context while building agent"
422 );
423 }
424 let system_context_state = Arc::new(std::sync::Mutex::new(
425 session.system_context_state().unwrap_or_default(),
426 ));
427
428 let has_system_prompt = matches!(session.messages().first(), Some(Message::System(_)));
430 if let Some(prompt) = self.system_prompt {
431 session.set_system_prompt(prompt);
432 } else if !has_system_prompt {
433 #[cfg(not(target_arch = "wasm32"))]
435 {
436 session.set_system_prompt(SystemPromptConfig::new().compose().await);
437 }
438 #[cfg(target_arch = "wasm32")]
439 {
440 session.set_system_prompt(String::new());
441 }
442 }
443
444 let budget = Budget::new(self.budget_limits.unwrap_or_default());
445 let catalog_mode = select_tool_catalog_mode(tools.as_ref());
446 let (control_tool_names, deferred_tool_names) =
447 if tools.tool_catalog_capabilities().exact_catalog {
448 let catalog = tools.tool_catalog();
449 let control_names = catalog
450 .iter()
451 .filter(|entry| entry.plane == ToolPlaneClass::Control)
452 .map(|entry| entry.tool.name.to_string())
453 .collect::<std::collections::HashSet<_>>();
454 let deferred_names = if !control_names.is_empty()
455 && matches!(catalog_mode, ToolCatalogMode::Deferred)
456 {
457 catalog
458 .iter()
459 .filter(|entry| entry.plane == ToolPlaneClass::Session)
460 .filter(|entry| {
461 matches!(
462 entry.deferred_eligibility,
463 ToolCatalogDeferredEligibility::DeferredEligible { .. }
464 )
465 })
466 .map(|entry| entry.tool.name.to_string())
467 .collect()
468 } else {
469 std::collections::HashSet::new()
470 };
471 (control_names, deferred_names)
472 } else {
473 (
474 std::collections::HashSet::new(),
475 std::collections::HashSet::new(),
476 )
477 };
478 let tool_scope = ToolScope::new_with_visibility_owner(
479 tools.tools(),
480 control_tool_names,
481 deferred_tool_names,
482 tool_visibility_owner,
483 );
484 let compaction_cadence = crate::agent::compact::load_compaction_cadence(&session);
485
486 let mut agent = Agent {
487 config: self.config,
488 client,
489 tools,
490 tool_scope,
491 store,
492 session,
493 budget,
494 retry_policy: self.retry_policy,
495 depth: self.depth,
496 comms_runtime: self.comms_runtime,
497 hook_engine: self.hook_engine,
498 hook_run_overrides: self.hook_run_overrides,
499 compactor: self.compactor,
500 last_input_tokens: 0,
501 compaction_cadence,
502 memory_store: self.memory_store,
503 skill_engine: self.skill_engine,
504 pending_skill_references: None,
505 terminal_error_detail: None,
506 run_completed_hooks_applied: false,
507 run_completed_event_emitted: false,
508 silent_comms_intents: self.silent_comms_intents,
509 checkpointer: self.checkpointer,
510 blob_store: self.blob_store,
511 event_tap: self
512 .event_tap
513 .unwrap_or_else(crate::event_tap::new_event_tap),
514 system_context_state,
515 default_event_tx: self.default_event_tx,
516 ops_lifecycle: self.ops_lifecycle,
517 applied_cursor: self
522 .epoch_cursor_state
523 .as_ref()
524 .map(|cs| {
525 cs.agent_applied_cursor
526 .load(std::sync::atomic::Ordering::Acquire)
527 })
528 .unwrap_or_else(|| self.completion_feed.as_ref().map_or(0, |f| f.watermark())),
529 completion_feed: self.completion_feed,
530 epoch_cursor_state: self.epoch_cursor_state,
531 completion_enrichment: self.completion_enrichment,
532 mob_authority_handle: None,
533 runtime_execution_kind_required: self.runtime_execution_kind_required,
534 runtime_execution_kind: self.runtime_execution_kind,
535 turn_state_handle: self.turn_state_handle,
536 external_tool_surface_handle: self.external_tool_surface_handle,
537 auth_lease_handle: self.auth_lease_handle,
538 mcp_server_lifecycle_handle: self.mcp_server_lifecycle_handle,
539 cancel_after_boundary_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
540 model_defaults_resolver: self.model_defaults_resolver,
541 call_timeout_override: self.call_timeout_override,
542 extraction_state: super::extraction::ExtractionState::default(),
543 last_hidden_deferred_catalog_names: Default::default(),
544 last_pending_catalog_sources: Default::default(),
545 tool_dispatch_context: Default::default(),
546 turn_tool_dispatch_metadata: Default::default(),
547 };
548
549 let has_canonical_visibility_state = agent
550 .session
551 .metadata()
552 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY);
553 let mut visibility_state = match agent.session.tool_visibility_state() {
554 Ok(Some(state)) => state,
555 Ok(None) => SessionToolVisibilityState::default(),
556 Err(err) => {
557 return Err(AgentBuildPolicyError::ToolVisibilityRestore {
558 message: format!(
559 "failed to decode canonical session metadata `{SESSION_TOOL_VISIBILITY_STATE_KEY}`: {err}"
560 ),
561 });
562 }
563 };
564 if runtime_tool_visibility_owner_required
565 && agent
566 .session
567 .metadata()
568 .contains_key(INHERITED_TOOL_FILTER_METADATA_KEY)
569 {
570 tracing::error!(
571 metadata_key = INHERITED_TOOL_FILTER_METADATA_KEY,
572 "runtime-backed agent build rejected legacy inherited tool visibility metadata"
573 );
574 return Err(AgentBuildPolicyError::LegacyInheritedToolFilterMetadata);
575 }
576 if runtime_tool_visibility_owner_required
577 && let Err(err) = validate_inherited_filter_witnesses(
578 &visibility_state.inherited_base_filter,
579 &visibility_state.filter_witnesses,
580 )
581 {
582 tracing::error!(
583 error = %err,
584 "runtime-backed agent build rejected inherited tool visibility without witnesses"
585 );
586 return Err(AgentBuildPolicyError::MissingInheritedToolVisibilityWitnesses);
587 }
588
589 if !has_canonical_visibility_state && !runtime_tool_visibility_owner_required {
590 if let Some(raw_filter) = agent
591 .session
592 .metadata()
593 .get(EXTERNAL_TOOL_FILTER_METADATA_KEY)
594 .cloned()
595 {
596 match serde_json::from_value::<ToolFilter>(raw_filter) {
597 Ok(filter) => {
598 visibility_state.active_filter = filter.clone();
599 visibility_state.staged_filter = filter;
600 }
601 Err(err) => {
602 return Err(AgentBuildPolicyError::ToolVisibilityRestore {
603 message: format!(
604 "failed to decode legacy session metadata `{EXTERNAL_TOOL_FILTER_METADATA_KEY}`: {err}"
605 ),
606 });
607 }
608 }
609 }
610
611 if let Some(raw_filter) = agent
612 .session
613 .metadata()
614 .get(INHERITED_TOOL_FILTER_METADATA_KEY)
615 .cloned()
616 {
617 match serde_json::from_value::<ToolFilter>(raw_filter) {
618 Ok(filter) => {
619 visibility_state.inherited_base_filter = filter;
620 }
621 Err(err) => {
622 return Err(AgentBuildPolicyError::ToolVisibilityRestore {
623 message: format!(
624 "failed to decode legacy session metadata `{INHERITED_TOOL_FILTER_METADATA_KEY}`: {err}"
625 ),
626 });
627 }
628 }
629 }
630 }
631
632 if visibility_state != SessionToolVisibilityState::default()
633 || has_canonical_visibility_state
634 {
635 if let Err(err) = agent
636 .tool_scope
637 .set_visibility_state(visibility_state.clone())
638 {
639 return Err(AgentBuildPolicyError::ToolVisibilityRestore {
640 message: err.to_string(),
641 });
642 }
643 if !runtime_tool_visibility_owner_required {
644 if let Err(err) = agent.session.set_tool_visibility_state(visibility_state) {
645 return Err(AgentBuildPolicyError::ToolVisibilityPersist {
646 message: err.to_string(),
647 });
648 }
649 agent
650 .session
651 .remove_metadata(EXTERNAL_TOOL_FILTER_METADATA_KEY);
652 agent
653 .session
654 .remove_metadata(INHERITED_TOOL_FILTER_METADATA_KEY);
655 }
656 }
657
658 Ok(agent)
659 }
660
661 pub fn with_checkpointer(
663 mut self,
664 cp: Arc<dyn crate::checkpoint::SessionCheckpointer>,
665 ) -> Self {
666 self.checkpointer = Some(cp);
667 self
668 }
669
670 pub fn with_blob_store(mut self, blob_store: Arc<dyn crate::BlobStore>) -> Self {
672 self.blob_store = Some(blob_store);
673 self
674 }
675
676 pub fn with_silent_comms_intents(mut self, intents: Vec<String>) -> Self {
679 self.silent_comms_intents = intents;
680 self
681 }
682
683 pub fn with_max_inline_peer_notifications(mut self, threshold: Option<i32>) -> Self {
685 self.max_inline_peer_notifications = threshold;
686 self
687 }
688
689 pub fn with_ops_lifecycle(
691 mut self,
692 registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
693 ) -> Self {
694 self.ops_lifecycle = Some(registry);
695 self
696 }
697
698 pub fn with_completion_feed(
700 mut self,
701 feed: Arc<dyn crate::completion_feed::CompletionFeed>,
702 ) -> Self {
703 self.completion_feed = Some(feed);
704 self
705 }
706
707 pub fn with_completion_enrichment(
709 mut self,
710 enrichment: Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>,
711 ) -> Self {
712 self.completion_enrichment = Some(enrichment);
713 self
714 }
715
716 pub fn with_skill_engine(mut self, engine: Arc<crate::skills::SkillRuntime>) -> Self {
718 self.skill_engine = Some(engine);
719 self
720 }
721
722 pub fn with_event_tap(mut self, tap: crate::event_tap::EventTap) -> Self {
724 self.event_tap = Some(tap);
725 self
726 }
727
728 pub fn with_default_event_tx(
731 mut self,
732 event_tx: mpsc::Sender<crate::event::AgentEvent>,
733 ) -> Self {
734 self.default_event_tx = Some(event_tx);
735 self
736 }
737
738 pub fn with_model_defaults_resolver(
744 mut self,
745 resolver: Arc<dyn ModelOperationalDefaultsResolver>,
746 ) -> Self {
747 self.model_defaults_resolver = Some(resolver);
748 self
749 }
750
751 pub fn with_epoch_cursor_state(
753 mut self,
754 state: Arc<crate::runtime_epoch::EpochCursorState>,
755 ) -> Self {
756 self.epoch_cursor_state = Some(state);
757 self
758 }
759
760 pub fn with_tool_visibility_owner(mut self, owner: Arc<dyn ToolVisibilityOwner>) -> Self {
762 self.tool_visibility_owner = Some(owner);
763 self
764 }
765
766 pub fn with_turn_state_handle(mut self, handle: Arc<dyn crate::TurnStateHandle>) -> Self {
768 self.turn_state_handle = Some(handle);
769 self
770 }
771
772 pub fn require_runtime_execution_kind_stamp(mut self) -> Self {
774 self.runtime_execution_kind_required = true;
775 self
776 }
777
778 #[cfg(test)]
779 pub(crate) fn with_runtime_execution_kind_for_test(
780 mut self,
781 execution_kind: crate::lifecycle::RuntimeExecutionKind,
782 ) -> Self {
783 self.runtime_execution_kind = Some(execution_kind);
784 self
785 }
786
787 pub fn with_external_tool_surface_handle(
789 mut self,
790 handle: Arc<dyn crate::ExternalToolSurfaceHandle>,
791 ) -> Self {
792 self.external_tool_surface_handle = Some(handle);
793 self
794 }
795
796 pub fn with_auth_lease_handle(
798 mut self,
799 handle: Arc<dyn crate::handles::AuthLeaseHandle>,
800 ) -> Self {
801 self.auth_lease_handle = Some(handle);
802 self
803 }
804
805 pub fn with_mcp_server_lifecycle_handle(
813 mut self,
814 handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
815 ) -> Self {
816 self.mcp_server_lifecycle_handle = Some(handle);
817 self
818 }
819
820 pub fn with_call_timeout_override(mut self, override_value: CallTimeoutOverride) -> Self {
826 self.call_timeout_override = override_value;
827 self
828 }
829}
830
831#[cfg(test)]
832#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
833mod tests {
834 use super::*;
835 use crate::LlmStreamResult;
836 use crate::connection::{AuthBindingRef, BindingId, RealmId};
837 use crate::error::{AgentError, ToolError};
838 use crate::event::AgentEvent;
839 use crate::event_tap::EventTapState;
840 use crate::handles::{
841 AuthLeaseHandle, AuthLeasePhase, AuthLeaseSnapshot, AuthLeaseTransition,
842 DslTransitionError, LeaseKey,
843 };
844 use crate::types::{AssistantBlock, StopReason, ToolCallView, ToolDef, UserMessage};
845 use async_trait::async_trait;
846 use std::collections::{BTreeMap, BTreeSet};
847 use std::sync::Mutex;
848 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
849 use tokio::sync::mpsc;
850
851 struct MockClient;
852
853 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
854 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
855 impl AgentLlmClient for MockClient {
856 async fn stream_response(
857 &self,
858 _messages: &[Message],
859 _tools: &[Arc<ToolDef>],
860 _max_tokens: u32,
861 _temperature: Option<f32>,
862 _provider_params: Option<&crate::lifecycle::run_primitive::ProviderParamsOverride>,
863 ) -> Result<LlmStreamResult, AgentError> {
864 Ok(LlmStreamResult::new(
865 vec![AssistantBlock::Text {
866 text: "Done".to_string(),
867 meta: None,
868 }],
869 StopReason::EndTurn,
870 crate::types::Usage::default(),
871 ))
872 }
873
874 fn provider(&self) -> &'static str {
875 "mock"
876 }
877
878 fn model(&self) -> &'static str {
879 "mock-model"
880 }
881 }
882
883 struct MockTools;
884
885 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
886 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
887 impl AgentToolDispatcher for MockTools {
888 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
889 Arc::new([])
890 }
891
892 async fn dispatch(
893 &self,
894 call: ToolCallView<'_>,
895 ) -> Result<crate::ops::ToolDispatchOutcome, ToolError> {
896 Err(ToolError::NotFound {
897 name: call.name.to_string(),
898 })
899 }
900 }
901
902 struct MockStore;
903
904 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
905 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
906 impl AgentSessionStore for MockStore {
907 async fn save(&self, _session: &Session) -> Result<(), AgentError> {
908 Ok(())
909 }
910 async fn load(&self, _id: &str) -> Result<Option<Session>, AgentError> {
911 Ok(None)
912 }
913 }
914
915 fn explicit_test_visibility_owner() -> Arc<dyn ToolVisibilityOwner> {
916 Arc::new(LocalToolVisibilityOwner::new())
917 }
918
919 struct RestoreFailingVisibilityOwner {
920 fallback_state: LocalToolVisibilityOwner,
921 replace_calls: AtomicUsize,
922 }
923
924 impl RestoreFailingVisibilityOwner {
925 fn new() -> Self {
926 Self {
927 fallback_state: LocalToolVisibilityOwner::new(),
928 replace_calls: AtomicUsize::new(0),
929 }
930 }
931
932 fn replace_calls(&self) -> usize {
933 self.replace_calls.load(Ordering::SeqCst)
934 }
935 }
936
937 impl ToolVisibilityOwner for RestoreFailingVisibilityOwner {
938 fn visibility_state(
939 &self,
940 ) -> Result<SessionToolVisibilityState, crate::ToolScopeApplyError> {
941 self.fallback_state.visibility_state()
942 }
943
944 fn replace_visibility_state(
945 &self,
946 _visibility_state: SessionToolVisibilityState,
947 ) -> Result<(), crate::ToolScopeApplyError> {
948 self.replace_calls.fetch_add(1, Ordering::SeqCst);
949 Err(crate::ToolScopeApplyError::Owner {
950 message: "restore fixture rejected canonical visibility state".to_string(),
951 })
952 }
953
954 fn stage_persistent_filter(
955 &self,
956 filter: ToolFilter,
957 witnesses: BTreeMap<String, crate::ToolVisibilityWitness>,
958 ) -> Result<crate::ToolScopeRevision, crate::ToolScopeStageError> {
959 self.fallback_state
960 .stage_persistent_filter(filter, witnesses)
961 }
962
963 fn stage_requested_deferred_names(
964 &self,
965 names: BTreeSet<String>,
966 ) -> Result<crate::ToolScopeRevision, crate::ToolScopeStageError> {
967 self.fallback_state.stage_requested_deferred_names(names)
968 }
969
970 fn request_deferred_tools(
971 &self,
972 authorities: Vec<crate::DeferredToolLoadAuthority>,
973 ) -> Result<crate::ToolScopeRevision, crate::ToolScopeStageError> {
974 self.fallback_state.request_deferred_tools(authorities)
975 }
976
977 fn replace_deferred_tool_authority_catalog(
978 &self,
979 catalog: BTreeMap<String, crate::ToolVisibilityWitness>,
980 ) {
981 self.fallback_state
982 .replace_deferred_tool_authority_catalog(catalog);
983 }
984
985 fn boundary_applied(
986 &self,
987 ) -> Result<SessionToolVisibilityState, crate::ToolScopeApplyError> {
988 self.fallback_state.boundary_applied()
989 }
990 }
991
992 struct RestoreFailureCatalogDispatcher {
993 tools: Arc<[Arc<ToolDef>]>,
994 catalog: Arc<[crate::ToolCatalogEntry]>,
995 }
996
997 impl RestoreFailureCatalogDispatcher {
998 fn new() -> Self {
999 let visible = Arc::new(ToolDef::new(
1000 "visible",
1001 "visible session tool",
1002 serde_json::json!({ "type": "object" }),
1003 ));
1004 let secret = Arc::new(ToolDef::new(
1005 "secret",
1006 "policy-hidden session tool",
1007 serde_json::json!({ "type": "object" }),
1008 ));
1009 let deferred_a = Arc::new(
1010 ToolDef::new(
1011 "deferred_a",
1012 "deferred session tool",
1013 serde_json::json!({ "type": "object" }),
1014 )
1015 .with_provenance(crate::ToolProvenance {
1016 kind: crate::ToolSourceKind::Callback,
1017 source_id: "restore-fixture".into(),
1018 }),
1019 );
1020 let deferred_b = Arc::new(
1021 ToolDef::new(
1022 "deferred_b",
1023 "second deferred session tool",
1024 serde_json::json!({ "type": "object" }),
1025 )
1026 .with_provenance(crate::ToolProvenance {
1027 kind: crate::ToolSourceKind::Callback,
1028 source_id: "restore-fixture".into(),
1029 }),
1030 );
1031 let control = Arc::new(ToolDef::new(
1032 "tool_catalog_load",
1033 "deferred catalog control tool",
1034 serde_json::json!({ "type": "object" }),
1035 ));
1036
1037 Self {
1038 tools: vec![
1039 Arc::clone(&visible),
1040 Arc::clone(&secret),
1041 Arc::clone(&deferred_a),
1042 Arc::clone(&deferred_b),
1043 Arc::clone(&control),
1044 ]
1045 .into(),
1046 catalog: vec![
1047 crate::ToolCatalogEntry::session_inline(visible, true),
1048 crate::ToolCatalogEntry::session_inline(secret, true),
1049 crate::ToolCatalogEntry::session_deferred(
1050 deferred_a,
1051 true,
1052 "callback:restore-fixture".to_string(),
1053 ),
1054 crate::ToolCatalogEntry::session_deferred(
1055 deferred_b,
1056 true,
1057 "callback:restore-fixture".to_string(),
1058 ),
1059 crate::ToolCatalogEntry::control_inline(control, true),
1060 ]
1061 .into(),
1062 }
1063 }
1064 }
1065
1066 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1067 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1068 impl AgentToolDispatcher for RestoreFailureCatalogDispatcher {
1069 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
1070 Arc::clone(&self.tools)
1071 }
1072
1073 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
1074 crate::ToolCatalogCapabilities {
1075 exact_catalog: true,
1076 may_require_catalog_control_plane: false,
1077 }
1078 }
1079
1080 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
1081 Arc::clone(&self.catalog)
1082 }
1083
1084 async fn dispatch(
1085 &self,
1086 call: ToolCallView<'_>,
1087 ) -> Result<crate::ops::ToolDispatchOutcome, ToolError> {
1088 Err(ToolError::access_denied(call.name))
1089 }
1090 }
1091
1092 #[derive(Default)]
1093 struct RecordingAuthLeaseHandle {
1094 snapshots: Mutex<BTreeMap<LeaseKey, AuthLeaseSnapshot>>,
1095 }
1096
1097 impl RecordingAuthLeaseHandle {
1098 fn seed(&self, key: LeaseKey, snapshot: AuthLeaseSnapshot) {
1099 self.snapshots
1100 .lock()
1101 .unwrap_or_else(std::sync::PoisonError::into_inner)
1102 .insert(key, snapshot);
1103 }
1104 }
1105
1106 impl AuthLeaseHandle for RecordingAuthLeaseHandle {
1107 fn acquire_lease(
1108 &self,
1109 lease_key: &LeaseKey,
1110 expires_at: u64,
1111 ) -> Result<AuthLeaseTransition, DslTransitionError> {
1112 let mut snapshots = self
1113 .snapshots
1114 .lock()
1115 .unwrap_or_else(std::sync::PoisonError::into_inner);
1116 let generation = snapshots
1117 .get(lease_key)
1118 .map(|snapshot| snapshot.generation + 1)
1119 .unwrap_or(1);
1120 snapshots.insert(
1121 lease_key.clone(),
1122 AuthLeaseSnapshot {
1123 phase: Some(AuthLeasePhase::Valid),
1124 expires_at: (expires_at != u64::MAX).then_some(expires_at),
1125 credential_present: true,
1126 generation,
1127 credential_published_at_millis: None,
1128 },
1129 );
1130 Ok(AuthLeaseTransition {
1131 generation,
1132 credential_published_at_millis: None,
1133 })
1134 }
1135
1136 fn mark_expiring(&self, _lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1137 Ok(())
1138 }
1139
1140 fn begin_refresh(&self, _lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1141 Ok(())
1142 }
1143
1144 fn complete_refresh(
1145 &self,
1146 lease_key: &LeaseKey,
1147 new_expires_at: u64,
1148 _now: u64,
1149 ) -> Result<AuthLeaseTransition, DslTransitionError> {
1150 self.acquire_lease(lease_key, new_expires_at)
1151 }
1152
1153 fn refresh_failed(
1154 &self,
1155 _lease_key: &LeaseKey,
1156 _permanent: bool,
1157 ) -> Result<(), DslTransitionError> {
1158 Ok(())
1159 }
1160
1161 fn mark_reauth_required(&self, _lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1162 Ok(())
1163 }
1164
1165 fn release_lease(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1166 self.snapshots
1167 .lock()
1168 .unwrap_or_else(std::sync::PoisonError::into_inner)
1169 .remove(lease_key);
1170 Ok(())
1171 }
1172
1173 fn snapshot(&self, lease_key: &LeaseKey) -> AuthLeaseSnapshot {
1174 self.snapshots
1175 .lock()
1176 .unwrap_or_else(std::sync::PoisonError::into_inner)
1177 .get(lease_key)
1178 .cloned()
1179 .unwrap_or(AuthLeaseSnapshot {
1180 phase: None,
1181 expires_at: None,
1182 credential_present: false,
1183 generation: 0,
1184 credential_published_at_millis: None,
1185 })
1186 }
1187 }
1188
1189 fn auth_binding(binding: &str) -> AuthBindingRef {
1190 AuthBindingRef {
1191 realm: RealmId::parse("dev").expect("valid realm fixture"),
1192 binding: BindingId::parse(binding).expect("valid binding fixture"),
1193 profile: None,
1194 }
1195 }
1196
1197 #[tokio::test]
1198 async fn builder_fails_closed_when_canonical_visibility_restore_fails() {
1199 let client = Arc::new(MockClient);
1200 let tools = Arc::new(RestoreFailureCatalogDispatcher::new());
1201 let store = Arc::new(MockStore);
1202 let visibility_owner = Arc::new(RestoreFailingVisibilityOwner::new());
1203 let mut session = Session::new();
1204 let hidden_filter = match serde_json::to_value(ToolFilter::Deny(
1205 ["secret".to_string()].into_iter().collect(),
1206 )) {
1207 Ok(value) => value,
1208 Err(err) => panic!("filter should serialize: {err}"),
1209 };
1210 session.set_metadata(EXTERNAL_TOOL_FILTER_METADATA_KEY, hidden_filter);
1211
1212 let result = AgentBuilder::new()
1213 .resume_session(session)
1214 .with_tool_visibility_owner(visibility_owner.clone())
1215 .try_build_standalone(client, tools, store)
1216 .await;
1217
1218 match result {
1219 Err(AgentBuildPolicyError::ToolVisibilityRestore { message }) => {
1220 assert!(
1221 message.contains("restore fixture rejected"),
1222 "restore error should preserve owner failure details: {message}"
1223 );
1224 }
1225 Ok(agent) => {
1226 let visible_names = agent
1227 .tool_scope()
1228 .visible_tool_names()
1229 .unwrap_or_else(|_| Default::default());
1230 panic!(
1231 "builder must not complete after canonical restore failure; visible names from stale/default authority: {visible_names:?}"
1232 );
1233 }
1234 Err(err) => panic!("unexpected build error: {err}"),
1235 }
1236 assert_eq!(
1237 visibility_owner.replace_calls(),
1238 1,
1239 "builder should attempt the canonical restore once, then fail closed"
1240 );
1241 }
1242
1243 #[tokio::test]
1244 async fn builder_fails_closed_when_canonical_visibility_metadata_is_malformed() {
1245 let client = Arc::new(MockClient);
1246 let tools = Arc::new(RestoreFailureCatalogDispatcher::new());
1247 let store = Arc::new(MockStore);
1248 let mut session = Session::new();
1249 session.set_metadata(
1250 SESSION_TOOL_VISIBILITY_STATE_KEY,
1251 serde_json::json!({
1252 "active_filter": {
1253 "unexpected_filter_kind": ["secret"]
1254 }
1255 }),
1256 );
1257
1258 let result = AgentBuilder::new()
1259 .resume_session(session)
1260 .try_build_standalone(client, tools, store)
1261 .await;
1262
1263 match result {
1264 Err(AgentBuildPolicyError::ToolVisibilityRestore { message }) => {
1265 assert!(
1266 message.contains("failed to decode canonical session metadata"),
1267 "restore error should identify malformed canonical metadata: {message}"
1268 );
1269 }
1270 Ok(agent) => {
1271 let visible_names = agent
1272 .tool_scope()
1273 .visible_tool_names()
1274 .unwrap_or_else(|_| Default::default());
1275 panic!(
1276 "builder must not complete with default visibility after malformed canonical metadata; visible names: {visible_names:?}"
1277 );
1278 }
1279 Err(err) => panic!("unexpected build error: {err}"),
1280 }
1281 }
1282
1283 #[tokio::test]
1284 async fn builder_fails_closed_when_legacy_visibility_filter_metadata_is_malformed() {
1285 let client = Arc::new(MockClient);
1286 let tools = Arc::new(RestoreFailureCatalogDispatcher::new());
1287 let store = Arc::new(MockStore);
1288 let mut session = Session::new();
1289 session.set_metadata(
1290 EXTERNAL_TOOL_FILTER_METADATA_KEY,
1291 serde_json::json!({
1292 "unexpected_filter_kind": ["secret"]
1293 }),
1294 );
1295
1296 let result = AgentBuilder::new()
1297 .resume_session(session)
1298 .try_build_standalone(client, tools, store)
1299 .await;
1300
1301 match result {
1302 Err(AgentBuildPolicyError::ToolVisibilityRestore { message }) => {
1303 assert!(
1304 message.contains("failed to decode legacy session metadata"),
1305 "restore error should identify malformed legacy visibility metadata: {message}"
1306 );
1307 }
1308 Ok(agent) => {
1309 let visible_names = agent
1310 .tool_scope()
1311 .visible_tool_names()
1312 .unwrap_or_else(|_| Default::default());
1313 panic!(
1314 "builder must not complete with default visibility after malformed legacy visibility metadata; visible names: {visible_names:?}"
1315 );
1316 }
1317 Err(err) => panic!("unexpected build error: {err}"),
1318 }
1319 }
1320
1321 #[tokio::test]
1322 async fn auth_lease_rotation_preserves_existing_target_expiry() {
1323 let client = Arc::new(MockClient);
1324 let tools = Arc::new(MockTools);
1325 let store = Arc::new(MockStore);
1326 let auth_lease = Arc::new(RecordingAuthLeaseHandle::default());
1327 let previous = auth_binding("previous");
1328 let target = auth_binding("target");
1329 let target_key = LeaseKey::from_auth_binding(&target);
1330 auth_lease.seed(
1331 target_key.clone(),
1332 AuthLeaseSnapshot {
1333 phase: Some(AuthLeasePhase::Valid),
1334 expires_at: Some(1_900_000_000),
1335 credential_present: true,
1336 generation: 7,
1337 credential_published_at_millis: None,
1338 },
1339 );
1340 let agent = AgentBuilder::new()
1341 .with_auth_lease_handle(auth_lease.clone())
1342 .build_standalone(client, tools, store)
1343 .await;
1344
1345 agent
1346 .rotate_auth_lease_auth_binding(Some(&previous), Some(&target))
1347 .unwrap();
1348
1349 let snapshot = auth_lease.snapshot(&target_key);
1350 assert_eq!(snapshot.phase, Some(AuthLeasePhase::Valid));
1351 assert_eq!(snapshot.expires_at, Some(1_900_000_000));
1352 assert_eq!(snapshot.generation, 7);
1353 }
1354
1355 #[tokio::test]
1357 async fn test_regression_builder_applies_system_prompt_to_new_session() {
1358 let client = Arc::new(MockClient);
1359 let tools = Arc::new(MockTools);
1360 let store = Arc::new(MockStore);
1361
1362 let agent = AgentBuilder::new()
1363 .system_prompt("Custom system prompt")
1364 .build_standalone(client, tools, store)
1365 .await;
1366
1367 let messages = agent.session().messages();
1369 assert!(!messages.is_empty(), "Session should have messages");
1370
1371 match &messages[0] {
1372 Message::System(sys) => {
1373 assert_eq!(sys.content, "Custom system prompt");
1374 }
1375 other => panic!("First message should be System, got: {other:?}"),
1376 }
1377 }
1378
1379 #[tokio::test]
1382 async fn test_regression_builder_applies_system_prompt_to_resumed_session() {
1383 let client = Arc::new(MockClient);
1384 let tools = Arc::new(MockTools);
1385 let store = Arc::new(MockStore);
1386
1387 let mut existing_session = Session::new();
1389 existing_session.set_system_prompt("Original system prompt".to_string());
1390 existing_session.push(Message::User(UserMessage::text("Hello".to_string())));
1391
1392 let agent = AgentBuilder::new()
1394 .resume_session(existing_session)
1395 .system_prompt("Updated system prompt")
1396 .build_standalone(client, tools, store)
1397 .await;
1398
1399 let messages = agent.session().messages();
1401 assert!(!messages.is_empty(), "Session should have messages");
1402
1403 match &messages[0] {
1404 Message::System(sys) => {
1405 assert_eq!(
1406 sys.content, "Updated system prompt",
1407 "System prompt should be updated when resuming with a new prompt"
1408 );
1409 }
1410 other => panic!("First message should be System, got: {other:?}"),
1411 }
1412
1413 assert!(messages.len() >= 2, "Should have system + user messages");
1415 match &messages[1] {
1416 Message::User(user) => {
1417 assert_eq!(user.text_content(), "Hello");
1418 }
1419 other => panic!("Second message should be User, got: {other:?}"),
1420 }
1421 }
1422
1423 #[tokio::test]
1425 async fn test_builder_preserves_existing_system_prompt_on_resume() {
1426 let client = Arc::new(MockClient);
1427 let tools = Arc::new(MockTools);
1428 let store = Arc::new(MockStore);
1429
1430 let mut existing_session = Session::new();
1432 existing_session.set_system_prompt("Original system prompt".to_string());
1433
1434 let agent = AgentBuilder::new()
1436 .resume_session(existing_session)
1437 .build_standalone(client, tools, store)
1439 .await;
1440
1441 let messages = agent.session().messages();
1443 match &messages[0] {
1444 Message::System(sys) => {
1445 assert_eq!(
1446 sys.content, "Original system prompt",
1447 "Original system prompt should be preserved when not overridden"
1448 );
1449 }
1450 other => panic!("First message should be System, got: {other:?}"),
1451 }
1452 }
1453
1454 #[tokio::test]
1455 async fn runtime_backed_builder_requires_explicit_visibility_owner() {
1456 let client = Arc::new(MockClient);
1457 let tools = Arc::new(MockTools);
1458 let store = Arc::new(MockStore);
1459
1460 let result = AgentBuilder::new()
1461 .with_epoch_cursor_state(Arc::new(crate::runtime_epoch::EpochCursorState::new()))
1462 .build_inner(client, tools, store)
1463 .await;
1464
1465 assert!(matches!(
1466 result,
1467 Err(AgentBuildPolicyError::MissingToolVisibilityOwner)
1468 ));
1469 }
1470
1471 #[tokio::test]
1472 async fn runtime_backed_builder_restore_failure_returns_error_and_keeps_owner_state() {
1473 let client = Arc::new(MockClient);
1474 let tools = Arc::new(MockTools);
1475 let store = Arc::new(MockStore);
1476 let mut session = Session::new();
1477 session
1478 .set_tool_visibility_state(SessionToolVisibilityState {
1479 active_filter: ToolFilter::Deny(["secret".to_string()].into_iter().collect()),
1480 staged_filter: ToolFilter::Deny(["secret".to_string()].into_iter().collect()),
1481 active_revision: 7,
1482 staged_revision: 7,
1483 ..Default::default()
1484 })
1485 .expect("visibility state should serialize");
1486 let owner = Arc::new(RestoreFailingVisibilityOwner::new());
1487 let owner_trait: Arc<dyn ToolVisibilityOwner> = owner.clone();
1488
1489 let result = AgentBuilder::new()
1490 .resume_session(session)
1491 .with_epoch_cursor_state(Arc::new(crate::runtime_epoch::EpochCursorState::new()))
1492 .with_tool_visibility_owner(owner_trait)
1493 .build_inner(client, tools, store)
1494 .await;
1495
1496 match result {
1497 Err(AgentBuildPolicyError::ToolVisibilityRestore { message }) => {
1498 assert!(
1499 message.contains("restore fixture rejected"),
1500 "restore error should preserve owner failure details: {message}"
1501 );
1502 }
1503 Ok(_) => panic!("runtime-backed builder must fail when visibility restore fails"),
1504 Err(err) => panic!("unexpected build error: {err}"),
1505 }
1506 assert_eq!(
1507 owner.visibility_state().unwrap(),
1508 SessionToolVisibilityState::default(),
1509 "failed runtime restore must leave the owner state unchanged"
1510 );
1511 assert_eq!(
1512 owner.replace_calls(),
1513 1,
1514 "builder should attempt the runtime restore once, then fail closed"
1515 );
1516 }
1517
1518 #[tokio::test]
1519 async fn runtime_backed_builder_rejects_malformed_canonical_visibility_state() {
1520 let client = Arc::new(MockClient);
1521 let tools = Arc::new(MockTools);
1522 let store = Arc::new(MockStore);
1523 let mut session = Session::new();
1524 session.set_metadata(
1525 SESSION_TOOL_VISIBILITY_STATE_KEY,
1526 serde_json::json!("not-a-visibility-state"),
1527 );
1528 let original_state = SessionToolVisibilityState {
1529 active_filter: ToolFilter::Deny(["secret".to_string()].into_iter().collect()),
1530 staged_filter: ToolFilter::Deny(["secret".to_string()].into_iter().collect()),
1531 active_revision: 3,
1532 staged_revision: 3,
1533 ..Default::default()
1534 };
1535 let owner = Arc::new(LocalToolVisibilityOwner::new());
1536 owner
1537 .replace_visibility_state(original_state.clone())
1538 .expect("test owner should accept initial state");
1539 let owner_trait: Arc<dyn ToolVisibilityOwner> = owner.clone();
1540
1541 let result = AgentBuilder::new()
1542 .resume_session(session)
1543 .with_epoch_cursor_state(Arc::new(crate::runtime_epoch::EpochCursorState::new()))
1544 .with_tool_visibility_owner(owner_trait)
1545 .build_inner(client, tools, store)
1546 .await;
1547
1548 match result {
1549 Err(AgentBuildPolicyError::ToolVisibilityRestore { message }) => {
1550 assert!(
1551 message.contains("failed to decode canonical session metadata"),
1552 "restore error should identify malformed canonical metadata: {message}"
1553 );
1554 }
1555 Ok(_) => panic!("runtime-backed builder must reject malformed canonical visibility"),
1556 Err(err) => panic!("unexpected build error: {err}"),
1557 }
1558 assert_eq!(
1559 owner.visibility_state().unwrap(),
1560 original_state,
1561 "failed malformed restore must not install default visibility"
1562 );
1563 }
1564
1565 #[tokio::test]
1566 async fn runtime_backed_builder_ignores_legacy_local_visibility_metadata() {
1567 let client = Arc::new(MockClient);
1568 let tools = Arc::new(MockTools);
1569 let store = Arc::new(MockStore);
1570 let mut session = Session::new();
1571 session.set_metadata(
1572 EXTERNAL_TOOL_FILTER_METADATA_KEY,
1573 serde_json::to_value(ToolFilter::Deny(
1574 ["secret".to_string()].into_iter().collect(),
1575 ))
1576 .expect("legacy filter should serialize"),
1577 );
1578 let owner = Arc::new(LocalToolVisibilityOwner::new());
1579 let owner_trait: Arc<dyn ToolVisibilityOwner> = owner.clone();
1580
1581 let result = AgentBuilder::new()
1582 .resume_session(session)
1583 .with_epoch_cursor_state(Arc::new(crate::runtime_epoch::EpochCursorState::new()))
1584 .with_tool_visibility_owner(owner_trait)
1585 .build_inner(client, tools, store)
1586 .await;
1587
1588 assert!(result.is_ok());
1589 let state = owner.visibility_state().unwrap();
1590 assert_eq!(state.active_filter, ToolFilter::All);
1591 assert_eq!(state.staged_filter, ToolFilter::All);
1592 }
1593
1594 #[tokio::test]
1595 async fn runtime_backed_builder_rejects_legacy_inherited_visibility_metadata() {
1596 let client = Arc::new(MockClient);
1597 let tools = Arc::new(MockTools);
1598 let store = Arc::new(MockStore);
1599 let mut session = Session::new();
1600 session.set_metadata(
1601 INHERITED_TOOL_FILTER_METADATA_KEY,
1602 serde_json::to_value(ToolFilter::Deny(
1603 ["secret".to_string()].into_iter().collect(),
1604 ))
1605 .expect("legacy inherited filter should serialize"),
1606 );
1607 let owner = Arc::new(LocalToolVisibilityOwner::new());
1608 let owner_trait: Arc<dyn ToolVisibilityOwner> = owner.clone();
1609
1610 let result = AgentBuilder::new()
1611 .resume_session(session)
1612 .with_epoch_cursor_state(Arc::new(crate::runtime_epoch::EpochCursorState::new()))
1613 .with_tool_visibility_owner(owner_trait)
1614 .build_inner(client, tools, store)
1615 .await;
1616
1617 assert!(matches!(
1618 result,
1619 Err(AgentBuildPolicyError::LegacyInheritedToolFilterMetadata)
1620 ));
1621 assert_eq!(
1622 owner.visibility_state().unwrap(),
1623 SessionToolVisibilityState::default(),
1624 "failed legacy inherited metadata restore must leave owner visibility unchanged"
1625 );
1626 }
1627
1628 #[tokio::test]
1629 async fn runtime_backed_builder_rejects_name_only_canonical_inherited_visibility_state() {
1630 let client = Arc::new(MockClient);
1631 let tools = Arc::new(MockTools);
1632 let store = Arc::new(MockStore);
1633 let mut session = Session::new();
1634 session
1635 .set_tool_visibility_state(SessionToolVisibilityState {
1636 inherited_base_filter: ToolFilter::Allow(
1637 ["secret".to_string()].into_iter().collect(),
1638 ),
1639 ..Default::default()
1640 })
1641 .expect("visibility state should serialize");
1642 let owner = Arc::new(LocalToolVisibilityOwner::new());
1643 let owner_trait: Arc<dyn ToolVisibilityOwner> = owner.clone();
1644
1645 let result = AgentBuilder::new()
1646 .resume_session(session)
1647 .with_epoch_cursor_state(Arc::new(crate::runtime_epoch::EpochCursorState::new()))
1648 .with_tool_visibility_owner(owner_trait)
1649 .build_inner(client, tools, store)
1650 .await;
1651
1652 assert!(matches!(
1653 result,
1654 Err(AgentBuildPolicyError::MissingInheritedToolVisibilityWitnesses)
1655 ));
1656 assert_eq!(
1657 owner.visibility_state().unwrap(),
1658 SessionToolVisibilityState::default(),
1659 "failed name-only inherited restore must leave owner visibility unchanged"
1660 );
1661 }
1662
1663 #[tokio::test]
1664 async fn runtime_backed_builder_restores_canonical_inherited_visibility_state() {
1665 let client = Arc::new(MockClient);
1666 let tools = Arc::new(MockTools);
1667 let store = Arc::new(MockStore);
1668 let inherited_filter = ToolFilter::Deny(["secret".to_string()].into_iter().collect());
1669 let mut session = Session::new();
1670 session
1671 .set_tool_visibility_state(SessionToolVisibilityState {
1672 inherited_base_filter: inherited_filter.clone(),
1673 filter_witnesses: [(
1674 "secret".to_string(),
1675 crate::ToolVisibilityWitness {
1676 stable_owner_key: Some("test-owner:secret".to_string()),
1677 last_seen_provenance: None,
1678 },
1679 )]
1680 .into_iter()
1681 .collect(),
1682 ..Default::default()
1683 })
1684 .expect("visibility state should serialize");
1685 let owner = Arc::new(LocalToolVisibilityOwner::new());
1686 let owner_trait: Arc<dyn ToolVisibilityOwner> = owner.clone();
1687
1688 let result = AgentBuilder::new()
1689 .resume_session(session)
1690 .with_epoch_cursor_state(Arc::new(crate::runtime_epoch::EpochCursorState::new()))
1691 .with_tool_visibility_owner(owner_trait)
1692 .build_inner(client, tools, store)
1693 .await;
1694
1695 assert!(result.is_ok());
1696 assert_eq!(
1697 owner.visibility_state().unwrap().inherited_base_filter,
1698 inherited_filter,
1699 "canonical inherited metadata should restore through the visibility owner"
1700 );
1701 }
1702
1703 #[tokio::test]
1704 async fn runtime_backed_builder_does_not_seed_execution_kind() {
1705 let client = Arc::new(MockClient);
1706 let tools = Arc::new(MockTools);
1707 let store = Arc::new(MockStore);
1708
1709 let agent = AgentBuilder::new()
1710 .with_turn_state_handle(Arc::new(
1711 crate::agent::test_turn_state_handle::TestTurnStateHandle::new(),
1712 ))
1713 .with_tool_visibility_owner(explicit_test_visibility_owner())
1714 .require_runtime_execution_kind_stamp()
1715 .build_standalone(client, tools, store)
1716 .await;
1717
1718 assert_eq!(agent.runtime_execution_kind, None);
1719 assert!(agent.runtime_execution_kind_required);
1720 }
1721
1722 #[tokio::test]
1723 async fn runtime_backed_run_rejects_missing_execution_kind() {
1724 let client = Arc::new(MockClient);
1725 let tools = Arc::new(MockTools);
1726 let store = Arc::new(MockStore);
1727
1728 let mut agent = AgentBuilder::new()
1729 .with_turn_state_handle(Arc::new(
1730 crate::agent::test_turn_state_handle::TestTurnStateHandle::new(),
1731 ))
1732 .with_tool_visibility_owner(explicit_test_visibility_owner())
1733 .require_runtime_execution_kind_stamp()
1734 .build_standalone(client, tools, store)
1735 .await;
1736
1737 let err = agent
1738 .run("hello".to_string().into())
1739 .await
1740 .expect_err("runtime-backed runs must be stamped before execution");
1741
1742 assert!(
1743 err.to_string().contains("runtime_execution_kind not set"),
1744 "unexpected error: {err}"
1745 );
1746 }
1747
1748 #[tokio::test]
1749 async fn runtime_backed_run_pending_rejects_missing_execution_kind() {
1750 let client = Arc::new(MockClient);
1751 let tools = Arc::new(MockTools);
1752 let store = Arc::new(MockStore);
1753
1754 let mut session = Session::new();
1755 session.push(Message::User(UserMessage::text("resume me".to_string())));
1756
1757 let mut agent = AgentBuilder::new()
1758 .resume_session(session)
1759 .with_turn_state_handle(Arc::new(
1760 crate::agent::test_turn_state_handle::TestTurnStateHandle::new(),
1761 ))
1762 .with_tool_visibility_owner(explicit_test_visibility_owner())
1763 .require_runtime_execution_kind_stamp()
1764 .build_standalone(client, tools, store)
1765 .await;
1766
1767 let err = agent
1768 .run_pending()
1769 .await
1770 .expect_err("runtime-backed pending runs must be stamped before execution");
1771
1772 assert!(
1773 err.to_string().contains("runtime_execution_kind not set"),
1774 "unexpected error: {err}"
1775 );
1776 }
1777
1778 #[tokio::test]
1779 async fn runtime_backed_run_consumes_execution_kind_stamp() {
1780 let client = Arc::new(MockClient);
1781 let tools = Arc::new(MockTools);
1782 let store = Arc::new(MockStore);
1783
1784 let mut agent = AgentBuilder::new()
1785 .with_turn_state_handle(Arc::new(
1786 crate::agent::test_turn_state_handle::TestTurnStateHandle::new(),
1787 ))
1788 .with_tool_visibility_owner(explicit_test_visibility_owner())
1789 .require_runtime_execution_kind_stamp()
1790 .build_standalone(client, tools, store)
1791 .await;
1792 agent.set_runtime_execution_kind(Some(crate::lifecycle::RuntimeExecutionKind::ContentTurn));
1793
1794 agent
1795 .run("hello".to_string().into())
1796 .await
1797 .expect("stamped runtime-backed run should succeed");
1798
1799 let err = agent
1800 .run("raw follow-up".to_string().into())
1801 .await
1802 .expect_err("runtime-backed follow-up run must require a fresh stamp");
1803
1804 assert!(
1805 err.to_string().contains("runtime_execution_kind not set"),
1806 "unexpected error: {err}"
1807 );
1808 }
1809
1810 #[tokio::test]
1811 async fn runtime_backed_cancel_consumes_execution_kind_stamp() {
1812 let client = Arc::new(MockClient);
1813 let tools = Arc::new(MockTools);
1814 let store = Arc::new(MockStore);
1815
1816 let mut agent = AgentBuilder::new()
1817 .with_turn_state_handle(Arc::new(
1818 crate::agent::test_turn_state_handle::TestTurnStateHandle::new(),
1819 ))
1820 .with_tool_visibility_owner(explicit_test_visibility_owner())
1821 .require_runtime_execution_kind_stamp()
1822 .build_standalone(client, tools, store)
1823 .await;
1824 agent.set_runtime_execution_kind(Some(crate::lifecycle::RuntimeExecutionKind::ContentTurn));
1825
1826 agent.cancel();
1827
1828 let err = agent
1829 .run("raw follow-up".to_string().into())
1830 .await
1831 .expect_err("runtime-backed follow-up run must require a fresh stamp after cancel");
1832
1833 assert!(
1834 err.to_string().contains("runtime_execution_kind not set"),
1835 "unexpected error: {err}"
1836 );
1837 }
1838
1839 #[tokio::test]
1840 async fn test_builder_event_tap_receives_turn_started_without_primary_event_tx() {
1841 let client = Arc::new(MockClient);
1842 let tools = Arc::new(MockTools);
1843 let store = Arc::new(MockStore);
1844
1845 let tap = crate::event_tap::new_event_tap();
1846 let (tap_tx, mut tap_rx) = mpsc::channel(128);
1847 {
1848 let mut guard = tap.lock();
1849 *guard = Some(EventTapState {
1850 tx: tap_tx,
1851 truncated: AtomicBool::new(false),
1852 });
1853 }
1854
1855 let mut agent = AgentBuilder::new()
1856 .with_turn_state_handle(Arc::new(
1857 crate::agent::test_turn_state_handle::TestTurnStateHandle::new(),
1858 ))
1859 .with_event_tap(tap)
1860 .build_standalone(client, tools, store)
1861 .await;
1862 agent.set_runtime_execution_kind(Some(crate::lifecycle::RuntimeExecutionKind::ContentTurn));
1863
1864 let result = agent.run("hello".to_string().into()).await;
1865 assert!(result.is_ok());
1866
1867 let mut saw_turn_started = false;
1868 while let Ok(event) = tap_rx.try_recv() {
1869 if matches!(event, AgentEvent::TurnStarted { .. }) {
1870 saw_turn_started = true;
1871 break;
1872 }
1873 }
1874 assert!(
1875 saw_turn_started,
1876 "tap should receive TurnStarted even without primary event channel"
1877 );
1878 }
1879
1880 #[tokio::test]
1884 async fn test_builder_seeds_applied_cursor_from_feed_watermark() {
1885 use crate::completion_feed::tests::MockCompletionFeed;
1886
1887 let client = Arc::new(MockClient);
1888 let tools = Arc::new(MockTools);
1889 let store = Arc::new(MockStore);
1890
1891 let feed = Arc::new(MockCompletionFeed::with_watermark(42));
1893
1894 let agent = AgentBuilder::new()
1895 .with_completion_feed(feed)
1896 .build_standalone(client, tools, store)
1897 .await;
1898
1899 assert_eq!(
1900 agent.applied_cursor, 42,
1901 "applied_cursor must seed from feed watermark, not 0"
1902 );
1903 }
1904
1905 #[tokio::test]
1907 async fn test_builder_applied_cursor_zero_without_feed() {
1908 let client = Arc::new(MockClient);
1909 let tools = Arc::new(MockTools);
1910 let store = Arc::new(MockStore);
1911
1912 let agent = AgentBuilder::new()
1913 .build_standalone(client, tools, store)
1914 .await;
1915
1916 assert_eq!(agent.applied_cursor, 0);
1917 }
1918}