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
691            harness_id: crate::typed_id::HarnessId::from_uuid(uuid::Uuid::nil()),
692            default_version_id: None,
693            forked_from_agent_id: None,
694            forked_from_version_id: None,
695            root_agent_id: None,
696            capabilities: vec![AgentCapabilityConfig::new("current_time")],
697            initial_files: vec![],
698            network_access: None,
699            max_iterations: None,
700            parallel_tool_calls: None,
701            tools: vec![],
702            mcp_servers: Default::default(),
703            status: AgentStatus::Active,
704            tags: vec![],
705            created_at: chrono::Utc::now(),
706            updated_at: chrono::Utc::now(),
707            archived_at: None,
708            deleted_at: None,
709            usage: None,
710        };
711
712        let runtime_agent = RuntimeAgentBuilder::new()
713            .with_agent(&agent, &registry, &test_ctx())
714            .await
715            .model("gpt-5.2")
716            .build();
717
718        assert!(runtime_agent.system_prompt.contains("Agent prompt."));
719        assert_eq!(runtime_agent.tools.len(), 1);
720        match &runtime_agent.tools[0] {
721            ToolDefinition::Builtin(tool) => {
722                assert_eq!(tool.name, "get_current_time");
723            }
724            _ => panic!("expected Builtin variant"),
725        }
726    }
727
728    #[test]
729    fn test_builder_default() {
730        let builder = RuntimeAgentBuilder::default();
731        let runtime_agent = builder.build();
732
733        assert_eq!(runtime_agent.system_prompt, "You are a helpful assistant.");
734        assert_eq!(runtime_agent.model, "gpt-5.2");
735    }
736
737    #[tokio::test]
738    async fn test_builder_with_capabilities_resolves_dependencies() {
739        // Sample Data depends on Session File System
740        // When we request only Sample Data, we should get system prompt from both
741        let registry = CapabilityRegistry::with_builtins();
742        let runtime_agent = RuntimeAgentBuilder::new()
743            .system_prompt("Base prompt.")
744            .with_capabilities(&["sample_data".to_string()], &registry, &test_ctx())
745            .await
746            .build();
747
748        // System prompt should include File System's contribution (the dependency) in XML tags
749        assert!(
750            runtime_agent
751                .system_prompt
752                .contains("<capability id=\"session_file_system\">"),
753            "Should include File System capability in XML tags"
754        );
755        assert!(
756            runtime_agent.system_prompt.contains("/workspace"),
757            "Should include File System system prompt (mentions workspace root)"
758        );
759        // Should also include Sample Data's contribution in XML tags
760        assert!(
761            runtime_agent
762                .system_prompt
763                .contains("<capability id=\"sample_data\">"),
764            "Should include Sample Data capability in XML tags"
765        );
766        assert!(
767            runtime_agent.system_prompt.contains("/samples"),
768            "Should include Sample Data system prompt (mentions /samples path)"
769        );
770        // Base prompt should still be there, wrapped
771        assert!(
772            runtime_agent.system_prompt.contains("Base prompt."),
773            "Should preserve base prompt"
774        );
775        assert!(
776            runtime_agent.system_prompt.contains("<system-prompt>"),
777            "Base prompt should be wrapped in system-prompt tags"
778        );
779    }
780
781    #[tokio::test]
782    async fn test_builder_additive_capabilities() {
783        use crate::tool_types::ToolDefinition;
784
785        let registry = CapabilityRegistry::with_builtins();
786
787        // Apply capabilities additively (simulating session-level capabilities)
788        let runtime_agent = RuntimeAgentBuilder::new()
789            .system_prompt("Agent prompt.")
790            .with_capabilities(&["current_time".to_string()], &registry, &test_ctx())
791            .await
792            .build();
793
794        // Should have the tool from capability
795        assert_eq!(runtime_agent.tools.len(), 1);
796        match &runtime_agent.tools[0] {
797            ToolDefinition::Builtin(tool) => {
798                assert_eq!(tool.name, "get_current_time");
799            }
800            _ => panic!("expected Builtin variant"),
801        }
802    }
803
804    #[tokio::test]
805    async fn test_builder_with_agent_client_side_tools() {
806        use crate::tool_types::{ClientSideTool, DeferrablePolicy, ToolDefinition};
807        use uuid::{NoContext, Timestamp, Uuid};
808
809        let registry = CapabilityRegistry::with_builtins();
810        let ts = Timestamp::now(NoContext);
811        let uuid = Uuid::new_v7(ts);
812
813        let client_tool = ToolDefinition::ClientSide(ClientSideTool {
814            name: "browser_click".to_string(),
815            display_name: None,
816            description: "Click an element in the browser".to_string(),
817            parameters: serde_json::json!({
818                "type": "object",
819                "properties": {
820                    "selector": {"type": "string"}
821                }
822            }),
823            category: None,
824            deferrable: DeferrablePolicy::default(),
825            hints: crate::tool_types::ToolHints::default(),
826            full_parameters: None,
827        });
828
829        let agent = Agent {
830            public_id: AgentId::from_uuid(uuid),
831            internal_id: uuid,
832            name: "client-tool-agent".to_string(),
833            display_name: Some("Client Tool Agent".to_string()),
834            description: None,
835            system_prompt: "Agent with client tools.".to_string(),
836            default_model_id: None,
837
838            harness_id: crate::typed_id::HarnessId::from_uuid(uuid::Uuid::nil()),
839            default_version_id: None,
840            forked_from_agent_id: None,
841            forked_from_version_id: None,
842            root_agent_id: None,
843            capabilities: vec![],
844            initial_files: vec![],
845            network_access: None,
846            max_iterations: None,
847            parallel_tool_calls: None,
848            tools: vec![client_tool],
849            mcp_servers: Default::default(),
850            status: AgentStatus::Active,
851            tags: vec![],
852            created_at: chrono::Utc::now(),
853            updated_at: chrono::Utc::now(),
854            archived_at: None,
855            deleted_at: None,
856            usage: None,
857        };
858
859        let runtime_agent = RuntimeAgentBuilder::new()
860            .with_agent(&agent, &registry, &test_ctx())
861            .await
862            .model("gpt-5.2")
863            .build();
864
865        assert_eq!(runtime_agent.tools.len(), 1);
866        assert_eq!(runtime_agent.tools[0].name(), "browser_click");
867        assert_eq!(
868            runtime_agent.tools[0].policy(),
869            &crate::tool_types::ToolPolicy::ClientSide
870        );
871    }
872
873    #[tokio::test]
874    async fn test_builder_with_agent_client_side_and_capabilities() {
875        use crate::tool_types::{ClientSideTool, DeferrablePolicy, ToolDefinition};
876        use uuid::{NoContext, Timestamp, Uuid};
877
878        let registry = CapabilityRegistry::with_builtins();
879        let ts = Timestamp::now(NoContext);
880        let uuid = Uuid::new_v7(ts);
881
882        let client_tool = ToolDefinition::ClientSide(ClientSideTool {
883            name: "deploy_staging".to_string(),
884            display_name: None,
885            description: "Deploy to staging".to_string(),
886            parameters: serde_json::json!({"type": "object"}),
887            category: None,
888            deferrable: DeferrablePolicy::default(),
889            hints: crate::tool_types::ToolHints::default(),
890            full_parameters: None,
891        });
892
893        let agent = Agent {
894            public_id: AgentId::from_uuid(uuid),
895            internal_id: uuid,
896            name: "mixed-tool-agent".to_string(),
897            display_name: Some("Mixed Tool Agent".to_string()),
898            description: None,
899            system_prompt: "Agent with mixed tools.".to_string(),
900            default_model_id: None,
901
902            harness_id: crate::typed_id::HarnessId::from_uuid(uuid::Uuid::nil()),
903            default_version_id: None,
904            forked_from_agent_id: None,
905            forked_from_version_id: None,
906            root_agent_id: None,
907            capabilities: vec![AgentCapabilityConfig::new("current_time")],
908            initial_files: vec![],
909            network_access: None,
910            max_iterations: None,
911            parallel_tool_calls: None,
912            tools: vec![client_tool],
913            mcp_servers: Default::default(),
914            status: AgentStatus::Active,
915            tags: vec![],
916            created_at: chrono::Utc::now(),
917            updated_at: chrono::Utc::now(),
918            archived_at: None,
919            deleted_at: None,
920            usage: None,
921        };
922
923        let runtime_agent = RuntimeAgentBuilder::new()
924            .with_agent(&agent, &registry, &test_ctx())
925            .await
926            .model("gpt-5.2")
927            .build();
928
929        // Should have capability tool + client-side tool
930        assert_eq!(runtime_agent.tools.len(), 2);
931        let tool_names: Vec<&str> = runtime_agent.tools.iter().map(|t| t.name()).collect();
932        assert!(tool_names.contains(&"get_current_time"));
933        assert!(tool_names.contains(&"deploy_staging"));
934
935        // Verify the client tool is ClientSide variant
936        let deploy_tool = runtime_agent
937            .tools
938            .iter()
939            .find(|t| t.name() == "deploy_staging")
940            .unwrap();
941        assert!(matches!(deploy_tool, ToolDefinition::ClientSide(_)));
942    }
943
944    #[tokio::test]
945    async fn test_builder_with_agent_and_additive_capabilities() {
946        use uuid::{NoContext, Timestamp, Uuid};
947
948        let registry = CapabilityRegistry::with_builtins();
949        let ts = Timestamp::now(NoContext);
950
951        // Agent has current_time capability (no system prompt addition)
952        let uuid = Uuid::new_v7(ts);
953        let agent = Agent {
954            public_id: AgentId::from_uuid(uuid),
955            internal_id: uuid,
956            name: "test-agent".to_string(),
957            display_name: Some("Test Agent".to_string()),
958            description: None,
959            system_prompt: "Agent prompt.".to_string(),
960            default_model_id: None,
961
962            harness_id: crate::typed_id::HarnessId::from_uuid(uuid::Uuid::nil()),
963            default_version_id: None,
964            forked_from_agent_id: None,
965            forked_from_version_id: None,
966            root_agent_id: None,
967            capabilities: vec![AgentCapabilityConfig::new("current_time")],
968            initial_files: vec![],
969            network_access: None,
970            max_iterations: None,
971            parallel_tool_calls: None,
972            tools: vec![],
973            mcp_servers: Default::default(),
974            status: AgentStatus::Active,
975            tags: vec![],
976            created_at: chrono::Utc::now(),
977            updated_at: chrono::Utc::now(),
978            archived_at: None,
979            deleted_at: None,
980            usage: None,
981        };
982
983        // Session adds stateless_todo_list capability (additive — has system prompt addition)
984        let session_capability_ids = vec!["stateless_todo_list".to_string()];
985
986        let runtime_agent = RuntimeAgentBuilder::new()
987            .with_agent(&agent, &registry, &test_ctx())
988            .await
989            .with_capabilities(&session_capability_ids, &registry, &test_ctx())
990            .await
991            .model("gpt-5.2")
992            .build();
993
994        // Should have tools from both agent and session capabilities
995        assert!(runtime_agent.tools.len() >= 2);
996        let tool_names: Vec<&str> = runtime_agent.tools.iter().map(|t| t.name()).collect();
997        assert!(tool_names.contains(&"get_current_time"));
998        assert!(tool_names.contains(&"write_todos"));
999
1000        // System prompt should contain both capability additions and agent prompt
1001        assert!(runtime_agent.system_prompt.contains("Agent prompt."));
1002        assert!(runtime_agent.system_prompt.contains("Task Management"));
1003        assert!(
1004            runtime_agent
1005                .system_prompt
1006                .contains("<capability id=\"stateless_todo_list\">")
1007        );
1008        // Base prompt should be wrapped in <system-prompt> tags (no double wrapping)
1009        let system_prompt_count = runtime_agent
1010            .system_prompt
1011            .matches("<system-prompt>")
1012            .count();
1013        assert_eq!(
1014            system_prompt_count, 1,
1015            "Should have exactly one <system-prompt> tag, not double-wrapped"
1016        );
1017    }
1018
1019    #[test]
1020    fn test_build_clears_tool_search_for_unsupported_model() {
1021        let agent = RuntimeAgentBuilder::new()
1022            .model("gpt-5.2")
1023            .tool_search(ToolSearchConfig {
1024                enabled: true,
1025                threshold: 15,
1026            })
1027            .build();
1028
1029        assert!(
1030            agent.tool_search.is_none(),
1031            "tool_search should be cleared for gpt-5.2 (unsupported)"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_build_keeps_tool_search_for_supported_model() {
1037        let agent = RuntimeAgentBuilder::new()
1038            .model("gpt-5.4")
1039            .tool_search(ToolSearchConfig {
1040                enabled: true,
1041                threshold: 15,
1042            })
1043            .build();
1044
1045        assert!(
1046            agent.tool_search.is_some(),
1047            "tool_search should be kept for gpt-5.4 (supported)"
1048        );
1049    }
1050
1051    #[test]
1052    fn test_build_skips_client_side_hook_when_native_tool_search_configured() {
1053        use crate::tool_types::{BuiltinTool, ToolPolicy};
1054        use std::sync::Arc;
1055
1056        // A hook that would clear all tools if it ran, but opts out of coexisting
1057        // with native tool_search (like the generic tool_search DeferSchemaHook).
1058        struct ClearAllHook;
1059        impl ToolDefinitionHook for ClearAllHook {
1060            fn transform(&self, _tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
1061                vec![]
1062            }
1063            fn applies_with_native_tool_search(&self) -> bool {
1064                false
1065            }
1066        }
1067
1068        let tool = ToolDefinition::Builtin(BuiltinTool {
1069            name: "read_file".to_string(),
1070            display_name: None,
1071            description: "read".to_string(),
1072            parameters: serde_json::json!({}),
1073            policy: ToolPolicy::Auto,
1074            category: None,
1075            deferrable: Default::default(),
1076            hints: Default::default(),
1077            full_parameters: None,
1078        });
1079
1080        // Native tool_search configured → opt-out hook is skipped (tools kept).
1081        let mut builder = RuntimeAgentBuilder::new()
1082            .model("gpt-5.4")
1083            .tools(vec![tool.clone()])
1084            .tool_search(ToolSearchConfig {
1085                enabled: true,
1086                threshold: 15,
1087            });
1088        builder.tool_definition_hooks.push(Arc::new(ClearAllHook));
1089        assert_eq!(
1090            builder.build().tools.len(),
1091            1,
1092            "opt-out hook must be skipped when native tool_search is configured"
1093        );
1094
1095        // No native tool_search → the same hook runs and clears the tools.
1096        let mut builder = RuntimeAgentBuilder::new()
1097            .model("claude-sonnet-4-5-20250514")
1098            .tools(vec![tool]);
1099        builder.tool_definition_hooks.push(Arc::new(ClearAllHook));
1100        assert!(
1101            builder.build().tools.is_empty(),
1102            "opt-out hook runs when native tool_search is not configured"
1103        );
1104    }
1105
1106    #[test]
1107    fn test_build_clears_tool_search_for_non_native_model() {
1108        // A pre-4 Claude model has no hosted tool_search support on either
1109        // provider, so a hosted config is cleared (full schemas sent).
1110        let agent = RuntimeAgentBuilder::new()
1111            .model("claude-3-5-haiku")
1112            .tool_search(ToolSearchConfig {
1113                enabled: true,
1114                threshold: 15,
1115            })
1116            .build();
1117
1118        assert!(
1119            agent.tool_search.is_none(),
1120            "tool_search should be cleared for models with no hosted support"
1121        );
1122    }
1123
1124    #[test]
1125    fn test_build_keeps_tool_search_for_native_anthropic_model() {
1126        // Claude 4-family models support Anthropic's hosted tool_search, so the
1127        // hosted config survives build() (the Anthropic driver renders it).
1128        let agent = RuntimeAgentBuilder::new()
1129            .model("claude-opus-4-8")
1130            .tool_search(ToolSearchConfig {
1131                enabled: true,
1132                threshold: 15,
1133            })
1134            .build();
1135
1136        assert!(
1137            agent.tool_search.is_some(),
1138            "tool_search should be kept for native Anthropic models"
1139        );
1140    }
1141
1142    #[test]
1143    fn test_build_no_auto_enable_tool_search_without_capability() {
1144        // tool_search requires explicit openai_tool_search capability.
1145        // Even GPT-5.4 (which supports it) should not get it automatically.
1146        let agent = RuntimeAgentBuilder::new().model("gpt-5.4").build();
1147
1148        assert!(
1149            agent.tool_search.is_none(),
1150            "tool_search must not be auto-enabled; it is capability-driven"
1151        );
1152    }
1153
1154    #[test]
1155    fn test_build_preserves_explicit_tool_search_config_for_supported_model() {
1156        // Simulates Generic harness setting openai_tool_search capability
1157        // with custom threshold — build() must preserve it.
1158        let agent = RuntimeAgentBuilder::new()
1159            .model("gpt-5.4")
1160            .tool_search(ToolSearchConfig {
1161                enabled: true,
1162                threshold: 5,
1163            })
1164            .build();
1165
1166        let ts = agent
1167            .tool_search
1168            .expect("explicit tool_search should be preserved");
1169        assert!(ts.enabled);
1170        assert_eq!(
1171            ts.threshold, 5,
1172            "custom threshold from capability must be preserved"
1173        );
1174    }
1175
1176    // Note: `auto_tool_search`'s hosted-vs-client-side selection now happens at
1177    // capability-collection time (see `Capability::resolve_for_model` and the
1178    // collection tests in `capabilities::mod`), not in `build()`. `build()` only
1179    // reconciles a hosted config with the model, covered by the tests above.
1180
1181    #[test]
1182    fn test_build_preserves_prompt_cache_for_supported_provider() {
1183        let agent = RuntimeAgentBuilder::new()
1184            .model("gpt-5.4")
1185            .prompt_cache(PromptCacheConfig {
1186                enabled: true,
1187                strategy: crate::driver_registry::PromptCacheStrategy::Auto,
1188                gemini_cached_content: None,
1189            })
1190            .build();
1191
1192        let prompt_cache = agent
1193            .prompt_cache
1194            .expect("explicit prompt_cache should be preserved");
1195        assert!(prompt_cache.enabled);
1196        assert_eq!(
1197            prompt_cache.strategy,
1198            crate::driver_registry::PromptCacheStrategy::Auto
1199        );
1200    }
1201
1202    #[test]
1203    fn test_build_deduplicates_tools_by_name() {
1204        use crate::tool_types::{BuiltinTool, ToolDefinition, ToolPolicy};
1205
1206        let make_tool = |name: &str, desc: &str| {
1207            ToolDefinition::Builtin(BuiltinTool {
1208                name: name.to_string(),
1209                display_name: None,
1210                description: desc.to_string(),
1211                parameters: serde_json::json!({}),
1212                policy: ToolPolicy::Auto,
1213                category: None,
1214                deferrable: Default::default(),
1215                hints: crate::tool_types::ToolHints::default(),
1216                full_parameters: None,
1217            })
1218        };
1219
1220        let agent = RuntimeAgentBuilder::new()
1221            .tool(make_tool("kv_store", "first"))
1222            .tool(make_tool("browser", "only one"))
1223            .tool(make_tool("kv_store", "second (should win)"))
1224            .build();
1225
1226        assert_eq!(agent.tools.len(), 2);
1227        // Last-added kv_store wins
1228        assert_eq!(agent.tools[0].name(), "browser");
1229        assert_eq!(agent.tools[1].name(), "kv_store");
1230        assert_eq!(agent.tools[1].description(), "second (should win)");
1231    }
1232}