1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct RuntimeAgent {
32 pub system_prompt: String,
34
35 pub model: String,
37
38 #[serde(default)]
40 pub tools: Vec<ToolDefinition>,
41
42 #[serde(default = "default_max_iterations")]
44 pub max_iterations: usize,
45
46 #[serde(default)]
48 pub temperature: Option<f32>,
49
50 #[serde(default)]
52 pub max_tokens: Option<u32>,
53
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub tool_search: Option<ToolSearchConfig>,
57
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub prompt_cache: Option<PromptCacheConfig>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig>,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub network_access: Option<crate::network_access::NetworkAccessList>,
72
73 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub parallel_tool_calls: Option<bool>,
82}
83
84pub fn default_max_iterations() -> usize {
88 500
89}
90
91impl RuntimeAgent {
92 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
128pub struct RuntimeAgentBuilder {
133 runtime_agent: RuntimeAgent,
134 tool_definition_hooks: Vec<std::sync::Arc<dyn ToolDefinitionHook>>,
135}
136
137impl RuntimeAgentBuilder {
138 pub fn new() -> Self {
140 Self {
141 runtime_agent: RuntimeAgent::default(),
142 tool_definition_hooks: Vec::new(),
143 }
144 }
145
146 pub async fn from_overlay(
166 layer: AgentConfigOverlay,
167 registry: &CapabilityRegistry,
168 ctx: &SystemPromptContext,
169 ) -> Self {
170 let mut builder = Self::new();
171
172 builder = builder.system_prompt(layer.system_prompt.unwrap_or_default());
175
176 builder = builder
178 .with_capability_configs(&layer.capabilities, registry, ctx)
179 .await;
180
181 if !layer.tools.is_empty() {
183 builder = builder.tools(layer.tools);
184 }
185
186 if let Some(max) = layer.max_iterations {
188 builder = builder.max_iterations(max);
189 }
190
191 builder = builder.network_access(layer.network_access);
193
194 if let Some(explicit) = layer.parallel_tool_calls {
199 builder = builder.parallel_tool_calls(Some(explicit));
200 }
201
202 builder
203 }
204
205 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 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 if !agent.tools.is_empty() {
250 builder = builder.tools(agent.tools.clone());
251 }
252
253 builder
254 }
255
256 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 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 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 if !collected.tool_definitions.is_empty() {
308 self = self.tools(collected.tool_definitions);
309 }
310
311 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 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 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
338 self.runtime_agent.system_prompt = prompt.into();
339 self
340 }
341
342 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 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 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 pub fn model(mut self, model: impl Into<String>) -> Self {
382 self.runtime_agent.model = model.into();
383 self
384 }
385
386 pub fn tool(mut self, tool: ToolDefinition) -> Self {
388 self.runtime_agent.tools.push(tool);
389 self
390 }
391
392 pub fn tools(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
394 self.runtime_agent.tools.extend(tools);
395 self
396 }
397
398 pub fn max_iterations(mut self, max: usize) -> Self {
400 self.runtime_agent.max_iterations = max;
401 self
402 }
403
404 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 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 pub fn temperature(mut self, temp: f32) -> Self {
421 self.runtime_agent.temperature = Some(temp);
422 self
423 }
424
425 pub fn max_tokens(mut self, tokens: u32) -> Self {
427 self.runtime_agent.max_tokens = Some(tokens);
428 self
429 }
430
431 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
433 self.runtime_agent.tool_search = Some(config);
434 self
435 }
436
437 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
439 self.runtime_agent.prompt_cache = Some(config);
440 self
441 }
442
443 pub fn build(mut self) -> RuntimeAgent {
455 {
460 let mut seen = std::collections::HashSet::new();
461 let mut deduped = Vec::with_capacity(self.runtime_agent.tools.len());
462 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 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 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 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(&[], ®istry, &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()], ®istry, &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()], ®istry, &test_ctx())
661 .await
662 .build();
663
664 assert!(runtime_agent.system_prompt.contains("/workspace"));
665 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, ®istry, &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 let registry = CapabilityRegistry::with_builtins();
742 let runtime_agent = RuntimeAgentBuilder::new()
743 .system_prompt("Base prompt.")
744 .with_capabilities(&["sample_data".to_string()], ®istry, &test_ctx())
745 .await
746 .build();
747
748 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 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 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 let runtime_agent = RuntimeAgentBuilder::new()
789 .system_prompt("Agent prompt.")
790 .with_capabilities(&["current_time".to_string()], ®istry, &test_ctx())
791 .await
792 .build();
793
794 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, ®istry, &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, ®istry, &test_ctx())
925 .await
926 .model("gpt-5.2")
927 .build();
928
929 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 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 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 let session_capability_ids = vec!["stateless_todo_list".to_string()];
985
986 let runtime_agent = RuntimeAgentBuilder::new()
987 .with_agent(&agent, ®istry, &test_ctx())
988 .await
989 .with_capabilities(&session_capability_ids, ®istry, &test_ctx())
990 .await
991 .model("gpt-5.2")
992 .build();
993
994 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 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 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 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 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 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 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 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 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 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 #[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 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}