1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct RuntimeAgent {
34 pub system_prompt: String,
36
37 pub model: String,
39
40 #[serde(default)]
42 pub tools: Vec<ToolDefinition>,
43
44 #[serde(default = "default_max_iterations")]
46 pub max_iterations: usize,
47
48 #[serde(default)]
50 pub temperature: Option<f32>,
51
52 #[serde(default)]
54 pub max_tokens: Option<u32>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub tool_search: Option<ToolSearchConfig>,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub prompt_cache: Option<PromptCacheConfig>,
63
64 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69 pub driver_options: HashMap<String, serde_json::Value>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub network_access: Option<crate::network_access::NetworkAccessList>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub parallel_tool_calls: Option<bool>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub conversation_context: Option<String>,
92}
93
94pub fn default_max_iterations() -> usize {
98 500
99}
100
101impl RuntimeAgent {
102 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
140pub struct RuntimeAgentBuilder {
145 runtime_agent: RuntimeAgent,
146 tool_definition_hooks: Vec<std::sync::Arc<dyn ToolDefinitionHook>>,
147}
148
149impl RuntimeAgentBuilder {
150 pub fn new() -> Self {
152 Self {
153 runtime_agent: RuntimeAgent::default(),
154 tool_definition_hooks: Vec::new(),
155 }
156 }
157
158 pub async fn from_overlay(
178 layer: AgentConfigOverlay,
179 registry: &CapabilityRegistry,
180 ctx: &SystemPromptContext,
181 ) -> Self {
182 let mut builder = Self::new();
183
184 builder = builder.system_prompt(layer.system_prompt.unwrap_or_default());
187
188 builder = builder
190 .with_capability_configs(&layer.capabilities, registry, ctx)
191 .await;
192
193 if !layer.tools.is_empty() {
195 builder = builder.tools(layer.tools);
196 }
197
198 if let Some(max) = layer.max_iterations {
200 builder = builder.max_iterations(max);
201 }
202
203 builder = builder.network_access(layer.network_access);
205
206 if let Some(explicit) = layer.parallel_tool_calls {
211 builder = builder.parallel_tool_calls(Some(explicit));
212 }
213
214 builder
215 }
216
217 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 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 if !agent.tools.is_empty() {
262 builder = builder.tools(agent.tools.clone());
263 }
264
265 builder
266 }
267
268 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 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 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 self.runtime_agent.conversation_context = collected.conversation_context();
323
324 if !collected.tool_definitions.is_empty() {
326 self = self.tools(collected.tool_definitions);
327 }
328
329 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 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 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
356 self.runtime_agent.system_prompt = prompt.into();
357 self
358 }
359
360 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 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 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 pub fn model(mut self, model: impl Into<String>) -> Self {
400 self.runtime_agent.model = model.into();
401 self
402 }
403
404 pub fn tool(mut self, tool: ToolDefinition) -> Self {
406 self.runtime_agent.tools.push(tool);
407 self
408 }
409
410 pub fn tools(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
412 self.runtime_agent.tools.extend(tools);
413 self
414 }
415
416 pub fn max_iterations(mut self, max: usize) -> Self {
418 self.runtime_agent.max_iterations = max;
419 self
420 }
421
422 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 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 pub fn temperature(mut self, temp: f32) -> Self {
439 self.runtime_agent.temperature = Some(temp);
440 self
441 }
442
443 pub fn max_tokens(mut self, tokens: u32) -> Self {
445 self.runtime_agent.max_tokens = Some(tokens);
446 self
447 }
448
449 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
451 self.runtime_agent.tool_search = Some(config);
452 self
453 }
454
455 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
457 self.runtime_agent.prompt_cache = Some(config);
458 self
459 }
460
461 pub fn build(mut self) -> RuntimeAgent {
473 {
478 let mut seen = std::collections::HashSet::new();
479 let mut deduped = Vec::with_capacity(self.runtime_agent.tools.len());
480 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 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 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 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()], ®istry, &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, ®istry, &test_ctx())
826 .await
827 .with_capabilities(&["prompt_tool_fixture".into()], ®istry, &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 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, ®istry, &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 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 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()], ®istry, &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}