Skip to main content

everruns_core/
runtime_agent.rs

1// Runtime agent configuration for the loop
2//
3// RuntimeAgent is a DB-agnostic configuration struct that can be:
4// - Created directly for standalone usage
5// - Built from a AgentConfigOverlay via `from_overlay()` (preferred)
6// - Built from individual Harness/Agent entities via builder methods (legacy)
7//
8// Preferred usage: merge Harness/Agent/Session into a AgentConfigOverlay, then:
9//   RuntimeAgentBuilder::from_overlay(layer, &registry, &ctx).await
10//       .model("gpt-5.2")
11//       .build()
12//
13// Legacy per-entity methods (with_harness, with_agent) are kept for
14// backward compatibility but the AgentConfigOverlay path is canonical.
15
16use crate::agent::Agent;
17use crate::capabilities::{
18    CapabilityRegistry, SystemPromptContext, ToolDefinitionHook, collect_capabilities_with_configs,
19    resolve_capability_configs,
20};
21use crate::config_layer::AgentConfigOverlay;
22use crate::harness::Harness;
23use crate::llm_driver_registry::{PromptCacheConfig, ToolSearchConfig};
24use crate::llm_model_profiles::get_model_profile;
25use crate::llm_models::LlmProviderType;
26use crate::tool_types::ToolDefinition;
27use serde::{Deserialize, Serialize};
28
29/// Runtime configuration for the agent loop
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct RuntimeAgent {
32    /// System prompt that defines the agent's behavior
33    pub system_prompt: String,
34
35    /// Model identifier (e.g., "gpt-5.2", "claude-3-opus")
36    pub model: String,
37
38    /// Available tools for the agent
39    #[serde(default)]
40    pub tools: Vec<ToolDefinition>,
41
42    /// Maximum number of tool-calling iterations (prevents infinite loops)
43    #[serde(default = "default_max_iterations")]
44    pub max_iterations: usize,
45
46    /// Temperature for LLM sampling (0.0 - 2.0)
47    #[serde(default)]
48    pub temperature: Option<f32>,
49
50    /// Maximum tokens to generate per response
51    #[serde(default)]
52    pub max_tokens: Option<u32>,
53
54    /// Tool search config (set by openai_tool_search capability)
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub tool_search: Option<ToolSearchConfig>,
57
58    /// Prompt caching config (set by prompt_caching capability)
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub prompt_cache: Option<PromptCacheConfig>,
61
62    /// Merged network access list (harness ∩ agent ∩ session).
63    /// Used by tools (web_fetch) to enforce URL access policy.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub network_access: Option<crate::network_access::NetworkAccessList>,
66}
67
68/// Default maximum iterations per turn (500).
69///
70/// Resolution priority: session override > agent config > this default.
71pub fn default_max_iterations() -> usize {
72    500
73}
74
75impl RuntimeAgent {
76    /// Create a new runtime agent configuration with required fields only
77    pub fn new(system_prompt: impl Into<String>, model: impl Into<String>) -> Self {
78        Self {
79            system_prompt: system_prompt.into(),
80            model: model.into(),
81            tools: Vec::new(),
82            max_iterations: default_max_iterations(),
83            temperature: None,
84            max_tokens: None,
85            tool_search: None,
86            prompt_cache: None,
87            network_access: None,
88        }
89    }
90}
91
92impl Default for RuntimeAgent {
93    fn default() -> Self {
94        Self {
95            system_prompt: "You are a helpful assistant.".to_string(),
96            model: "gpt-5.2".to_string(),
97            tools: Vec::new(),
98            max_iterations: default_max_iterations(),
99            temperature: None,
100            max_tokens: None,
101            tool_search: None,
102            prompt_cache: None,
103            network_access: None,
104        }
105    }
106}
107
108/// Builder for RuntimeAgent with fluent API
109///
110/// Use `new()` to start building, then chain methods like `with_agent()`,
111/// `model()`, `temperature()`, etc. Call `build()` to get the final runtime agent.
112pub struct RuntimeAgentBuilder {
113    runtime_agent: RuntimeAgent,
114    tool_definition_hooks: Vec<std::sync::Arc<dyn ToolDefinitionHook>>,
115}
116
117impl RuntimeAgentBuilder {
118    /// Start building a new runtime agent from scratch
119    pub fn new() -> Self {
120        Self {
121            runtime_agent: RuntimeAgent::default(),
122            tool_definition_hooks: Vec::new(),
123        }
124    }
125
126    /// Build from a pre-merged AgentConfigOverlay.
127    ///
128    /// This is the preferred way to build a RuntimeAgent. The caller merges
129    /// Harness/Agent/Session into a single AgentConfigOverlay (via `AgentConfigOverlay::fold`),
130    /// then this method resolves capabilities and assembles the final config.
131    ///
132    /// # Example
133    ///
134    /// ```ignore
135    /// let layer = AgentConfigOverlay::fold([
136    ///     AgentConfigOverlay::from(&harness),
137    ///     AgentConfigOverlay::from(&agent),
138    ///     AgentConfigOverlay::from(&session),
139    /// ]);
140    /// let runtime_agent = RuntimeAgentBuilder::from_overlay(layer, &registry, &ctx)
141    ///     .await
142    ///     .model("gpt-5.2")
143    ///     .build();
144    /// ```
145    pub async fn from_overlay(
146        layer: AgentConfigOverlay,
147        registry: &CapabilityRegistry,
148        ctx: &SystemPromptContext,
149    ) -> Self {
150        let mut builder = Self::new();
151
152        // Always set system prompt (even to empty) so an intentionally empty
153        // merged prompt clears the builder default instead of leaving it.
154        builder = builder.system_prompt(layer.system_prompt.unwrap_or_default());
155
156        // Resolve merged capabilities (once, on the effective set)
157        builder = builder
158            .with_capability_configs(&layer.capabilities, registry, ctx)
159            .await;
160
161        // Add tools from all layers
162        if !layer.tools.is_empty() {
163            builder = builder.tools(layer.tools);
164        }
165
166        // Set max_iterations if any layer specified it
167        if let Some(max) = layer.max_iterations {
168            builder = builder.max_iterations(max);
169        }
170
171        // Set merged network_access
172        builder = builder.network_access(layer.network_access);
173
174        builder
175    }
176
177    /// Apply a Harness's configuration to this builder.
178    ///
179    /// Sets the system prompt from the harness and applies harness capabilities.
180    /// Calls `system_prompt_contribution()` on each capability for dynamic content.
181    /// Call this BEFORE `with_agent()` to establish the base prompt layer.
182    pub async fn with_harness(
183        self,
184        harness: &Harness,
185        registry: &CapabilityRegistry,
186        ctx: &SystemPromptContext,
187    ) -> Self {
188        self.system_prompt(&harness.system_prompt)
189            .with_capability_configs(&harness.capabilities, registry, ctx)
190            .await
191    }
192
193    /// Apply an Agent's configuration to this builder.
194    ///
195    /// Prepends the agent's system prompt and capabilities on top of the
196    /// existing prompt (typically from a harness). Call after `with_harness()`.
197    ///
198    /// # Example
199    ///
200    /// ```ignore
201    /// let ctx = SystemPromptContext::without_file_store(session_id);
202    /// let runtime_agent = RuntimeAgentBuilder::new()
203    ///     .with_harness(&harness, &registry, &ctx).await
204    ///     .with_agent(&agent, &registry, &ctx).await
205    ///     .with_capabilities(&session_caps, &registry, &ctx).await
206    ///     .model("gpt-4o")
207    ///     .build();
208    /// ```
209    pub async fn with_agent(
210        self,
211        agent: &Agent,
212        registry: &CapabilityRegistry,
213        ctx: &SystemPromptContext,
214    ) -> Self {
215        let mut builder = self
216            .system_prompt(&agent.system_prompt)
217            .with_capability_configs(&agent.capabilities, registry, ctx)
218            .await;
219
220        // Add agent-level client-side tools
221        if !agent.tools.is_empty() {
222            builder = builder.tools(agent.tools.clone());
223        }
224
225        builder
226    }
227
228    /// Apply capabilities to this builder.
229    ///
230    /// Resolves dependencies, then collects contributions from capabilities:
231    /// - Dependencies are automatically included (topologically sorted)
232    /// - `system_prompt_contribution(ctx)` called on each (may read from filesystem)
233    /// - System prompt additions are prepended to the current system prompt
234    /// - Tool definitions are added to the tools list
235    ///
236    /// # Arguments
237    ///
238    /// * `capability_ids` - Ordered list of capability IDs to apply
239    /// * `registry` - The capability registry containing implementations
240    /// * `ctx` - Session context for dynamic prompt resolution
241    pub async fn with_capabilities(
242        self,
243        capability_ids: &[String],
244        registry: &CapabilityRegistry,
245        ctx: &SystemPromptContext,
246    ) -> Self {
247        let capability_configs: Vec<crate::AgentCapabilityConfig> = capability_ids
248            .iter()
249            .map(|id| crate::AgentCapabilityConfig::new(id.clone()))
250            .collect();
251        self.with_capability_configs(&capability_configs, registry, ctx)
252            .await
253    }
254
255    /// Apply capability configs to this builder, preserving per-capability configuration.
256    pub async fn with_capability_configs(
257        mut self,
258        capability_configs: &[crate::AgentCapabilityConfig],
259        registry: &CapabilityRegistry,
260        ctx: &SystemPromptContext,
261    ) -> Self {
262        let resolved_configs = match resolve_capability_configs(capability_configs, registry) {
263            Ok(resolved) => resolved,
264            Err(e) => {
265                tracing::warn!("Failed to resolve capability dependencies: {}", e);
266                capability_configs.to_vec()
267            }
268        };
269
270        let collected = collect_capabilities_with_configs(&resolved_configs, registry, ctx).await;
271
272        // Apply system prompt additions (prepend to existing, wrap base in XML tags)
273        if let Some(prefix) = collected.system_prompt_prefix() {
274            if !self.runtime_agent.system_prompt.is_empty()
275                && !self.runtime_agent.system_prompt.contains("<system-prompt>")
276            {
277                self.runtime_agent.system_prompt = format!(
278                    "<system-prompt>\n{}\n</system-prompt>",
279                    self.runtime_agent.system_prompt
280                );
281            }
282            self = self.prepend_system_prompt(prefix);
283        }
284
285        // Apply tool definitions
286        if !collected.tool_definitions.is_empty() {
287            self = self.tools(collected.tool_definitions);
288        }
289
290        // Apply tool_search config if capability provided one
291        if let Some(ts_config) = collected.tool_search {
292            self.runtime_agent.tool_search = Some(ts_config);
293        }
294
295        if let Some(pc_config) = collected.prompt_cache {
296            self.runtime_agent.prompt_cache = Some(pc_config);
297        }
298
299        self.tool_definition_hooks
300            .extend(collected.tool_definition_hooks);
301
302        self
303    }
304
305    /// Set the system prompt
306    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
307        self.runtime_agent.system_prompt = prompt.into();
308        self
309    }
310
311    /// Prepend text to the system prompt
312    pub fn prepend_system_prompt(mut self, prefix: impl Into<String>) -> Self {
313        let prefix = prefix.into();
314        if !prefix.is_empty() {
315            self.runtime_agent.system_prompt =
316                format!("{}\n\n{}", prefix, self.runtime_agent.system_prompt);
317        }
318        self
319    }
320
321    /// Prepend locale instructions for session-aware localization.
322    pub fn with_locale(self, locale: Option<&str>) -> Self {
323        let Some(locale) = locale.map(str::trim).filter(|value| !value.is_empty()) else {
324            return self;
325        };
326
327        self.prepend_system_prompt(format!(
328            "<locale preference=\"{locale}\">\n\
329             Default locale for this session: {locale}.\n\
330             Unless the user explicitly asks otherwise, respond in this locale and use its language, spelling, and regional formatting conventions for dates, times, numbers, and currency.\n\
331             </locale>"
332        ))
333    }
334
335    /// Set the model
336    pub fn model(mut self, model: impl Into<String>) -> Self {
337        self.runtime_agent.model = model.into();
338        self
339    }
340
341    /// Add a tool
342    pub fn tool(mut self, tool: ToolDefinition) -> Self {
343        self.runtime_agent.tools.push(tool);
344        self
345    }
346
347    /// Add multiple tools
348    pub fn tools(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
349        self.runtime_agent.tools.extend(tools);
350        self
351    }
352
353    /// Set maximum iterations
354    pub fn max_iterations(mut self, max: usize) -> Self {
355        self.runtime_agent.max_iterations = max;
356        self
357    }
358
359    /// Set the merged network access list.
360    pub fn network_access(
361        mut self,
362        network_access: Option<crate::network_access::NetworkAccessList>,
363    ) -> Self {
364        self.runtime_agent.network_access = network_access;
365        self
366    }
367
368    /// Set temperature
369    pub fn temperature(mut self, temp: f32) -> Self {
370        self.runtime_agent.temperature = Some(temp);
371        self
372    }
373
374    /// Set max tokens
375    pub fn max_tokens(mut self, tokens: u32) -> Self {
376        self.runtime_agent.max_tokens = Some(tokens);
377        self
378    }
379
380    /// Set tool_search configuration
381    pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
382        self.runtime_agent.tool_search = Some(config);
383        self
384    }
385
386    /// Set prompt caching configuration
387    pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
388        self.runtime_agent.prompt_cache = Some(config);
389        self
390    }
391
392    /// Build the runtime agent.
393    ///
394    /// Validates that tool_search is only enabled for models that support it
395    /// (currently GPT-5.4 and newer). Clears tool_search for unsupported models to
396    /// prevent 400 errors from the OpenAI API.
397    ///
398    /// tool_search is capability-driven: it is only set when the
399    /// `openai_tool_search` capability is explicitly added to the agent
400    /// or harness. This method does NOT auto-enable it.
401    pub fn build(mut self) -> RuntimeAgent {
402        // Deduplicate tools by name (last wins). Tools are collected additively
403        // from harness, agent, MCP servers, session capabilities, and client-side
404        // tools — duplicates can occur when the same tool is registered by
405        // multiple sources.
406        {
407            let mut seen = std::collections::HashSet::new();
408            let mut deduped = Vec::with_capacity(self.runtime_agent.tools.len());
409            // Iterate in reverse so the last-added tool wins, then reverse back.
410            for tool in self.runtime_agent.tools.drain(..).rev() {
411                if seen.insert(tool.name().to_owned()) {
412                    deduped.push(tool);
413                }
414            }
415            deduped.reverse();
416            self.runtime_agent.tools = deduped;
417        }
418
419        // Resolve tool_search (deferred tool loading). The mechanism is already
420        // chosen at capability-collection time (see `Capability::resolve_for_model`
421        // and `auto_tool_search`): a hosted `ToolSearchConfig` means the hosted
422        // (native) mechanism; client-side deferral arrives as `DeferSchemaHook`
423        // plus a `tool_search` tool. This step only reconciles a hosted config
424        // with the model — collection may have set one (via a direct
425        // `openai_tool_search` capability) that the model can't honor.
426        let model_supports_native =
427            get_model_profile(&LlmProviderType::Openai, &self.runtime_agent.model)
428                .is_some_and(|p| p.tool_search);
429
430        // Hosted (native) deferral hides schemas server-side, so client-side
431        // opt-out hooks (DeferSchemaHook) must be skipped while a hosted config
432        // is present — even on an unsupported model, where the hosted config is
433        // disabled below (full schemas, no client-side fallback). This is what
434        // makes a hand-configured `openai_tool_search` win over a separately
435        // configured `tool_search`.
436        let native_tool_search = self.runtime_agent.tool_search.is_some();
437        for hook in &self.tool_definition_hooks {
438            if native_tool_search && !hook.applies_with_native_tool_search() {
439                continue;
440            }
441            self.runtime_agent.tools =
442                hook.transform(std::mem::take(&mut self.runtime_agent.tools));
443        }
444
445        // Clear a hosted config the model can't honor (a direct `openai_tool_search`
446        // on an unsupported model): it simply sends full schemas. `auto_tool_search`
447        // never reaches here on an unsupported model — it resolves to the generic
448        // client-side mechanism at collection time and sets no hosted config.
449        if self.runtime_agent.tool_search.is_some() && !model_supports_native {
450            tracing::debug!(
451                model = %self.runtime_agent.model,
452                "hosted tool_search not supported by model; disabling (full schemas)"
453            );
454            self.runtime_agent.tool_search = None;
455        }
456
457        self.runtime_agent
458    }
459}
460
461impl Default for RuntimeAgentBuilder {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::agent::AgentStatus;
471    use crate::capabilities::{AgentCapabilityConfig, SystemPromptContext};
472    use crate::typed_id::AgentId;
473
474    fn test_ctx() -> SystemPromptContext {
475        SystemPromptContext::without_file_store(crate::typed_id::SessionId::new())
476    }
477
478    #[test]
479    fn test_runtime_agent_new() {
480        let runtime_agent = RuntimeAgent::new("You are helpful.", "gpt-5.2");
481
482        assert_eq!(runtime_agent.system_prompt, "You are helpful.");
483        assert_eq!(runtime_agent.model, "gpt-5.2");
484        assert!(runtime_agent.tools.is_empty());
485        assert_eq!(runtime_agent.max_iterations, 500);
486        assert!(runtime_agent.temperature.is_none());
487        assert!(runtime_agent.max_tokens.is_none());
488    }
489
490    #[test]
491    fn test_runtime_agent_default() {
492        let runtime_agent = RuntimeAgent::default();
493
494        assert_eq!(runtime_agent.system_prompt, "You are a helpful assistant.");
495        assert_eq!(runtime_agent.model, "gpt-5.2");
496        assert!(runtime_agent.tools.is_empty());
497        assert_eq!(runtime_agent.max_iterations, 500);
498    }
499
500    #[test]
501    fn test_builder_basic() {
502        let runtime_agent = RuntimeAgentBuilder::new()
503            .system_prompt("Custom prompt")
504            .model("claude-3-opus")
505            .build();
506
507        assert_eq!(runtime_agent.system_prompt, "Custom prompt");
508        assert_eq!(runtime_agent.model, "claude-3-opus");
509    }
510
511    #[test]
512    fn test_builder_with_all_options() {
513        let runtime_agent = RuntimeAgentBuilder::new()
514            .system_prompt("You are a coder.")
515            .model("gpt-5.2")
516            .max_iterations(20)
517            .temperature(0.7)
518            .max_tokens(4096)
519            .build();
520
521        assert_eq!(runtime_agent.system_prompt, "You are a coder.");
522        assert_eq!(runtime_agent.model, "gpt-5.2");
523        assert_eq!(runtime_agent.max_iterations, 20);
524        assert_eq!(runtime_agent.temperature, Some(0.7));
525        assert_eq!(runtime_agent.max_tokens, Some(4096));
526    }
527
528    #[test]
529    fn test_builder_prepend_system_prompt() {
530        let runtime_agent = RuntimeAgentBuilder::new()
531            .system_prompt("Base prompt.")
532            .prepend_system_prompt("Prefix text.")
533            .build();
534
535        assert_eq!(runtime_agent.system_prompt, "Prefix text.\n\nBase prompt.");
536    }
537
538    #[test]
539    fn test_builder_prepend_empty_string_does_nothing() {
540        let runtime_agent = RuntimeAgentBuilder::new()
541            .system_prompt("Base prompt.")
542            .prepend_system_prompt("")
543            .build();
544
545        assert_eq!(runtime_agent.system_prompt, "Base prompt.");
546    }
547
548    #[test]
549    fn test_builder_with_locale_prepends_locale_instructions() {
550        let runtime_agent = RuntimeAgentBuilder::new()
551            .system_prompt("Base prompt.")
552            .with_locale(Some("uk-UA"))
553            .build();
554
555        assert!(runtime_agent.system_prompt.contains("<locale"));
556        assert!(runtime_agent.system_prompt.contains("uk-UA"));
557        assert!(runtime_agent.system_prompt.contains("Base prompt."));
558    }
559
560    #[tokio::test]
561    async fn test_builder_with_capabilities_empty() {
562        let registry = CapabilityRegistry::with_builtins();
563        let runtime_agent = RuntimeAgentBuilder::new()
564            .system_prompt("Base prompt.")
565            .with_capabilities(&[], &registry, &test_ctx())
566            .await
567            .build();
568
569        assert_eq!(runtime_agent.system_prompt, "Base prompt.");
570        assert!(runtime_agent.tools.is_empty());
571    }
572
573    #[tokio::test]
574    async fn test_builder_with_capabilities_adds_tools() {
575        use crate::tool_types::ToolDefinition;
576
577        let registry = CapabilityRegistry::with_builtins();
578        let runtime_agent = RuntimeAgentBuilder::new()
579            .system_prompt("Base prompt.")
580            .with_capabilities(&["current_time".to_string()], &registry, &test_ctx())
581            .await
582            .build();
583
584        assert_eq!(runtime_agent.tools.len(), 1);
585        match &runtime_agent.tools[0] {
586            ToolDefinition::Builtin(tool) => {
587                assert_eq!(tool.name, "get_current_time");
588            }
589            _ => panic!("expected Builtin variant"),
590        }
591    }
592
593    #[tokio::test]
594    async fn test_builder_with_capabilities_prepends_system_prompt() {
595        let registry = CapabilityRegistry::with_builtins();
596        let runtime_agent = RuntimeAgentBuilder::new()
597            .system_prompt("Base prompt.")
598            .with_capabilities(&["session_file_system".to_string()], &registry, &test_ctx())
599            .await
600            .build();
601
602        assert!(runtime_agent.system_prompt.contains("/workspace"));
603        // Base prompt wrapped in <system-prompt> tags
604        assert!(runtime_agent.system_prompt.contains("<system-prompt>"));
605        assert!(
606            runtime_agent
607                .system_prompt
608                .ends_with("<system-prompt>\nBase prompt.\n</system-prompt>")
609        );
610    }
611
612    #[tokio::test]
613    async fn test_builder_with_agent() {
614        use crate::tool_types::ToolDefinition;
615        use uuid::{NoContext, Timestamp, Uuid};
616
617        let registry = CapabilityRegistry::with_builtins();
618        let ts = Timestamp::now(NoContext);
619        let uuid = Uuid::new_v7(ts);
620        let agent = Agent {
621            public_id: AgentId::from_uuid(uuid),
622            internal_id: uuid,
623            name: "test-agent".to_string(),
624            display_name: Some("Test Agent".to_string()),
625            description: None,
626            system_prompt: "Agent prompt.".to_string(),
627            default_model_id: None,
628            default_version_id: None,
629            forked_from_agent_id: None,
630            forked_from_version_id: None,
631            root_agent_id: None,
632            capabilities: vec![AgentCapabilityConfig::new("current_time")],
633            initial_files: vec![],
634            network_access: None,
635            max_iterations: None,
636            tools: vec![],
637            mcp_servers: Default::default(),
638            status: AgentStatus::Active,
639            tags: vec![],
640            created_at: chrono::Utc::now(),
641            updated_at: chrono::Utc::now(),
642            archived_at: None,
643            deleted_at: None,
644            usage: None,
645        };
646
647        let runtime_agent = RuntimeAgentBuilder::new()
648            .with_agent(&agent, &registry, &test_ctx())
649            .await
650            .model("gpt-5.2")
651            .build();
652
653        assert!(runtime_agent.system_prompt.contains("Agent prompt."));
654        assert_eq!(runtime_agent.tools.len(), 1);
655        match &runtime_agent.tools[0] {
656            ToolDefinition::Builtin(tool) => {
657                assert_eq!(tool.name, "get_current_time");
658            }
659            _ => panic!("expected Builtin variant"),
660        }
661    }
662
663    #[test]
664    fn test_builder_default() {
665        let builder = RuntimeAgentBuilder::default();
666        let runtime_agent = builder.build();
667
668        assert_eq!(runtime_agent.system_prompt, "You are a helpful assistant.");
669        assert_eq!(runtime_agent.model, "gpt-5.2");
670    }
671
672    #[tokio::test]
673    async fn test_builder_with_capabilities_resolves_dependencies() {
674        // Sample Data depends on Session File System
675        // When we request only Sample Data, we should get system prompt from both
676        let registry = CapabilityRegistry::with_builtins();
677        let runtime_agent = RuntimeAgentBuilder::new()
678            .system_prompt("Base prompt.")
679            .with_capabilities(&["sample_data".to_string()], &registry, &test_ctx())
680            .await
681            .build();
682
683        // System prompt should include File System's contribution (the dependency) in XML tags
684        assert!(
685            runtime_agent
686                .system_prompt
687                .contains("<capability id=\"session_file_system\">"),
688            "Should include File System capability in XML tags"
689        );
690        assert!(
691            runtime_agent.system_prompt.contains("/workspace"),
692            "Should include File System system prompt (mentions workspace root)"
693        );
694        // Should also include Sample Data's contribution in XML tags
695        assert!(
696            runtime_agent
697                .system_prompt
698                .contains("<capability id=\"sample_data\">"),
699            "Should include Sample Data capability in XML tags"
700        );
701        assert!(
702            runtime_agent.system_prompt.contains("/samples"),
703            "Should include Sample Data system prompt (mentions /samples path)"
704        );
705        // Base prompt should still be there, wrapped
706        assert!(
707            runtime_agent.system_prompt.contains("Base prompt."),
708            "Should preserve base prompt"
709        );
710        assert!(
711            runtime_agent.system_prompt.contains("<system-prompt>"),
712            "Base prompt should be wrapped in system-prompt tags"
713        );
714    }
715
716    #[tokio::test]
717    async fn test_builder_additive_capabilities() {
718        use crate::tool_types::ToolDefinition;
719
720        let registry = CapabilityRegistry::with_builtins();
721
722        // Apply capabilities additively (simulating session-level capabilities)
723        let runtime_agent = RuntimeAgentBuilder::new()
724            .system_prompt("Agent prompt.")
725            .with_capabilities(&["current_time".to_string()], &registry, &test_ctx())
726            .await
727            .build();
728
729        // Should have the tool from capability
730        assert_eq!(runtime_agent.tools.len(), 1);
731        match &runtime_agent.tools[0] {
732            ToolDefinition::Builtin(tool) => {
733                assert_eq!(tool.name, "get_current_time");
734            }
735            _ => panic!("expected Builtin variant"),
736        }
737    }
738
739    #[tokio::test]
740    async fn test_builder_with_agent_client_side_tools() {
741        use crate::tool_types::{ClientSideTool, DeferrablePolicy, ToolDefinition};
742        use uuid::{NoContext, Timestamp, Uuid};
743
744        let registry = CapabilityRegistry::with_builtins();
745        let ts = Timestamp::now(NoContext);
746        let uuid = Uuid::new_v7(ts);
747
748        let client_tool = ToolDefinition::ClientSide(ClientSideTool {
749            name: "browser_click".to_string(),
750            display_name: None,
751            description: "Click an element in the browser".to_string(),
752            parameters: serde_json::json!({
753                "type": "object",
754                "properties": {
755                    "selector": {"type": "string"}
756                }
757            }),
758            category: None,
759            deferrable: DeferrablePolicy::default(),
760            hints: crate::tool_types::ToolHints::default(),
761            full_parameters: None,
762        });
763
764        let agent = Agent {
765            public_id: AgentId::from_uuid(uuid),
766            internal_id: uuid,
767            name: "client-tool-agent".to_string(),
768            display_name: Some("Client Tool Agent".to_string()),
769            description: None,
770            system_prompt: "Agent with client tools.".to_string(),
771            default_model_id: None,
772            default_version_id: None,
773            forked_from_agent_id: None,
774            forked_from_version_id: None,
775            root_agent_id: None,
776            capabilities: vec![],
777            initial_files: vec![],
778            network_access: None,
779            max_iterations: None,
780            tools: vec![client_tool],
781            mcp_servers: Default::default(),
782            status: AgentStatus::Active,
783            tags: vec![],
784            created_at: chrono::Utc::now(),
785            updated_at: chrono::Utc::now(),
786            archived_at: None,
787            deleted_at: None,
788            usage: None,
789        };
790
791        let runtime_agent = RuntimeAgentBuilder::new()
792            .with_agent(&agent, &registry, &test_ctx())
793            .await
794            .model("gpt-5.2")
795            .build();
796
797        assert_eq!(runtime_agent.tools.len(), 1);
798        assert_eq!(runtime_agent.tools[0].name(), "browser_click");
799        assert_eq!(
800            runtime_agent.tools[0].policy(),
801            &crate::tool_types::ToolPolicy::ClientSide
802        );
803    }
804
805    #[tokio::test]
806    async fn test_builder_with_agent_client_side_and_capabilities() {
807        use crate::tool_types::{ClientSideTool, DeferrablePolicy, ToolDefinition};
808        use uuid::{NoContext, Timestamp, Uuid};
809
810        let registry = CapabilityRegistry::with_builtins();
811        let ts = Timestamp::now(NoContext);
812        let uuid = Uuid::new_v7(ts);
813
814        let client_tool = ToolDefinition::ClientSide(ClientSideTool {
815            name: "deploy_staging".to_string(),
816            display_name: None,
817            description: "Deploy to staging".to_string(),
818            parameters: serde_json::json!({"type": "object"}),
819            category: None,
820            deferrable: DeferrablePolicy::default(),
821            hints: crate::tool_types::ToolHints::default(),
822            full_parameters: None,
823        });
824
825        let agent = Agent {
826            public_id: AgentId::from_uuid(uuid),
827            internal_id: uuid,
828            name: "mixed-tool-agent".to_string(),
829            display_name: Some("Mixed Tool Agent".to_string()),
830            description: None,
831            system_prompt: "Agent with mixed tools.".to_string(),
832            default_model_id: None,
833            default_version_id: None,
834            forked_from_agent_id: None,
835            forked_from_version_id: None,
836            root_agent_id: None,
837            capabilities: vec![AgentCapabilityConfig::new("current_time")],
838            initial_files: vec![],
839            network_access: None,
840            max_iterations: None,
841            tools: vec![client_tool],
842            mcp_servers: Default::default(),
843            status: AgentStatus::Active,
844            tags: vec![],
845            created_at: chrono::Utc::now(),
846            updated_at: chrono::Utc::now(),
847            archived_at: None,
848            deleted_at: None,
849            usage: None,
850        };
851
852        let runtime_agent = RuntimeAgentBuilder::new()
853            .with_agent(&agent, &registry, &test_ctx())
854            .await
855            .model("gpt-5.2")
856            .build();
857
858        // Should have capability tool + client-side tool
859        assert_eq!(runtime_agent.tools.len(), 2);
860        let tool_names: Vec<&str> = runtime_agent.tools.iter().map(|t| t.name()).collect();
861        assert!(tool_names.contains(&"get_current_time"));
862        assert!(tool_names.contains(&"deploy_staging"));
863
864        // Verify the client tool is ClientSide variant
865        let deploy_tool = runtime_agent
866            .tools
867            .iter()
868            .find(|t| t.name() == "deploy_staging")
869            .unwrap();
870        assert!(matches!(deploy_tool, ToolDefinition::ClientSide(_)));
871    }
872
873    #[tokio::test]
874    async fn test_builder_with_agent_and_additive_capabilities() {
875        use uuid::{NoContext, Timestamp, Uuid};
876
877        let registry = CapabilityRegistry::with_builtins();
878        let ts = Timestamp::now(NoContext);
879
880        // Agent has current_time capability (no system prompt addition)
881        let uuid = Uuid::new_v7(ts);
882        let agent = Agent {
883            public_id: AgentId::from_uuid(uuid),
884            internal_id: uuid,
885            name: "test-agent".to_string(),
886            display_name: Some("Test Agent".to_string()),
887            description: None,
888            system_prompt: "Agent prompt.".to_string(),
889            default_model_id: None,
890            default_version_id: None,
891            forked_from_agent_id: None,
892            forked_from_version_id: None,
893            root_agent_id: None,
894            capabilities: vec![AgentCapabilityConfig::new("current_time")],
895            initial_files: vec![],
896            network_access: None,
897            max_iterations: None,
898            tools: vec![],
899            mcp_servers: Default::default(),
900            status: AgentStatus::Active,
901            tags: vec![],
902            created_at: chrono::Utc::now(),
903            updated_at: chrono::Utc::now(),
904            archived_at: None,
905            deleted_at: None,
906            usage: None,
907        };
908
909        // Session adds stateless_todo_list capability (additive — has system prompt addition)
910        let session_capability_ids = vec!["stateless_todo_list".to_string()];
911
912        let runtime_agent = RuntimeAgentBuilder::new()
913            .with_agent(&agent, &registry, &test_ctx())
914            .await
915            .with_capabilities(&session_capability_ids, &registry, &test_ctx())
916            .await
917            .model("gpt-5.2")
918            .build();
919
920        // Should have tools from both agent and session capabilities
921        assert!(runtime_agent.tools.len() >= 2);
922        let tool_names: Vec<&str> = runtime_agent.tools.iter().map(|t| t.name()).collect();
923        assert!(tool_names.contains(&"get_current_time"));
924        assert!(tool_names.contains(&"write_todos"));
925
926        // System prompt should contain both capability additions and agent prompt
927        assert!(runtime_agent.system_prompt.contains("Agent prompt."));
928        assert!(runtime_agent.system_prompt.contains("Task Management"));
929        assert!(
930            runtime_agent
931                .system_prompt
932                .contains("<capability id=\"stateless_todo_list\">")
933        );
934        // Base prompt should be wrapped in <system-prompt> tags (no double wrapping)
935        let system_prompt_count = runtime_agent
936            .system_prompt
937            .matches("<system-prompt>")
938            .count();
939        assert_eq!(
940            system_prompt_count, 1,
941            "Should have exactly one <system-prompt> tag, not double-wrapped"
942        );
943    }
944
945    #[test]
946    fn test_build_clears_tool_search_for_unsupported_model() {
947        let agent = RuntimeAgentBuilder::new()
948            .model("gpt-5.2")
949            .tool_search(ToolSearchConfig {
950                enabled: true,
951                threshold: 15,
952            })
953            .build();
954
955        assert!(
956            agent.tool_search.is_none(),
957            "tool_search should be cleared for gpt-5.2 (unsupported)"
958        );
959    }
960
961    #[test]
962    fn test_build_keeps_tool_search_for_supported_model() {
963        let agent = RuntimeAgentBuilder::new()
964            .model("gpt-5.4")
965            .tool_search(ToolSearchConfig {
966                enabled: true,
967                threshold: 15,
968            })
969            .build();
970
971        assert!(
972            agent.tool_search.is_some(),
973            "tool_search should be kept for gpt-5.4 (supported)"
974        );
975    }
976
977    #[test]
978    fn test_build_skips_client_side_hook_when_native_tool_search_configured() {
979        use crate::tool_types::{BuiltinTool, ToolPolicy};
980        use std::sync::Arc;
981
982        // A hook that would clear all tools if it ran, but opts out of coexisting
983        // with native tool_search (like the generic tool_search DeferSchemaHook).
984        struct ClearAllHook;
985        impl ToolDefinitionHook for ClearAllHook {
986            fn transform(&self, _tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
987                vec![]
988            }
989            fn applies_with_native_tool_search(&self) -> bool {
990                false
991            }
992        }
993
994        let tool = ToolDefinition::Builtin(BuiltinTool {
995            name: "read_file".to_string(),
996            display_name: None,
997            description: "read".to_string(),
998            parameters: serde_json::json!({}),
999            policy: ToolPolicy::Auto,
1000            category: None,
1001            deferrable: Default::default(),
1002            hints: Default::default(),
1003            full_parameters: None,
1004        });
1005
1006        // Native tool_search configured → opt-out hook is skipped (tools kept).
1007        let mut builder = RuntimeAgentBuilder::new()
1008            .model("gpt-5.4")
1009            .tools(vec![tool.clone()])
1010            .tool_search(ToolSearchConfig {
1011                enabled: true,
1012                threshold: 15,
1013            });
1014        builder.tool_definition_hooks.push(Arc::new(ClearAllHook));
1015        assert_eq!(
1016            builder.build().tools.len(),
1017            1,
1018            "opt-out hook must be skipped when native tool_search is configured"
1019        );
1020
1021        // No native tool_search → the same hook runs and clears the tools.
1022        let mut builder = RuntimeAgentBuilder::new()
1023            .model("claude-sonnet-4-5-20250514")
1024            .tools(vec![tool]);
1025        builder.tool_definition_hooks.push(Arc::new(ClearAllHook));
1026        assert!(
1027            builder.build().tools.is_empty(),
1028            "opt-out hook runs when native tool_search is not configured"
1029        );
1030    }
1031
1032    #[test]
1033    fn test_build_clears_tool_search_for_non_openai_model() {
1034        let agent = RuntimeAgentBuilder::new()
1035            .model("claude-sonnet-4-5-20250514")
1036            .tool_search(ToolSearchConfig {
1037                enabled: true,
1038                threshold: 15,
1039            })
1040            .build();
1041
1042        assert!(
1043            agent.tool_search.is_none(),
1044            "tool_search should be cleared for non-OpenAI models"
1045        );
1046    }
1047
1048    #[test]
1049    fn test_build_no_auto_enable_tool_search_without_capability() {
1050        // tool_search requires explicit openai_tool_search capability.
1051        // Even GPT-5.4 (which supports it) should not get it automatically.
1052        let agent = RuntimeAgentBuilder::new().model("gpt-5.4").build();
1053
1054        assert!(
1055            agent.tool_search.is_none(),
1056            "tool_search must not be auto-enabled; it is capability-driven"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_build_preserves_explicit_tool_search_config_for_supported_model() {
1062        // Simulates Generic harness setting openai_tool_search capability
1063        // with custom threshold — build() must preserve it.
1064        let agent = RuntimeAgentBuilder::new()
1065            .model("gpt-5.4")
1066            .tool_search(ToolSearchConfig {
1067                enabled: true,
1068                threshold: 5,
1069            })
1070            .build();
1071
1072        let ts = agent
1073            .tool_search
1074            .expect("explicit tool_search should be preserved");
1075        assert!(ts.enabled);
1076        assert_eq!(
1077            ts.threshold, 5,
1078            "custom threshold from capability must be preserved"
1079        );
1080    }
1081
1082    // Note: `auto_tool_search`'s hosted-vs-client-side selection now happens at
1083    // capability-collection time (see `Capability::resolve_for_model` and the
1084    // collection tests in `capabilities::mod`), not in `build()`. `build()` only
1085    // reconciles a hosted config with the model, covered by the tests above.
1086
1087    #[test]
1088    fn test_build_preserves_prompt_cache_for_supported_provider() {
1089        let agent = RuntimeAgentBuilder::new()
1090            .model("gpt-5.4")
1091            .prompt_cache(PromptCacheConfig {
1092                enabled: true,
1093                strategy: crate::llm_driver_registry::PromptCacheStrategy::Auto,
1094                gemini_cached_content: None,
1095            })
1096            .build();
1097
1098        let prompt_cache = agent
1099            .prompt_cache
1100            .expect("explicit prompt_cache should be preserved");
1101        assert!(prompt_cache.enabled);
1102        assert_eq!(
1103            prompt_cache.strategy,
1104            crate::llm_driver_registry::PromptCacheStrategy::Auto
1105        );
1106    }
1107
1108    #[test]
1109    fn test_build_deduplicates_tools_by_name() {
1110        use crate::tool_types::{BuiltinTool, ToolDefinition, ToolPolicy};
1111
1112        let make_tool = |name: &str, desc: &str| {
1113            ToolDefinition::Builtin(BuiltinTool {
1114                name: name.to_string(),
1115                display_name: None,
1116                description: desc.to_string(),
1117                parameters: serde_json::json!({}),
1118                policy: ToolPolicy::Auto,
1119                category: None,
1120                deferrable: Default::default(),
1121                hints: crate::tool_types::ToolHints::default(),
1122                full_parameters: None,
1123            })
1124        };
1125
1126        let agent = RuntimeAgentBuilder::new()
1127            .tool(make_tool("kv_store", "first"))
1128            .tool(make_tool("browser", "only one"))
1129            .tool(make_tool("kv_store", "second (should win)"))
1130            .build();
1131
1132        assert_eq!(agent.tools.len(), 2);
1133        // Last-added kv_store wins
1134        assert_eq!(agent.tools[0].name(), "browser");
1135        assert_eq!(agent.tools[1].name(), "kv_store");
1136        assert_eq!(agent.tools[1].description(), "second (should win)");
1137    }
1138}