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