1use crate::agent::Agent;
17use crate::capabilities::{
18 CapabilityRegistry, SystemPromptContext, ToolDefinitionHook, collect_capabilities_with_configs,
19 resolve_capability_configs,
20};
21use crate::config_layer::AgentConfigOverlay;
22use crate::harness::Harness;
23use crate::llm_driver_registry::{PromptCacheConfig, ToolSearchConfig};
24use crate::llm_model_profiles::get_model_profile;
25use crate::llm_models::LlmProviderType;
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")]
65 pub network_access: Option<crate::network_access::NetworkAccessList>,
66}
67
68pub fn default_max_iterations() -> usize {
72 500
73}
74
75impl RuntimeAgent {
76 pub fn new(system_prompt: impl Into<String>, model: impl Into<String>) -> Self {
78 Self {
79 system_prompt: system_prompt.into(),
80 model: model.into(),
81 tools: Vec::new(),
82 max_iterations: default_max_iterations(),
83 temperature: None,
84 max_tokens: None,
85 tool_search: None,
86 prompt_cache: None,
87 network_access: None,
88 }
89 }
90}
91
92impl Default for RuntimeAgent {
93 fn default() -> Self {
94 Self {
95 system_prompt: "You are a helpful assistant.".to_string(),
96 model: "gpt-5.2".to_string(),
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 network_access: None,
104 }
105 }
106}
107
108pub struct RuntimeAgentBuilder {
113 runtime_agent: RuntimeAgent,
114 tool_definition_hooks: Vec<std::sync::Arc<dyn ToolDefinitionHook>>,
115}
116
117impl RuntimeAgentBuilder {
118 pub fn new() -> Self {
120 Self {
121 runtime_agent: RuntimeAgent::default(),
122 tool_definition_hooks: Vec::new(),
123 }
124 }
125
126 pub async fn from_overlay(
146 layer: AgentConfigOverlay,
147 registry: &CapabilityRegistry,
148 ctx: &SystemPromptContext,
149 ) -> Self {
150 let mut builder = Self::new();
151
152 builder = builder.system_prompt(layer.system_prompt.unwrap_or_default());
155
156 builder = builder
158 .with_capability_configs(&layer.capabilities, registry, ctx)
159 .await;
160
161 if !layer.tools.is_empty() {
163 builder = builder.tools(layer.tools);
164 }
165
166 if let Some(max) = layer.max_iterations {
168 builder = builder.max_iterations(max);
169 }
170
171 builder = builder.network_access(layer.network_access);
173
174 builder
175 }
176
177 pub async fn with_harness(
183 self,
184 harness: &Harness,
185 registry: &CapabilityRegistry,
186 ctx: &SystemPromptContext,
187 ) -> Self {
188 self.system_prompt(&harness.system_prompt)
189 .with_capability_configs(&harness.capabilities, registry, ctx)
190 .await
191 }
192
193 pub async fn with_agent(
210 self,
211 agent: &Agent,
212 registry: &CapabilityRegistry,
213 ctx: &SystemPromptContext,
214 ) -> Self {
215 let mut builder = self
216 .system_prompt(&agent.system_prompt)
217 .with_capability_configs(&agent.capabilities, registry, ctx)
218 .await;
219
220 if !agent.tools.is_empty() {
222 builder = builder.tools(agent.tools.clone());
223 }
224
225 builder
226 }
227
228 pub async fn with_capabilities(
242 self,
243 capability_ids: &[String],
244 registry: &CapabilityRegistry,
245 ctx: &SystemPromptContext,
246 ) -> Self {
247 let capability_configs: Vec<crate::AgentCapabilityConfig> = capability_ids
248 .iter()
249 .map(|id| crate::AgentCapabilityConfig::new(id.clone()))
250 .collect();
251 self.with_capability_configs(&capability_configs, registry, ctx)
252 .await
253 }
254
255 pub async fn with_capability_configs(
257 mut self,
258 capability_configs: &[crate::AgentCapabilityConfig],
259 registry: &CapabilityRegistry,
260 ctx: &SystemPromptContext,
261 ) -> Self {
262 let resolved_configs = match resolve_capability_configs(capability_configs, registry) {
263 Ok(resolved) => resolved,
264 Err(e) => {
265 tracing::warn!("Failed to resolve capability dependencies: {}", e);
266 capability_configs.to_vec()
267 }
268 };
269
270 let collected = collect_capabilities_with_configs(&resolved_configs, registry, ctx).await;
271
272 if let Some(prefix) = collected.system_prompt_prefix() {
274 if !self.runtime_agent.system_prompt.is_empty()
275 && !self.runtime_agent.system_prompt.contains("<system-prompt>")
276 {
277 self.runtime_agent.system_prompt = format!(
278 "<system-prompt>\n{}\n</system-prompt>",
279 self.runtime_agent.system_prompt
280 );
281 }
282 self = self.prepend_system_prompt(prefix);
283 }
284
285 if !collected.tool_definitions.is_empty() {
287 self = self.tools(collected.tool_definitions);
288 }
289
290 if let Some(ts_config) = collected.tool_search {
292 self.runtime_agent.tool_search = Some(ts_config);
293 }
294
295 if let Some(pc_config) = collected.prompt_cache {
296 self.runtime_agent.prompt_cache = Some(pc_config);
297 }
298
299 self.tool_definition_hooks
300 .extend(collected.tool_definition_hooks);
301
302 self
303 }
304
305 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
307 self.runtime_agent.system_prompt = prompt.into();
308 self
309 }
310
311 pub fn prepend_system_prompt(mut self, prefix: impl Into<String>) -> Self {
313 let prefix = prefix.into();
314 if !prefix.is_empty() {
315 self.runtime_agent.system_prompt =
316 format!("{}\n\n{}", prefix, self.runtime_agent.system_prompt);
317 }
318 self
319 }
320
321 pub fn with_locale(self, locale: Option<&str>) -> Self {
323 let Some(locale) = locale.map(str::trim).filter(|value| !value.is_empty()) else {
324 return self;
325 };
326
327 self.prepend_system_prompt(format!(
328 "<locale preference=\"{locale}\">\n\
329 Default locale for this session: {locale}.\n\
330 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\
331 </locale>"
332 ))
333 }
334
335 pub fn model(mut self, model: impl Into<String>) -> Self {
337 self.runtime_agent.model = model.into();
338 self
339 }
340
341 pub fn tool(mut self, tool: ToolDefinition) -> Self {
343 self.runtime_agent.tools.push(tool);
344 self
345 }
346
347 pub fn tools(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
349 self.runtime_agent.tools.extend(tools);
350 self
351 }
352
353 pub fn max_iterations(mut self, max: usize) -> Self {
355 self.runtime_agent.max_iterations = max;
356 self
357 }
358
359 pub fn network_access(
361 mut self,
362 network_access: Option<crate::network_access::NetworkAccessList>,
363 ) -> Self {
364 self.runtime_agent.network_access = network_access;
365 self
366 }
367
368 pub fn temperature(mut self, temp: f32) -> Self {
370 self.runtime_agent.temperature = Some(temp);
371 self
372 }
373
374 pub fn max_tokens(mut self, tokens: u32) -> Self {
376 self.runtime_agent.max_tokens = Some(tokens);
377 self
378 }
379
380 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
382 self.runtime_agent.tool_search = Some(config);
383 self
384 }
385
386 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
388 self.runtime_agent.prompt_cache = Some(config);
389 self
390 }
391
392 pub fn build(mut self) -> RuntimeAgent {
402 {
407 let mut seen = std::collections::HashSet::new();
408 let mut deduped = Vec::with_capacity(self.runtime_agent.tools.len());
409 for tool in self.runtime_agent.tools.drain(..).rev() {
411 if seen.insert(tool.name().to_owned()) {
412 deduped.push(tool);
413 }
414 }
415 deduped.reverse();
416 self.runtime_agent.tools = deduped;
417 }
418
419 let model_supports_native =
427 get_model_profile(&LlmProviderType::Openai, &self.runtime_agent.model)
428 .is_some_and(|p| p.tool_search);
429
430 let native_tool_search = self.runtime_agent.tool_search.is_some();
437 for hook in &self.tool_definition_hooks {
438 if native_tool_search && !hook.applies_with_native_tool_search() {
439 continue;
440 }
441 self.runtime_agent.tools =
442 hook.transform(std::mem::take(&mut self.runtime_agent.tools));
443 }
444
445 if self.runtime_agent.tool_search.is_some() && !model_supports_native {
450 tracing::debug!(
451 model = %self.runtime_agent.model,
452 "hosted tool_search not supported by model; disabling (full schemas)"
453 );
454 self.runtime_agent.tool_search = None;
455 }
456
457 self.runtime_agent
458 }
459}
460
461impl Default for RuntimeAgentBuilder {
462 fn default() -> Self {
463 Self::new()
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::agent::AgentStatus;
471 use crate::capabilities::{AgentCapabilityConfig, SystemPromptContext};
472 use crate::typed_id::AgentId;
473
474 fn test_ctx() -> SystemPromptContext {
475 SystemPromptContext::without_file_store(crate::typed_id::SessionId::new())
476 }
477
478 #[test]
479 fn test_runtime_agent_new() {
480 let runtime_agent = RuntimeAgent::new("You are helpful.", "gpt-5.2");
481
482 assert_eq!(runtime_agent.system_prompt, "You are helpful.");
483 assert_eq!(runtime_agent.model, "gpt-5.2");
484 assert!(runtime_agent.tools.is_empty());
485 assert_eq!(runtime_agent.max_iterations, 500);
486 assert!(runtime_agent.temperature.is_none());
487 assert!(runtime_agent.max_tokens.is_none());
488 }
489
490 #[test]
491 fn test_runtime_agent_default() {
492 let runtime_agent = RuntimeAgent::default();
493
494 assert_eq!(runtime_agent.system_prompt, "You are a helpful assistant.");
495 assert_eq!(runtime_agent.model, "gpt-5.2");
496 assert!(runtime_agent.tools.is_empty());
497 assert_eq!(runtime_agent.max_iterations, 500);
498 }
499
500 #[test]
501 fn test_builder_basic() {
502 let runtime_agent = RuntimeAgentBuilder::new()
503 .system_prompt("Custom prompt")
504 .model("claude-3-opus")
505 .build();
506
507 assert_eq!(runtime_agent.system_prompt, "Custom prompt");
508 assert_eq!(runtime_agent.model, "claude-3-opus");
509 }
510
511 #[test]
512 fn test_builder_with_all_options() {
513 let runtime_agent = RuntimeAgentBuilder::new()
514 .system_prompt("You are a coder.")
515 .model("gpt-5.2")
516 .max_iterations(20)
517 .temperature(0.7)
518 .max_tokens(4096)
519 .build();
520
521 assert_eq!(runtime_agent.system_prompt, "You are a coder.");
522 assert_eq!(runtime_agent.model, "gpt-5.2");
523 assert_eq!(runtime_agent.max_iterations, 20);
524 assert_eq!(runtime_agent.temperature, Some(0.7));
525 assert_eq!(runtime_agent.max_tokens, Some(4096));
526 }
527
528 #[test]
529 fn test_builder_prepend_system_prompt() {
530 let runtime_agent = RuntimeAgentBuilder::new()
531 .system_prompt("Base prompt.")
532 .prepend_system_prompt("Prefix text.")
533 .build();
534
535 assert_eq!(runtime_agent.system_prompt, "Prefix text.\n\nBase prompt.");
536 }
537
538 #[test]
539 fn test_builder_prepend_empty_string_does_nothing() {
540 let runtime_agent = RuntimeAgentBuilder::new()
541 .system_prompt("Base prompt.")
542 .prepend_system_prompt("")
543 .build();
544
545 assert_eq!(runtime_agent.system_prompt, "Base prompt.");
546 }
547
548 #[test]
549 fn test_builder_with_locale_prepends_locale_instructions() {
550 let runtime_agent = RuntimeAgentBuilder::new()
551 .system_prompt("Base prompt.")
552 .with_locale(Some("uk-UA"))
553 .build();
554
555 assert!(runtime_agent.system_prompt.contains("<locale"));
556 assert!(runtime_agent.system_prompt.contains("uk-UA"));
557 assert!(runtime_agent.system_prompt.contains("Base prompt."));
558 }
559
560 #[tokio::test]
561 async fn test_builder_with_capabilities_empty() {
562 let registry = CapabilityRegistry::with_builtins();
563 let runtime_agent = RuntimeAgentBuilder::new()
564 .system_prompt("Base prompt.")
565 .with_capabilities(&[], ®istry, &test_ctx())
566 .await
567 .build();
568
569 assert_eq!(runtime_agent.system_prompt, "Base prompt.");
570 assert!(runtime_agent.tools.is_empty());
571 }
572
573 #[tokio::test]
574 async fn test_builder_with_capabilities_adds_tools() {
575 use crate::tool_types::ToolDefinition;
576
577 let registry = CapabilityRegistry::with_builtins();
578 let runtime_agent = RuntimeAgentBuilder::new()
579 .system_prompt("Base prompt.")
580 .with_capabilities(&["current_time".to_string()], ®istry, &test_ctx())
581 .await
582 .build();
583
584 assert_eq!(runtime_agent.tools.len(), 1);
585 match &runtime_agent.tools[0] {
586 ToolDefinition::Builtin(tool) => {
587 assert_eq!(tool.name, "get_current_time");
588 }
589 _ => panic!("expected Builtin variant"),
590 }
591 }
592
593 #[tokio::test]
594 async fn test_builder_with_capabilities_prepends_system_prompt() {
595 let registry = CapabilityRegistry::with_builtins();
596 let runtime_agent = RuntimeAgentBuilder::new()
597 .system_prompt("Base prompt.")
598 .with_capabilities(&["session_file_system".to_string()], ®istry, &test_ctx())
599 .await
600 .build();
601
602 assert!(runtime_agent.system_prompt.contains("/workspace"));
603 assert!(runtime_agent.system_prompt.contains("<system-prompt>"));
605 assert!(
606 runtime_agent
607 .system_prompt
608 .ends_with("<system-prompt>\nBase prompt.\n</system-prompt>")
609 );
610 }
611
612 #[tokio::test]
613 async fn test_builder_with_agent() {
614 use crate::tool_types::ToolDefinition;
615 use uuid::{NoContext, Timestamp, Uuid};
616
617 let registry = CapabilityRegistry::with_builtins();
618 let ts = Timestamp::now(NoContext);
619 let uuid = Uuid::new_v7(ts);
620 let agent = Agent {
621 public_id: AgentId::from_uuid(uuid),
622 internal_id: uuid,
623 name: "test-agent".to_string(),
624 display_name: Some("Test Agent".to_string()),
625 description: None,
626 system_prompt: "Agent prompt.".to_string(),
627 default_model_id: None,
628 default_version_id: None,
629 forked_from_agent_id: None,
630 forked_from_version_id: None,
631 root_agent_id: None,
632 capabilities: vec![AgentCapabilityConfig::new("current_time")],
633 initial_files: vec![],
634 network_access: None,
635 max_iterations: None,
636 tools: vec![],
637 mcp_servers: Default::default(),
638 status: AgentStatus::Active,
639 tags: vec![],
640 created_at: chrono::Utc::now(),
641 updated_at: chrono::Utc::now(),
642 archived_at: None,
643 deleted_at: None,
644 usage: None,
645 };
646
647 let runtime_agent = RuntimeAgentBuilder::new()
648 .with_agent(&agent, ®istry, &test_ctx())
649 .await
650 .model("gpt-5.2")
651 .build();
652
653 assert!(runtime_agent.system_prompt.contains("Agent prompt."));
654 assert_eq!(runtime_agent.tools.len(), 1);
655 match &runtime_agent.tools[0] {
656 ToolDefinition::Builtin(tool) => {
657 assert_eq!(tool.name, "get_current_time");
658 }
659 _ => panic!("expected Builtin variant"),
660 }
661 }
662
663 #[test]
664 fn test_builder_default() {
665 let builder = RuntimeAgentBuilder::default();
666 let runtime_agent = builder.build();
667
668 assert_eq!(runtime_agent.system_prompt, "You are a helpful assistant.");
669 assert_eq!(runtime_agent.model, "gpt-5.2");
670 }
671
672 #[tokio::test]
673 async fn test_builder_with_capabilities_resolves_dependencies() {
674 let registry = CapabilityRegistry::with_builtins();
677 let runtime_agent = RuntimeAgentBuilder::new()
678 .system_prompt("Base prompt.")
679 .with_capabilities(&["sample_data".to_string()], ®istry, &test_ctx())
680 .await
681 .build();
682
683 assert!(
685 runtime_agent
686 .system_prompt
687 .contains("<capability id=\"session_file_system\">"),
688 "Should include File System capability in XML tags"
689 );
690 assert!(
691 runtime_agent.system_prompt.contains("/workspace"),
692 "Should include File System system prompt (mentions workspace root)"
693 );
694 assert!(
696 runtime_agent
697 .system_prompt
698 .contains("<capability id=\"sample_data\">"),
699 "Should include Sample Data capability in XML tags"
700 );
701 assert!(
702 runtime_agent.system_prompt.contains("/samples"),
703 "Should include Sample Data system prompt (mentions /samples path)"
704 );
705 assert!(
707 runtime_agent.system_prompt.contains("Base prompt."),
708 "Should preserve base prompt"
709 );
710 assert!(
711 runtime_agent.system_prompt.contains("<system-prompt>"),
712 "Base prompt should be wrapped in system-prompt tags"
713 );
714 }
715
716 #[tokio::test]
717 async fn test_builder_additive_capabilities() {
718 use crate::tool_types::ToolDefinition;
719
720 let registry = CapabilityRegistry::with_builtins();
721
722 let runtime_agent = RuntimeAgentBuilder::new()
724 .system_prompt("Agent prompt.")
725 .with_capabilities(&["current_time".to_string()], ®istry, &test_ctx())
726 .await
727 .build();
728
729 assert_eq!(runtime_agent.tools.len(), 1);
731 match &runtime_agent.tools[0] {
732 ToolDefinition::Builtin(tool) => {
733 assert_eq!(tool.name, "get_current_time");
734 }
735 _ => panic!("expected Builtin variant"),
736 }
737 }
738
739 #[tokio::test]
740 async fn test_builder_with_agent_client_side_tools() {
741 use crate::tool_types::{ClientSideTool, DeferrablePolicy, ToolDefinition};
742 use uuid::{NoContext, Timestamp, Uuid};
743
744 let registry = CapabilityRegistry::with_builtins();
745 let ts = Timestamp::now(NoContext);
746 let uuid = Uuid::new_v7(ts);
747
748 let client_tool = ToolDefinition::ClientSide(ClientSideTool {
749 name: "browser_click".to_string(),
750 display_name: None,
751 description: "Click an element in the browser".to_string(),
752 parameters: serde_json::json!({
753 "type": "object",
754 "properties": {
755 "selector": {"type": "string"}
756 }
757 }),
758 category: None,
759 deferrable: DeferrablePolicy::default(),
760 hints: crate::tool_types::ToolHints::default(),
761 full_parameters: None,
762 });
763
764 let agent = Agent {
765 public_id: AgentId::from_uuid(uuid),
766 internal_id: uuid,
767 name: "client-tool-agent".to_string(),
768 display_name: Some("Client Tool Agent".to_string()),
769 description: None,
770 system_prompt: "Agent with client tools.".to_string(),
771 default_model_id: None,
772 default_version_id: None,
773 forked_from_agent_id: None,
774 forked_from_version_id: None,
775 root_agent_id: None,
776 capabilities: vec![],
777 initial_files: vec![],
778 network_access: None,
779 max_iterations: None,
780 tools: vec![client_tool],
781 mcp_servers: Default::default(),
782 status: AgentStatus::Active,
783 tags: vec![],
784 created_at: chrono::Utc::now(),
785 updated_at: chrono::Utc::now(),
786 archived_at: None,
787 deleted_at: None,
788 usage: None,
789 };
790
791 let runtime_agent = RuntimeAgentBuilder::new()
792 .with_agent(&agent, ®istry, &test_ctx())
793 .await
794 .model("gpt-5.2")
795 .build();
796
797 assert_eq!(runtime_agent.tools.len(), 1);
798 assert_eq!(runtime_agent.tools[0].name(), "browser_click");
799 assert_eq!(
800 runtime_agent.tools[0].policy(),
801 &crate::tool_types::ToolPolicy::ClientSide
802 );
803 }
804
805 #[tokio::test]
806 async fn test_builder_with_agent_client_side_and_capabilities() {
807 use crate::tool_types::{ClientSideTool, DeferrablePolicy, ToolDefinition};
808 use uuid::{NoContext, Timestamp, Uuid};
809
810 let registry = CapabilityRegistry::with_builtins();
811 let ts = Timestamp::now(NoContext);
812 let uuid = Uuid::new_v7(ts);
813
814 let client_tool = ToolDefinition::ClientSide(ClientSideTool {
815 name: "deploy_staging".to_string(),
816 display_name: None,
817 description: "Deploy to staging".to_string(),
818 parameters: serde_json::json!({"type": "object"}),
819 category: None,
820 deferrable: DeferrablePolicy::default(),
821 hints: crate::tool_types::ToolHints::default(),
822 full_parameters: None,
823 });
824
825 let agent = Agent {
826 public_id: AgentId::from_uuid(uuid),
827 internal_id: uuid,
828 name: "mixed-tool-agent".to_string(),
829 display_name: Some("Mixed Tool Agent".to_string()),
830 description: None,
831 system_prompt: "Agent with mixed tools.".to_string(),
832 default_model_id: None,
833 default_version_id: None,
834 forked_from_agent_id: None,
835 forked_from_version_id: None,
836 root_agent_id: None,
837 capabilities: vec![AgentCapabilityConfig::new("current_time")],
838 initial_files: vec![],
839 network_access: None,
840 max_iterations: None,
841 tools: vec![client_tool],
842 mcp_servers: Default::default(),
843 status: AgentStatus::Active,
844 tags: vec![],
845 created_at: chrono::Utc::now(),
846 updated_at: chrono::Utc::now(),
847 archived_at: None,
848 deleted_at: None,
849 usage: None,
850 };
851
852 let runtime_agent = RuntimeAgentBuilder::new()
853 .with_agent(&agent, ®istry, &test_ctx())
854 .await
855 .model("gpt-5.2")
856 .build();
857
858 assert_eq!(runtime_agent.tools.len(), 2);
860 let tool_names: Vec<&str> = runtime_agent.tools.iter().map(|t| t.name()).collect();
861 assert!(tool_names.contains(&"get_current_time"));
862 assert!(tool_names.contains(&"deploy_staging"));
863
864 let deploy_tool = runtime_agent
866 .tools
867 .iter()
868 .find(|t| t.name() == "deploy_staging")
869 .unwrap();
870 assert!(matches!(deploy_tool, ToolDefinition::ClientSide(_)));
871 }
872
873 #[tokio::test]
874 async fn test_builder_with_agent_and_additive_capabilities() {
875 use uuid::{NoContext, Timestamp, Uuid};
876
877 let registry = CapabilityRegistry::with_builtins();
878 let ts = Timestamp::now(NoContext);
879
880 let uuid = Uuid::new_v7(ts);
882 let agent = Agent {
883 public_id: AgentId::from_uuid(uuid),
884 internal_id: uuid,
885 name: "test-agent".to_string(),
886 display_name: Some("Test Agent".to_string()),
887 description: None,
888 system_prompt: "Agent prompt.".to_string(),
889 default_model_id: None,
890 default_version_id: None,
891 forked_from_agent_id: None,
892 forked_from_version_id: None,
893 root_agent_id: None,
894 capabilities: vec![AgentCapabilityConfig::new("current_time")],
895 initial_files: vec![],
896 network_access: None,
897 max_iterations: None,
898 tools: vec![],
899 mcp_servers: Default::default(),
900 status: AgentStatus::Active,
901 tags: vec![],
902 created_at: chrono::Utc::now(),
903 updated_at: chrono::Utc::now(),
904 archived_at: None,
905 deleted_at: None,
906 usage: None,
907 };
908
909 let session_capability_ids = vec!["stateless_todo_list".to_string()];
911
912 let runtime_agent = RuntimeAgentBuilder::new()
913 .with_agent(&agent, ®istry, &test_ctx())
914 .await
915 .with_capabilities(&session_capability_ids, ®istry, &test_ctx())
916 .await
917 .model("gpt-5.2")
918 .build();
919
920 assert!(runtime_agent.tools.len() >= 2);
922 let tool_names: Vec<&str> = runtime_agent.tools.iter().map(|t| t.name()).collect();
923 assert!(tool_names.contains(&"get_current_time"));
924 assert!(tool_names.contains(&"write_todos"));
925
926 assert!(runtime_agent.system_prompt.contains("Agent prompt."));
928 assert!(runtime_agent.system_prompt.contains("Task Management"));
929 assert!(
930 runtime_agent
931 .system_prompt
932 .contains("<capability id=\"stateless_todo_list\">")
933 );
934 let system_prompt_count = runtime_agent
936 .system_prompt
937 .matches("<system-prompt>")
938 .count();
939 assert_eq!(
940 system_prompt_count, 1,
941 "Should have exactly one <system-prompt> tag, not double-wrapped"
942 );
943 }
944
945 #[test]
946 fn test_build_clears_tool_search_for_unsupported_model() {
947 let agent = RuntimeAgentBuilder::new()
948 .model("gpt-5.2")
949 .tool_search(ToolSearchConfig {
950 enabled: true,
951 threshold: 15,
952 })
953 .build();
954
955 assert!(
956 agent.tool_search.is_none(),
957 "tool_search should be cleared for gpt-5.2 (unsupported)"
958 );
959 }
960
961 #[test]
962 fn test_build_keeps_tool_search_for_supported_model() {
963 let agent = RuntimeAgentBuilder::new()
964 .model("gpt-5.4")
965 .tool_search(ToolSearchConfig {
966 enabled: true,
967 threshold: 15,
968 })
969 .build();
970
971 assert!(
972 agent.tool_search.is_some(),
973 "tool_search should be kept for gpt-5.4 (supported)"
974 );
975 }
976
977 #[test]
978 fn test_build_skips_client_side_hook_when_native_tool_search_configured() {
979 use crate::tool_types::{BuiltinTool, ToolPolicy};
980 use std::sync::Arc;
981
982 struct ClearAllHook;
985 impl ToolDefinitionHook for ClearAllHook {
986 fn transform(&self, _tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
987 vec![]
988 }
989 fn applies_with_native_tool_search(&self) -> bool {
990 false
991 }
992 }
993
994 let tool = ToolDefinition::Builtin(BuiltinTool {
995 name: "read_file".to_string(),
996 display_name: None,
997 description: "read".to_string(),
998 parameters: serde_json::json!({}),
999 policy: ToolPolicy::Auto,
1000 category: None,
1001 deferrable: Default::default(),
1002 hints: Default::default(),
1003 full_parameters: None,
1004 });
1005
1006 let mut builder = RuntimeAgentBuilder::new()
1008 .model("gpt-5.4")
1009 .tools(vec![tool.clone()])
1010 .tool_search(ToolSearchConfig {
1011 enabled: true,
1012 threshold: 15,
1013 });
1014 builder.tool_definition_hooks.push(Arc::new(ClearAllHook));
1015 assert_eq!(
1016 builder.build().tools.len(),
1017 1,
1018 "opt-out hook must be skipped when native tool_search is configured"
1019 );
1020
1021 let mut builder = RuntimeAgentBuilder::new()
1023 .model("claude-sonnet-4-5-20250514")
1024 .tools(vec![tool]);
1025 builder.tool_definition_hooks.push(Arc::new(ClearAllHook));
1026 assert!(
1027 builder.build().tools.is_empty(),
1028 "opt-out hook runs when native tool_search is not configured"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_build_clears_tool_search_for_non_openai_model() {
1034 let agent = RuntimeAgentBuilder::new()
1035 .model("claude-sonnet-4-5-20250514")
1036 .tool_search(ToolSearchConfig {
1037 enabled: true,
1038 threshold: 15,
1039 })
1040 .build();
1041
1042 assert!(
1043 agent.tool_search.is_none(),
1044 "tool_search should be cleared for non-OpenAI models"
1045 );
1046 }
1047
1048 #[test]
1049 fn test_build_no_auto_enable_tool_search_without_capability() {
1050 let agent = RuntimeAgentBuilder::new().model("gpt-5.4").build();
1053
1054 assert!(
1055 agent.tool_search.is_none(),
1056 "tool_search must not be auto-enabled; it is capability-driven"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_build_preserves_explicit_tool_search_config_for_supported_model() {
1062 let agent = RuntimeAgentBuilder::new()
1065 .model("gpt-5.4")
1066 .tool_search(ToolSearchConfig {
1067 enabled: true,
1068 threshold: 5,
1069 })
1070 .build();
1071
1072 let ts = agent
1073 .tool_search
1074 .expect("explicit tool_search should be preserved");
1075 assert!(ts.enabled);
1076 assert_eq!(
1077 ts.threshold, 5,
1078 "custom threshold from capability must be preserved"
1079 );
1080 }
1081
1082 #[test]
1088 fn test_build_preserves_prompt_cache_for_supported_provider() {
1089 let agent = RuntimeAgentBuilder::new()
1090 .model("gpt-5.4")
1091 .prompt_cache(PromptCacheConfig {
1092 enabled: true,
1093 strategy: crate::llm_driver_registry::PromptCacheStrategy::Auto,
1094 gemini_cached_content: None,
1095 })
1096 .build();
1097
1098 let prompt_cache = agent
1099 .prompt_cache
1100 .expect("explicit prompt_cache should be preserved");
1101 assert!(prompt_cache.enabled);
1102 assert_eq!(
1103 prompt_cache.strategy,
1104 crate::llm_driver_registry::PromptCacheStrategy::Auto
1105 );
1106 }
1107
1108 #[test]
1109 fn test_build_deduplicates_tools_by_name() {
1110 use crate::tool_types::{BuiltinTool, ToolDefinition, ToolPolicy};
1111
1112 let make_tool = |name: &str, desc: &str| {
1113 ToolDefinition::Builtin(BuiltinTool {
1114 name: name.to_string(),
1115 display_name: None,
1116 description: desc.to_string(),
1117 parameters: serde_json::json!({}),
1118 policy: ToolPolicy::Auto,
1119 category: None,
1120 deferrable: Default::default(),
1121 hints: crate::tool_types::ToolHints::default(),
1122 full_parameters: None,
1123 })
1124 };
1125
1126 let agent = RuntimeAgentBuilder::new()
1127 .tool(make_tool("kv_store", "first"))
1128 .tool(make_tool("browser", "only one"))
1129 .tool(make_tool("kv_store", "second (should win)"))
1130 .build();
1131
1132 assert_eq!(agent.tools.len(), 2);
1133 assert_eq!(agent.tools[0].name(), "browser");
1135 assert_eq!(agent.tools[1].name(), "kv_store");
1136 assert_eq!(agent.tools[1].description(), "second (should win)");
1137 }
1138}