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_definition::AgentDefinition;
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 std::collections::HashMap;
23
24use crate::driver_registry::{PromptCacheConfig, ToolSearchConfig};
25use crate::harness_definition::HarnessDefinition;
26use crate::model_profiles::get_model_profile;
27use crate::provider::DriverId;
28use crate::tool_types::ToolDefinition;
29use serde::{Deserialize, Serialize};
30
31/// Runtime configuration for the agent loop
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct RuntimeAgent {
34    /// System prompt that defines the agent's behavior
35    pub system_prompt: String,
36
37    /// Model identifier (e.g., "gpt-5.2", "claude-opus-5")
38    pub model: String,
39
40    /// Available tools for the agent
41    #[serde(default)]
42    pub tools: Vec<ToolDefinition>,
43
44    /// Maximum number of tool-calling iterations (prevents infinite loops)
45    #[serde(default = "default_max_iterations")]
46    pub max_iterations: usize,
47
48    /// Temperature for LLM sampling (0.0 - 2.0)
49    #[serde(default)]
50    pub temperature: Option<f32>,
51
52    /// Maximum tokens to generate per response
53    #[serde(default)]
54    pub max_tokens: Option<u32>,
55
56    /// Tool search config (set by openai_tool_search capability)
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub tool_search: Option<ToolSearchConfig>,
59
60    /// Prompt caching config (set by prompt_caching capability)
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub prompt_cache: Option<PromptCacheConfig>,
63
64    /// Driver-namespaced opaque per-call options (`"<driver-id>/<option>"`),
65    /// e.g. provider-executed server tools contributed by the
66    /// `openrouter_server_tools` capability. Shapes are owned by the respective
67    /// driver crates.
68    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69    pub driver_options: HashMap<String, serde_json::Value>,
70
71    /// Merged network access list (harness ∩ agent ∩ session).
72    /// Used by tools (web_fetch) to enforce URL access policy.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub network_access: Option<crate::network_access::NetworkAccessList>,
75
76    /// Request-level parallel tool calling preference (EVE-598).
77    ///
78    /// `None` (default) preserves provider defaults and the act scheduler's
79    /// class-aware concurrent schedule. `Some(true)` explicitly signals the
80    /// provider that parallel tool calls are wanted; `Some(false)` asks the
81    /// provider to emit at most one tool call per turn AND forces the act
82    /// scheduler to serialize the batch (see `ActInput.parallel_tool_calls`).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub parallel_tool_calls: Option<bool>,
85
86    /// User-visible conversation context (e.g. hierarchical AGENTS.md).
87    /// Renders as the leading user-role message of every turn: model-visible
88    /// and re-resolved alongside the system prompt, but never folded into the
89    /// cached system prompt.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub conversation_context: Option<String>,
92}
93
94/// Default maximum iterations per turn (500).
95///
96/// Resolution priority: session override > agent config > this default.
97pub fn default_max_iterations() -> usize {
98    500
99}
100
101impl RuntimeAgent {
102    /// Create a new runtime agent configuration with required fields only
103    pub fn new(system_prompt: impl Into<String>, model: impl Into<String>) -> Self {
104        Self {
105            system_prompt: system_prompt.into(),
106            model: model.into(),
107            tools: Vec::new(),
108            max_iterations: default_max_iterations(),
109            temperature: None,
110            max_tokens: None,
111            tool_search: None,
112            prompt_cache: None,
113            driver_options: Default::default(),
114            network_access: None,
115            parallel_tool_calls: None,
116            conversation_context: None,
117        }
118    }
119}
120
121impl Default for RuntimeAgent {
122    fn default() -> Self {
123        Self {
124            system_prompt: "You are a helpful assistant.".to_string(),
125            model: "gpt-5.2".to_string(),
126            tools: Vec::new(),
127            max_iterations: default_max_iterations(),
128            temperature: None,
129            max_tokens: None,
130            tool_search: None,
131            prompt_cache: None,
132            driver_options: Default::default(),
133            network_access: None,
134            parallel_tool_calls: None,
135            conversation_context: None,
136        }
137    }
138}
139
140/// Builder for RuntimeAgent with fluent API
141///
142/// Use `new()` to start building, then chain methods like `with_agent()`,
143/// `model()`, `temperature()`, etc. Call `build()` to get the final runtime agent.
144pub struct RuntimeAgentBuilder {
145    runtime_agent: RuntimeAgent,
146    tool_definition_hooks: Vec<std::sync::Arc<dyn ToolDefinitionHook>>,
147}
148
149impl RuntimeAgentBuilder {
150    /// Start building a new runtime agent from scratch
151    pub fn new() -> Self {
152        Self {
153            runtime_agent: RuntimeAgent::default(),
154            tool_definition_hooks: Vec::new(),
155        }
156    }
157
158    /// Build from a pre-merged AgentConfigOverlay.
159    ///
160    /// This is the preferred way to build a RuntimeAgent. The caller merges
161    /// Harness/Agent/Session into a single AgentConfigOverlay (via `AgentConfigOverlay::fold`),
162    /// then this method resolves capabilities and assembles the final config.
163    ///
164    /// # Example
165    ///
166    /// ```ignore
167    /// let layer = AgentConfigOverlay::fold([
168    ///     AgentConfigOverlay::from(&harness),
169    ///     AgentConfigOverlay::from(&agent),
170    ///     AgentConfigOverlay::from(&session),
171    /// ]);
172    /// let runtime_agent = RuntimeAgentBuilder::from_overlay(layer, &registry, &ctx)
173    ///     .await
174    ///     .model("gpt-5.2")
175    ///     .build();
176    /// ```
177    pub async fn from_overlay(
178        layer: AgentConfigOverlay,
179        registry: &CapabilityRegistry,
180        ctx: &SystemPromptContext,
181    ) -> Self {
182        let mut builder = Self::new();
183
184        // Always set system prompt (even to empty) so an intentionally empty
185        // merged prompt clears the builder default instead of leaving it.
186        builder = builder.system_prompt(layer.system_prompt.unwrap_or_default());
187
188        // Resolve merged capabilities (once, on the effective set)
189        builder = builder
190            .with_capability_configs(&layer.capabilities, registry, ctx)
191            .await;
192
193        // Add tools from all layers
194        if !layer.tools.is_empty() {
195            builder = builder.tools(layer.tools);
196        }
197
198        // Set max_iterations if any layer specified it
199        if let Some(max) = layer.max_iterations {
200            builder = builder.max_iterations(max);
201        }
202
203        // Set merged network_access
204        builder = builder.network_access(layer.network_access);
205
206        // Set merged request-level parallel_tool_calls preference (EVE-598).
207        // The explicit field is an escape hatch and wins over the
208        // `parallel_tool_calls` capability applied during capability collection;
209        // when unset, the capability-derived preference (if any) stands.
210        if let Some(explicit) = layer.parallel_tool_calls {
211            builder = builder.parallel_tool_calls(Some(explicit));
212        }
213
214        builder
215    }
216
217    /// Apply a Harness's configuration to this builder.
218    ///
219    /// Sets the system prompt from the harness and applies harness capabilities.
220    /// Calls `system_prompt_contribution()` on each capability for dynamic content.
221    /// Call this BEFORE `with_agent()` to establish the base prompt layer.
222    pub async fn with_harness(
223        self,
224        harness: &HarnessDefinition,
225        registry: &CapabilityRegistry,
226        ctx: &SystemPromptContext,
227    ) -> Self {
228        self.system_prompt(harness.system_prompt.clone().unwrap_or_default())
229            .with_capability_configs(&harness.capabilities, registry, ctx)
230            .await
231    }
232
233    /// Apply an Agent's configuration to this builder.
234    ///
235    /// Applies the agent's system prompt and capabilities on top of the
236    /// existing prompt (typically from a harness). Call after `with_harness()`.
237    ///
238    /// # Example
239    ///
240    /// ```ignore
241    /// let ctx = SystemPromptContext::without_file_store(session_id);
242    /// let runtime_agent = RuntimeAgentBuilder::new()
243    ///     .with_harness(&harness, &registry, &ctx).await
244    ///     .with_agent(&agent, &registry, &ctx).await
245    ///     .with_capabilities(&session_caps, &registry, &ctx).await
246    ///     .model("gpt-5.2")
247    ///     .build();
248    /// ```
249    pub async fn with_agent(
250        self,
251        agent: &AgentDefinition,
252        registry: &CapabilityRegistry,
253        ctx: &SystemPromptContext,
254    ) -> Self {
255        let mut builder = self
256            .system_prompt(&agent.system_prompt)
257            .with_capability_configs(&agent.capabilities, registry, ctx)
258            .await;
259
260        // Add agent-level client-side tools
261        if !agent.tools.is_empty() {
262            builder = builder.tools(agent.tools.clone());
263        }
264
265        builder
266    }
267
268    /// Apply capabilities to this builder.
269    ///
270    /// Resolves dependencies, then collects contributions from capabilities:
271    /// - Dependencies are automatically included (topologically sorted)
272    /// - `system_prompt_contribution(ctx)` called on each (may read from filesystem)
273    /// - System prompt additions are appended after the current system prompt
274    /// - Tool definitions are added to the tools list
275    ///
276    /// # Arguments
277    ///
278    /// * `capability_ids` - Ordered list of capability IDs to apply
279    /// * `registry` - The capability registry containing implementations
280    /// * `ctx` - Session context for dynamic prompt resolution
281    pub async fn with_capabilities(
282        self,
283        capability_ids: &[String],
284        registry: &CapabilityRegistry,
285        ctx: &SystemPromptContext,
286    ) -> Self {
287        let capability_configs: Vec<crate::AgentCapabilityConfig> = capability_ids
288            .iter()
289            .map(|id| crate::AgentCapabilityConfig::new(id.clone()))
290            .collect();
291        self.with_capability_configs(&capability_configs, registry, ctx)
292            .await
293    }
294
295    /// Apply capability configs to this builder, preserving per-capability configuration.
296    pub async fn with_capability_configs(
297        mut self,
298        capability_configs: &[crate::AgentCapabilityConfig],
299        registry: &CapabilityRegistry,
300        ctx: &SystemPromptContext,
301    ) -> Self {
302        let resolved_configs = match resolve_capability_configs(capability_configs, registry) {
303            Ok(resolved) => resolved,
304            Err(e) => {
305                tracing::warn!("Failed to resolve capability dependencies: {}", e);
306                capability_configs.to_vec()
307            }
308        };
309
310        let collected = collect_capabilities_with_configs(&resolved_configs, registry, ctx).await;
311
312        // Apply system prompt additions after the stable base prompt.
313        if let Some(prefix) = collected.system_prompt_prefix() {
314            self.runtime_agent.system_prompt =
315                compose_system_prompt(&self.runtime_agent.system_prompt, Some(&prefix));
316        }
317
318        // Carry conversation context (e.g. hierarchical AGENTS.md) alongside
319        // the agent. The turn loop renders it as the leading user-role
320        // message: model-visible every turn, but never folded into the cached
321        // system prompt.
322        self.runtime_agent.conversation_context = collected.conversation_context();
323
324        // Apply tool definitions
325        if !collected.tool_definitions.is_empty() {
326            self = self.tools(collected.tool_definitions);
327        }
328
329        // Apply tool_search config if capability provided one
330        if let Some(ts_config) = collected.tool_search {
331            self.runtime_agent.tool_search = Some(ts_config);
332        }
333
334        if let Some(pc_config) = collected.prompt_cache {
335            self.runtime_agent.prompt_cache = Some(pc_config);
336        }
337
338        for (key, value) in collected.driver_options {
339            self.runtime_agent.driver_options.insert(key, value);
340        }
341
342        // Apply the `parallel_tool_calls` capability preference. An explicit
343        // request-level field set later (see `from_overlay`) takes precedence.
344        if let Some(ptc) = collected.parallel_tool_calls {
345            self.runtime_agent.parallel_tool_calls = Some(ptc);
346        }
347
348        self.tool_definition_hooks
349            .extend(collected.tool_definition_hooks);
350
351        self
352    }
353
354    /// Set the system prompt
355    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
356        self.runtime_agent.system_prompt = prompt.into();
357        self
358    }
359
360    /// Prepend text to the system prompt
361    pub fn prepend_system_prompt(mut self, prefix: impl Into<String>) -> Self {
362        let prefix = prefix.into();
363        if !prefix.is_empty() {
364            self.runtime_agent.system_prompt =
365                format!("{}\n\n{}", prefix, self.runtime_agent.system_prompt);
366        }
367        self
368    }
369
370    /// Append locale instructions for session-aware localization.
371    pub fn with_locale(self, locale: Option<&str>) -> Self {
372        let Some(locale) = locale.map(str::trim).filter(|value| !value.is_empty()) else {
373            return self;
374        };
375
376        self.append_system_prompt(format!(
377            "<locale preference=\"{locale}\">\n\
378             Default locale for this session: {locale}.\n\
379             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\
380             </locale>"
381        ))
382    }
383
384    /// Append text to the system prompt
385    pub fn append_system_prompt(mut self, suffix: impl Into<String>) -> Self {
386        let suffix = suffix.into();
387        if !suffix.is_empty() {
388            if self.runtime_agent.system_prompt.is_empty() {
389                self.runtime_agent.system_prompt = suffix;
390            } else {
391                self.runtime_agent.system_prompt =
392                    format!("{}\n\n{}", self.runtime_agent.system_prompt, suffix);
393            }
394        }
395        self
396    }
397
398    /// Set the model
399    pub fn model(mut self, model: impl Into<String>) -> Self {
400        self.runtime_agent.model = model.into();
401        self
402    }
403
404    /// Add a tool
405    pub fn tool(mut self, tool: ToolDefinition) -> Self {
406        self.runtime_agent.tools.push(tool);
407        self
408    }
409
410    /// Add multiple tools
411    pub fn tools(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
412        self.runtime_agent.tools.extend(tools);
413        self
414    }
415
416    /// Set maximum iterations
417    pub fn max_iterations(mut self, max: usize) -> Self {
418        self.runtime_agent.max_iterations = max;
419        self
420    }
421
422    /// Set the merged network access list.
423    pub fn network_access(
424        mut self,
425        network_access: Option<crate::network_access::NetworkAccessList>,
426    ) -> Self {
427        self.runtime_agent.network_access = network_access;
428        self
429    }
430
431    /// Set the request-level parallel tool calling preference (EVE-598).
432    pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
433        self.runtime_agent.parallel_tool_calls = parallel_tool_calls;
434        self
435    }
436
437    /// Set temperature
438    pub fn temperature(mut self, temp: f32) -> Self {
439        self.runtime_agent.temperature = Some(temp);
440        self
441    }
442
443    /// Set max tokens
444    pub fn max_tokens(mut self, tokens: u32) -> Self {
445        self.runtime_agent.max_tokens = Some(tokens);
446        self
447    }
448
449    /// Set tool_search configuration
450    pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
451        self.runtime_agent.tool_search = Some(config);
452        self
453    }
454
455    /// Set prompt caching configuration
456    pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
457        self.runtime_agent.prompt_cache = Some(config);
458        self
459    }
460
461    /// Build the runtime agent.
462    ///
463    /// Validates that a hosted tool_search config is only kept for models that
464    /// support it (OpenAI GPT-5.4+ and Claude Sonnet 4 / Opus 4 / Haiku 4.5 /
465    /// Fable 5 and newer). Clears it for unsupported models to prevent 400 errors
466    /// from the provider API.
467    ///
468    /// tool_search is capability-driven: a hosted config is only set when the
469    /// `openai_tool_search` / `claude_tool_search` capability (directly or via
470    /// `auto_tool_search`) is added to the agent or harness. This method does NOT
471    /// auto-enable it.
472    pub fn build(mut self) -> RuntimeAgent {
473        // Deduplicate tools by name (last wins). Tools are collected additively
474        // from harness, agent, MCP servers, session capabilities, and client-side
475        // tools — duplicates can occur when the same tool is registered by
476        // multiple sources.
477        {
478            let mut seen = std::collections::HashSet::new();
479            let mut deduped = Vec::with_capacity(self.runtime_agent.tools.len());
480            // Iterate in reverse so the last-added tool wins, then reverse back.
481            for tool in self.runtime_agent.tools.drain(..).rev() {
482                if seen.insert(tool.name().to_owned()) {
483                    deduped.push(tool);
484                }
485            }
486            deduped.reverse();
487            self.runtime_agent.tools = deduped;
488        }
489
490        // Resolve tool_search (deferred tool loading). The mechanism is already
491        // chosen at capability-collection time (see `Capability::resolve_for_model`
492        // and `auto_tool_search`): a hosted `ToolSearchConfig` means the hosted
493        // (native) mechanism; client-side deferral arrives as `DeferSchemaHook`
494        // plus a `tool_search` tool. This step only reconciles a hosted config
495        // with the model — collection may have set one (via a direct
496        // `openai_tool_search` capability) that the model can't honor.
497        // A hosted config is honorable when any provider with a driver that
498        // renders the hosted format advertises tool_search for this model:
499        // OpenAI (Responses) and Anthropic (Messages). A model id resolves under
500        // at most one of these provider profiles, so the other lookup is None.
501        let model_supports_native =
502            [DriverId::OpenAI, DriverId::Anthropic]
503                .iter()
504                .any(|provider| {
505                    get_model_profile(provider, &self.runtime_agent.model)
506                        .is_some_and(|p| p.tool_search)
507                });
508
509        // Hosted (native) deferral hides schemas server-side, so client-side
510        // opt-out hooks (DeferSchemaHook) must be skipped while a hosted config
511        // is present — even on an unsupported model, where the hosted config is
512        // disabled below (full schemas, no client-side fallback). This is what
513        // makes a hand-configured `openai_tool_search` win over a separately
514        // configured `tool_search`.
515        let native_tool_search = self.runtime_agent.tool_search.is_some();
516        for hook in &self.tool_definition_hooks {
517            if native_tool_search && !hook.applies_with_native_tool_search() {
518                continue;
519            }
520            self.runtime_agent.tools =
521                hook.transform(std::mem::take(&mut self.runtime_agent.tools));
522        }
523
524        // Clear a hosted config the model can't honor (a direct `openai_tool_search`
525        // on an unsupported model): it simply sends full schemas. `auto_tool_search`
526        // never reaches here on an unsupported model — it resolves to the generic
527        // client-side mechanism at collection time and sets no hosted config.
528        if self.runtime_agent.tool_search.is_some() && !model_supports_native {
529            tracing::debug!(
530                model = %self.runtime_agent.model,
531                "hosted tool_search not supported by model; disabling (full schemas)"
532            );
533            self.runtime_agent.tool_search = None;
534        }
535
536        self.runtime_agent
537    }
538}
539
540impl Default for RuntimeAgentBuilder {
541    fn default() -> Self {
542        Self::new()
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::AgentCapabilityConfig;
550    use crate::capabilities::{Capability, SystemPromptContext};
551    use crate::typed_id::AgentId;
552
553    struct ToolFixtureCapability;
554
555    impl Capability for ToolFixtureCapability {
556        fn id(&self) -> &str {
557            "tool_fixture"
558        }
559
560        fn name(&self) -> &str {
561            "Tool Fixture"
562        }
563
564        fn description(&self) -> &str {
565            "Neutral capability fixture with one tool."
566        }
567
568        fn tools(&self) -> Vec<Box<dyn crate::Tool>> {
569            vec![Box::new(crate::tools::EchoTool)]
570        }
571    }
572
573    struct PromptToolFixtureCapability;
574
575    impl Capability for PromptToolFixtureCapability {
576        fn id(&self) -> &str {
577            "prompt_tool_fixture"
578        }
579
580        fn name(&self) -> &str {
581            "Prompt Tool Fixture"
582        }
583
584        fn description(&self) -> &str {
585            "Neutral capability fixture with a prompt and tool."
586        }
587
588        fn system_prompt_addition(&self) -> Option<&str> {
589            Some("Task Management fixture guidance.")
590        }
591
592        fn tools(&self) -> Vec<Box<dyn crate::Tool>> {
593            vec![Box::new(crate::progress_reporting::ReportProgressTool)]
594        }
595    }
596
597    fn fixture_registry() -> CapabilityRegistry {
598        let mut registry = crate::CapabilityRegistry::new();
599        registry.register(ToolFixtureCapability);
600        registry.register(PromptToolFixtureCapability);
601        registry
602    }
603
604    fn test_ctx() -> SystemPromptContext {
605        SystemPromptContext::without_file_store(crate::typed_id::SessionId::new())
606    }
607
608    struct FileSystemFixture;
609
610    impl crate::capabilities::Capability for FileSystemFixture {
611        fn id(&self) -> &str {
612            "session_file_system"
613        }
614        fn name(&self) -> &str {
615            "Fixture Filesystem"
616        }
617        fn description(&self) -> &str {
618            "Fixture for host-supplied filesystem composition."
619        }
620        fn system_prompt_addition(&self) -> Option<&str> {
621            Some("The workspace root is `/workspace`.")
622        }
623    }
624
625    fn client_tool(name: &str, description: &str) -> ToolDefinition {
626        ToolDefinition::ClientSide(crate::tool_types::ClientSideTool {
627            name: name.into(),
628            display_name: Some("Client action".into()),
629            description: description.into(),
630            parameters: serde_json::json!({"type":"object","properties":{"selector":{"type":"string"}},"required":["selector"]}),
631            category: Some("Browser".into()),
632            deferrable: Default::default(),
633            hints: Default::default(),
634            full_parameters: None,
635        })
636    }
637
638    fn echo_definition() -> ToolDefinition {
639        crate::Tool::to_definition(&crate::tools::EchoTool)
640            .with_capability_attribution("tool_fixture", Some("Tool Fixture"))
641    }
642
643    fn progress_definition() -> ToolDefinition {
644        crate::Tool::to_definition(&crate::progress_reporting::ReportProgressTool)
645            .with_capability_attribution("prompt_tool_fixture", Some("Prompt Tool Fixture"))
646    }
647
648    fn tools_json(tools: &[ToolDefinition]) -> serde_json::Value {
649        serde_json::to_value(tools).unwrap()
650    }
651
652    #[test]
653    fn minimal_construction_and_legacy_wire_input_preserve_iteration_limit() {
654        let expected = serde_json::json!({
655            "system_prompt":"Custom prompt", "model":"custom-model", "tools":[],
656            "max_iterations":500, "temperature":null, "max_tokens":null
657        });
658        for agent in [
659            RuntimeAgent::new("Custom prompt", "custom-model"),
660            RuntimeAgentBuilder::new()
661                .system_prompt("Custom prompt")
662                .model("custom-model")
663                .build(),
664            serde_json::from_value::<RuntimeAgent>(
665                serde_json::json!({"system_prompt":"Custom prompt","model":"custom-model"}),
666            )
667            .unwrap(),
668        ] {
669            assert_eq!(serde_json::to_value(agent).unwrap(), expected);
670        }
671    }
672
673    #[test]
674    fn builder_preserves_all_explicit_request_options() {
675        let tool = client_tool("click", "Click a selector");
676        let policy = crate::network_access::NetworkAccessList::block(["private.example.com"]);
677        let agent = RuntimeAgentBuilder::default()
678            .system_prompt("You are a coder.")
679            .model("gpt-5.4")
680            .max_iterations(23)
681            .temperature(0.75)
682            .max_tokens(2048)
683            .parallel_tool_calls(Some(false))
684            .network_access(Some(policy.clone()))
685            .tool(tool.clone())
686            .build();
687        assert_eq!(
688            serde_json::to_value(agent).unwrap(),
689            serde_json::json!({
690                "system_prompt":"You are a coder.", "model":"gpt-5.4", "tools":[tool],
691                "max_iterations":23, "temperature":0.75, "max_tokens":2048,
692                "network_access":policy, "parallel_tool_calls":false
693            })
694        );
695    }
696
697    #[test]
698    fn prompt_operations_preserve_order_and_ignore_empty_additions() {
699        for (prefix, suffix, expected) in [
700            ("", "", "Base prompt."),
701            ("Prefix.", "", "Prefix.\n\nBase prompt."),
702            ("", "Suffix.", "Base prompt.\n\nSuffix."),
703            ("Prefix.", "Suffix.", "Prefix.\n\nBase prompt.\n\nSuffix."),
704        ] {
705            let agent = RuntimeAgentBuilder::new()
706                .system_prompt("Base prompt.")
707                .prepend_system_prompt(prefix)
708                .append_system_prompt(suffix)
709                .build();
710            assert_eq!(agent.system_prompt, expected);
711        }
712        assert_eq!(
713            RuntimeAgentBuilder::new()
714                .system_prompt("")
715                .append_system_prompt("Only suffix.")
716                .build()
717                .system_prompt,
718            "Only suffix."
719        );
720    }
721
722    #[test]
723    fn locale_instructions_trim_input_preserve_base_and_omit_empty_preferences() {
724        for locale in [None, Some(""), Some(" \t")] {
725            let agent = RuntimeAgentBuilder::new()
726                .system_prompt("Base prompt.")
727                .with_locale(locale)
728                .build();
729            assert_eq!(agent.system_prompt, "Base prompt.");
730        }
731        for locale in ["uk-UA", " uk-UA \n"] {
732            let prompt = RuntimeAgentBuilder::new()
733                .system_prompt("Base prompt.")
734                .with_locale(Some(locale))
735                .build()
736                .system_prompt;
737            assert!(prompt.starts_with("Base prompt.\n\n<locale preference=\"uk-UA\">\n"));
738            assert!(prompt.contains("Default locale for this session: uk-UA.\n"));
739            assert!(prompt.ends_with("\n</locale>"));
740            assert_eq!(prompt.matches("Base prompt.").count(), 1);
741            assert_eq!(prompt.matches("<locale ").count(), 1);
742        }
743    }
744
745    #[tokio::test]
746    async fn empty_capability_application_preserves_existing_configuration() {
747        let tool = client_tool("click", "existing tool");
748        let agent = RuntimeAgentBuilder::new()
749            .system_prompt("Base prompt.")
750            .model("custom-model")
751            .max_iterations(19)
752            .tool(tool.clone())
753            .parallel_tool_calls(Some(false))
754            .with_capabilities(&[], &fixture_registry(), &test_ctx())
755            .await
756            .build();
757        assert_eq!(
758            serde_json::to_value(agent).unwrap(),
759            serde_json::json!({
760                "system_prompt":"Base prompt.","model":"custom-model","tools":[tool],
761                "max_iterations":19,"temperature":null,"max_tokens":null,"parallel_tool_calls":false
762            })
763        );
764    }
765
766    #[tokio::test]
767    async fn direct_capabilities_preserve_complete_tool_definitions() {
768        let agent = RuntimeAgentBuilder::new()
769            .system_prompt("Base prompt.")
770            .with_capabilities(&["tool_fixture".into()], &fixture_registry(), &test_ctx())
771            .await
772            .build();
773        assert_eq!(tools_json(&agent.tools), tools_json(&[echo_definition()]));
774        assert_eq!(agent.system_prompt, "Base prompt.");
775    }
776
777    #[tokio::test]
778    async fn agent_application_preserves_client_and_capability_tool_payloads() {
779        for (with_capability, with_client) in [(true, false), (false, true), (true, true)] {
780            let mut source = AgentDefinition::new(AgentId::new(), "test-agent", "Agent prompt.");
781            let client = client_tool("click", "Click the requested selector");
782            let mut expected = Vec::new();
783            if with_capability {
784                source
785                    .capabilities
786                    .push(AgentCapabilityConfig::new("tool_fixture"));
787                expected.push(echo_definition());
788            }
789            if with_client {
790                source.tools.push(client.clone());
791                expected.push(client);
792            }
793            let agent = RuntimeAgentBuilder::new()
794                .with_agent(&source, &fixture_registry(), &test_ctx())
795                .await
796                .build();
797            assert_eq!(agent.system_prompt, "Agent prompt.");
798            assert_eq!(tools_json(&agent.tools), tools_json(&expected));
799        }
800    }
801
802    #[tokio::test]
803    async fn capability_prompt_follows_stable_base_once() {
804        let mut registry = CapabilityRegistry::new();
805        registry.register(FileSystemFixture);
806        let agent = RuntimeAgentBuilder::new()
807            .system_prompt("Base prompt.")
808            .with_capabilities(&["session_file_system".into()], &registry, &test_ctx())
809            .await
810            .build();
811        assert_eq!(
812            agent.system_prompt,
813            "<system-prompt>\nBase prompt.\n</system-prompt>\n\n<capability id=\"session_file_system\">\nThe workspace root is `/workspace`.\n</capability>"
814        );
815    }
816
817    #[tokio::test]
818    async fn additive_capabilities_preserve_prior_tools_and_prompt() {
819        let mut source = AgentDefinition::new(AgentId::new(), "test-agent", "Agent prompt.");
820        source
821            .capabilities
822            .push(AgentCapabilityConfig::new("tool_fixture"));
823        let registry = fixture_registry();
824        let agent = RuntimeAgentBuilder::new()
825            .with_agent(&source, &registry, &test_ctx())
826            .await
827            .with_capabilities(&["prompt_tool_fixture".into()], &registry, &test_ctx())
828            .await
829            .build();
830        assert_eq!(
831            tools_json(&agent.tools),
832            tools_json(&[echo_definition(), progress_definition()])
833        );
834        assert_eq!(
835            agent.system_prompt,
836            "<system-prompt>\nAgent prompt.\n</system-prompt>\n\n<capability id=\"prompt_tool_fixture\">\nTask Management fixture guidance.\n</capability>"
837        );
838    }
839
840    #[test]
841    fn hosted_tool_search_requires_support_and_preserves_explicit_config() {
842        for (model, supported) in [
843            ("gpt-5.2", false),
844            ("claude-3-5-haiku", false),
845            ("unknown-model", false),
846            ("gpt-5.4", true),
847            ("claude-opus-4-8", true),
848        ] {
849            assert!(
850                RuntimeAgentBuilder::new()
851                    .model(model)
852                    .build()
853                    .tool_search
854                    .is_none(),
855                "must not auto-enable for {model}"
856            );
857            for (enabled, threshold) in [(true, 5), (false, 0)] {
858                let agent = RuntimeAgentBuilder::new()
859                    .model(model)
860                    .tool_search(ToolSearchConfig { enabled, threshold })
861                    .build();
862                let expected =
863                    supported.then(|| serde_json::json!({"enabled":enabled,"threshold":threshold}));
864                assert_eq!(
865                    agent.tool_search.map(|v| serde_json::to_value(v).unwrap()),
866                    expected,
867                    "{model}"
868                );
869            }
870        }
871    }
872
873    #[test]
874    fn hooks_run_in_order_and_respect_native_configuration_before_model_filtering() {
875        struct AppendHook {
876            suffix: &'static str,
877            native: bool,
878        }
879        impl ToolDefinitionHook for AppendHook {
880            fn transform(&self, mut tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
881                for tool in &mut tools {
882                    match tool {
883                        ToolDefinition::Builtin(t) => t.description.push_str(self.suffix),
884                        ToolDefinition::ClientSide(t) => t.description.push_str(self.suffix),
885                    }
886                }
887                tools
888            }
889            fn applies_with_native_tool_search(&self) -> bool {
890                self.native
891            }
892        }
893        for (model, configured, expected_description, kept_native) in [
894            ("gpt-5.4", false, "original|first|conditional|last", false),
895            ("gpt-5.4", true, "original|first|last", true),
896            ("gpt-5.2", true, "original|first|last", false),
897        ] {
898            let mut builder = RuntimeAgentBuilder::new()
899                .model(model)
900                .tool(client_tool("click", "original"));
901            if configured {
902                builder = builder.tool_search(ToolSearchConfig {
903                    enabled: true,
904                    threshold: 9,
905                });
906            }
907            for (suffix, native) in [("|first", true), ("|conditional", false), ("|last", true)] {
908                builder
909                    .tool_definition_hooks
910                    .push(std::sync::Arc::new(AppendHook { suffix, native }));
911            }
912            let agent = builder.build();
913            assert_eq!(
914                tools_json(&agent.tools),
915                tools_json(&[client_tool("click", expected_description)])
916            );
917            assert_eq!(agent.tool_search.is_some(), kept_native);
918        }
919    }
920
921    #[test]
922    fn prompt_cache_config_is_preserved_for_driver_resolution() {
923        for model in ["gpt-5.4", "gemini-3-pro", "custom-model"] {
924            for enabled in [false, true] {
925                let config = PromptCacheConfig {
926                    enabled,
927                    strategy: crate::driver_registry::PromptCacheStrategy::Auto,
928                    gemini_cached_content: Some("cachedContents/review-fixture".into()),
929                };
930                let agent = RuntimeAgentBuilder::new()
931                    .model(model)
932                    .prompt_cache(config.clone())
933                    .build();
934                assert_eq!(agent.prompt_cache, Some(config));
935            }
936        }
937    }
938
939    #[test]
940    fn deduplication_keeps_complete_last_definition_and_survivor_order() {
941        let mut first = echo_definition();
942        if let ToolDefinition::Builtin(t) = &mut first {
943            t.name = "click".into();
944        }
945        let retained = client_tool("search", "retained");
946        let last = client_tool("click", "last wins across tool variants");
947        let agent = RuntimeAgentBuilder::new()
948            .tool(first)
949            .tools([retained.clone(), last.clone()])
950            .build();
951        assert_eq!(tools_json(&agent.tools), tools_json(&[retained, last]));
952    }
953
954    struct ConfiguredFixture;
955    impl Capability for ConfiguredFixture {
956        fn id(&self) -> &str {
957            "configured_fixture"
958        }
959        fn name(&self) -> &str {
960            "Configured Fixture"
961        }
962        fn description(&self) -> &str {
963            "Config-driven preferences for assembly tests."
964        }
965        fn tool_search_config(&self, config: &serde_json::Value) -> Option<ToolSearchConfig> {
966            Some(ToolSearchConfig {
967                enabled: true,
968                threshold: config["threshold"].as_u64().unwrap() as usize,
969            })
970        }
971        fn prompt_cache_config(&self, config: &serde_json::Value) -> Option<PromptCacheConfig> {
972            Some(PromptCacheConfig {
973                enabled: config["cache_enabled"].as_bool().unwrap(),
974                strategy: crate::driver_registry::PromptCacheStrategy::Auto,
975                gemini_cached_content: config["cache"].as_str().map(str::to_owned),
976            })
977        }
978        fn parallel_tool_calls_preference(&self, config: &serde_json::Value) -> Option<bool> {
979            config["parallel"].as_bool()
980        }
981        fn driver_options(&self, config: &serde_json::Value) -> Vec<(String, serde_json::Value)> {
982            // Core treats option keys opaquely; the `test/routing` namespace is
983            // test-only and never interpreted here.
984            vec![("test/routing".to_string(), config["routing"].clone())]
985        }
986    }
987
988    #[tokio::test]
989    async fn canonical_overlay_preserves_configured_contributions_and_explicit_precedence() {
990        let mut registry = fixture_registry();
991        registry.register(ConfiguredFixture);
992        for (capability_parallel, explicit_parallel, expected_parallel) in [
993            (true, None, true),
994            (false, None, false),
995            (true, Some(false), false),
996            (false, Some(true), true),
997        ] {
998            let client = client_tool("click", "overlay client");
999            let policy = crate::network_access::NetworkAccessList::block(["private.example.com"]);
1000            let routing =
1001                serde_json::json!({"models":["openai/a","anthropic/b"],"route":"fallback"});
1002            let layer = AgentConfigOverlay {
1003                system_prompt: Some("Overlay prompt.".into()),
1004                capabilities: vec![
1005                    AgentCapabilityConfig::new("tool_fixture"),
1006                    AgentCapabilityConfig::with_config(
1007                        "configured_fixture",
1008                        serde_json::json!({
1009                            "threshold":37,"cache_enabled":false,"cache":"cachedContents/configured", "parallel":capability_parallel,"routing":routing
1010                        }),
1011                    ),
1012                ],
1013                tools: vec![client.clone()],
1014                max_iterations: Some(0),
1015                network_access: Some(policy.clone()),
1016                parallel_tool_calls: explicit_parallel,
1017                ..Default::default()
1018            };
1019            let agent = RuntimeAgentBuilder::from_overlay(layer, &registry, &test_ctx())
1020                .await
1021                .model("gpt-5.4")
1022                .build();
1023            assert_eq!(agent.system_prompt, "Overlay prompt.");
1024            assert_eq!(
1025                tools_json(&agent.tools),
1026                tools_json(&[echo_definition(), client])
1027            );
1028            assert_eq!(agent.max_iterations, 0);
1029            assert_eq!(agent.network_access, Some(policy));
1030            assert_eq!(agent.parallel_tool_calls, Some(expected_parallel));
1031            assert_eq!(
1032                serde_json::to_value(agent.tool_search.unwrap()).unwrap(),
1033                serde_json::json!({"enabled":true,"threshold":37})
1034            );
1035            assert_eq!(
1036                agent.prompt_cache,
1037                Some(PromptCacheConfig {
1038                    enabled: false,
1039                    strategy: crate::driver_registry::PromptCacheStrategy::Auto,
1040                    gemini_cached_content: Some("cachedContents/configured".into())
1041                })
1042            );
1043            assert_eq!(agent.driver_options.get("test/routing"), Some(&routing));
1044        }
1045    }
1046
1047    #[tokio::test]
1048    async fn empty_overlay_clears_default_prompt_without_enabling_preferences() {
1049        for prompt in [None, Some(String::new())] {
1050            let agent = RuntimeAgentBuilder::from_overlay(
1051                AgentConfigOverlay {
1052                    system_prompt: prompt,
1053                    ..Default::default()
1054                },
1055                &fixture_registry(),
1056                &test_ctx(),
1057            )
1058            .await
1059            .build();
1060            assert_eq!(agent.system_prompt, "");
1061            assert!(agent.tools.is_empty());
1062            assert_eq!(agent.max_iterations, 500);
1063            assert!(agent.tool_search.is_none());
1064            assert!(agent.prompt_cache.is_none());
1065            assert!(agent.driver_options.is_empty());
1066            assert!(agent.network_access.is_none());
1067            assert_eq!(agent.parallel_tool_calls, None);
1068        }
1069    }
1070
1071    #[tokio::test]
1072    async fn test_builder_with_capabilities_resolves_dependencies() {
1073        // Local stand-in for the `sample_data` fixture (now in
1074        // everruns-test-support, EVE-875): mounts + a dependency on
1075        // session_file_system.
1076        struct SampleDataFixture;
1077
1078        impl crate::capabilities::Capability for SampleDataFixture {
1079            fn id(&self) -> &str {
1080                "sample_data"
1081            }
1082            fn name(&self) -> &str {
1083                "Sample Data"
1084            }
1085            fn description(&self) -> &str {
1086                "Fixture: mounted sample files."
1087            }
1088            fn system_prompt_addition(&self) -> Option<&str> {
1089                Some("Read-only sample files are mounted at `/samples`.")
1090            }
1091            fn dependencies(&self) -> Vec<&'static str> {
1092                vec!["session_file_system"]
1093            }
1094        }
1095
1096        // Sample Data depends on Session File System
1097        // When we request only Sample Data, we should get system prompt from both
1098        let mut registry = CapabilityRegistry::new();
1099        registry.register(FileSystemFixture);
1100        registry.register(SampleDataFixture);
1101        let runtime_agent = RuntimeAgentBuilder::new()
1102            .system_prompt("Base prompt.")
1103            .with_capabilities(&["sample_data".to_string()], &registry, &test_ctx())
1104            .await
1105            .build();
1106
1107        assert_eq!(
1108            runtime_agent.system_prompt,
1109            concat!(
1110                "<system-prompt>\nBase prompt.\n</system-prompt>\n\n",
1111                "<capability id=\"session_file_system\">\nThe workspace root is `/workspace`.\n</capability>\n\n",
1112                "<capability id=\"sample_data\">\nRead-only sample files are mounted at `/samples`.\n</capability>"
1113            )
1114        );
1115        assert!(runtime_agent.tools.is_empty());
1116    }
1117}