Skip to main content

meerkat_core/agent/
builder.rs

1//! Agent builder.
2
3use 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/// Builder for creating an Agent
37#[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/// Error returned when the canonical factory has not composed the policy state
77/// required before crossing into core agent construction.
78#[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    // The only authority core accepts is a positive check from the linked
165    // facade crate. Public inventory/source metadata is intentionally not part
166    // of this proof because downstream crates can spoof package names, source
167    // paths, and fingerprints.
168    #[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    /// Create a new agent builder with default config
179    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    /// Set concurrency limits for delegated branches
217    pub fn concurrency_limits(mut self, limits: ConcurrencyLimits) -> Self {
218        self.concurrency_limits = limits;
219        self
220    }
221
222    /// Set the nesting depth for delegated branches
223    pub fn depth(mut self, depth: u32) -> Self {
224        self.depth = depth;
225        self
226    }
227
228    /// Set the model to use
229    pub fn model(mut self, model: impl Into<String>) -> Self {
230        self.config.model = model.into();
231        self
232    }
233
234    /// Set the system prompt
235    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
236        self.system_prompt = Some(prompt.into());
237        self
238    }
239
240    /// Set max tokens per turn
241    pub fn max_tokens_per_turn(mut self, tokens: u32) -> Self {
242        self.config.max_tokens_per_turn = tokens;
243        self
244    }
245
246    /// Set temperature
247    pub fn temperature(mut self, temp: f32) -> Self {
248        self.config.temperature = Some(temp);
249        self
250    }
251
252    /// Set budget limits
253    pub fn budget(mut self, limits: BudgetLimits) -> Self {
254        self.budget_limits = Some(limits);
255        self
256    }
257
258    /// Set provider-specific parameters
259    pub fn provider_params(mut self, params: Value) -> Self {
260        self.config.provider_params = Some(params);
261        self
262    }
263
264    /// Set provider-native tool defaults (resolved at build time, not persisted).
265    pub fn provider_tool_defaults(mut self, defaults: Value) -> Self {
266        self.config.provider_tool_defaults = Some(defaults);
267        self
268    }
269
270    /// Set retry policy for LLM calls
271    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
272        self.retry_policy = policy;
273        self
274    }
275
276    /// Set output schema for structured output extraction
277    pub fn output_schema(mut self, schema: OutputSchema) -> Self {
278        self.config.output_schema = Some(schema);
279        self
280    }
281
282    /// Set the memory store for indexing compaction discards.
283    pub fn memory_store(mut self, store: Arc<dyn crate::memory::MemoryStore>) -> Self {
284        self.memory_store = Some(store);
285        self
286    }
287
288    /// Set maximum retries for structured output validation
289    pub fn structured_output_retries(mut self, retries: u32) -> Self {
290        self.config.structured_output_retries = retries;
291        self
292    }
293
294    /// Resume from an existing session
295    pub fn resume_session(mut self, session: Session) -> Self {
296        self.session = Some(session);
297        self
298    }
299
300    /// Set the comms runtime.
301    pub fn with_comms_runtime(mut self, runtime: Arc<dyn CommsRuntime>) -> Self {
302        self.comms_runtime = Some(runtime);
303        self
304    }
305
306    /// Set the hook engine.
307    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    /// Set run-scoped hook overrides.
313    pub fn with_hook_run_overrides(mut self, overrides: HookRunOverrides) -> Self {
314        self.hook_run_overrides = overrides;
315        self
316    }
317
318    /// Set the context compactor.
319    pub fn compactor(mut self, compactor: Arc<dyn crate::compact::Compactor>) -> Self {
320        self.compactor = Some(compactor);
321        self
322    }
323
324    /// Build a standalone low-level agent without facade/factory policy.
325    ///
326    /// This is an explicit escape hatch for core tests that own every loop
327    /// primitive themselves. Production-facing Meerkat surfaces route through
328    /// `AgentFactory::build_agent`.
329    #[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    /// Build a standalone low-level agent and surface core build failures.
349    #[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        // Session-owned runtime builds always carry one of these markers. The
389        // standalone test seam may still use local visibility, but a
390        // runtime-backed build must pass its machine-owned visibility owner
391        // explicitly instead of receiving a hidden local fallback.
392        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        // Apply system prompt: use builder's prompt if set, otherwise compose default for new sessions
429        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            // Only set default prompt for new sessions without an existing system prompt
434            #[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            // Seed from epoch cursor state if available (runtime-backed surfaces),
518            // otherwise fall back to the feed watermark to avoid replaying retained
519            // completions from prior agent lifetimes (stop/resume, live reattach).
520            // Same pattern as runtime_loop.rs line 276. Computed before move.
521            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    /// Set the session checkpointer for keep-alive persistence.
662    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    /// Set the blob store used to hydrate image refs before execution.
671    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    /// Set comms intents that should be silently injected into the session
677    /// without triggering an LLM turn.
678    pub fn with_silent_comms_intents(mut self, intents: Vec<String>) -> Self {
679        self.silent_comms_intents = intents;
680        self
681    }
682
683    /// Set max peer-count threshold for inline peer lifecycle notification injection.
684    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    /// Set the ops lifecycle registry for async operation tracking.
690    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    /// Set the completion feed for cursor-based completion delivery.
699    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    /// Set the enrichment provider for completion display details.
708    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    /// Set the skill engine for per-turn `/skill-ref` activation.
717    pub fn with_skill_engine(mut self, engine: Arc<crate::skills::SkillRuntime>) -> Self {
718        self.skill_engine = Some(engine);
719        self
720    }
721
722    /// Set the event tap for interaction-scoped streaming.
723    pub fn with_event_tap(mut self, tap: crate::event_tap::EventTap) -> Self {
724        self.event_tap = Some(tap);
725        self
726    }
727
728    /// Set a default event channel used when run methods are called without
729    /// per-call event channels.
730    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    /// Set the model operational defaults resolver for profile-derived call timeouts.
739    ///
740    /// The resolver is consulted at each LLM call to look up model-specific
741    /// operational defaults (e.g., call timeout) for the current effective
742    /// model/provider. This enables hot-swap-aware default resolution.
743    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    /// Set the shared epoch cursor state for runtime-backed cursor writeback.
752    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    /// Set the canonical durable tool-visibility owner for this build.
761    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    /// Set the runtime-backed turn-state diagnostic handle for this build.
767    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    /// Require runtime-stamped execution kind metadata before executing turns.
773    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    /// Set the runtime-backed external tool-surface diagnostic handle for this build.
788    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    /// Set the runtime-backed auth lease handle for this build (Phase 1.5-rev).
797    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    /// Set the runtime-backed MCP server lifecycle handle for this build
806    /// (Phase 5G / T5g).
807    ///
808    /// When set, the agent loop reads `pending_server_ids()` from this handle
809    /// to decide whether to emit the `[MCP_PENDING]` system notice at each
810    /// CallingLlm boundary — authoritative DSL state replaces the shell-level
811    /// `ext.pending` check.
812    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    /// Set the explicit call-timeout override from the build/config composition seam.
821    ///
822    /// - `Inherit`: defer to profile-derived default via the resolver
823    /// - `Disabled`: explicitly suppress call timeout
824    /// - `Value(d)`: explicitly set call timeout to `d`
825    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    /// Regression test: AgentBuilder should apply system_prompt to new sessions
1356    #[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        // Check that the system prompt was applied
1368        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    /// Regression test: AgentBuilder should apply system_prompt to resumed sessions
1380    /// Previously, system_prompt was ignored when resuming a session.
1381    #[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        // Create a session with an existing system prompt
1388        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        // Resume the session with a NEW system prompt
1393        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        // Check that the system prompt was UPDATED
1400        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        // User message should still be preserved
1414        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    /// Regression test: Resumed sessions without explicit system_prompt should keep their original
1424    #[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        // Create a session with an existing system prompt
1431        let mut existing_session = Session::new();
1432        existing_session.set_system_prompt("Original system prompt".to_string());
1433
1434        // Resume WITHOUT specifying a new system prompt
1435        let agent = AgentBuilder::new()
1436            .resume_session(existing_session)
1437            // Note: no .system_prompt() call
1438            .build_standalone(client, tools, store)
1439            .await;
1440
1441        // Original system prompt should be preserved
1442        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    /// Regression: agent builder must seed applied_cursor from the feed's
1881    /// current watermark, not from 0. Starting from 0 replays every retained
1882    /// completion as new after stop/resume or live reattachment.
1883    #[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        // Feed already has activity at watermark 42.
1892        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    /// Regression: without a feed, applied_cursor must be 0.
1906    #[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}