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