Skip to main content

awaken_runtime/registry/
resolver.rs

1//! Agent resolution: dynamic lookup of agent config + execution environment.
2
3use crate::backend::{ExecutionBackend, ExecutionBackendError, ExecutionBackendFactory};
4use awaken_contract::contract::executor::LlmExecutor;
5use awaken_contract::contract::inference::ContextWindowPolicy;
6use awaken_contract::contract::tool::Tool;
7use awaken_contract::registry_spec::{AgentSpec, RemoteEndpoint};
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use crate::error::RuntimeError;
12use crate::execution::{SequentialToolExecutor, ToolExecutor};
13use crate::phase::ExecutionEnv;
14
15/// A fully resolved agent: all capabilities + plugin environment, ready to run.
16///
17/// Produced by `AgentResolver::resolve()`. Contains live references
18/// (LlmExecutor, tools, plugins) — not serializable.
19#[derive(Clone)]
20pub struct ResolvedAgent {
21    /// The source agent specification.
22    pub spec: Arc<AgentSpec>,
23    /// Actual model name sent to the upstream provider.
24    pub upstream_model: String,
25    pub tools: HashMap<String, Arc<dyn Tool>>,
26    pub llm_executor: Arc<dyn LlmExecutor>,
27    pub tool_executor: Arc<dyn ToolExecutor>,
28    /// Context summarizer for LLM-based compaction. `None` disables LLM compaction
29    /// (hard truncation still works if `context_policy` is set).
30    pub context_summarizer: Option<Arc<dyn crate::context::ContextSummarizer>>,
31    /// Plugin-provided behavior (hooks, handlers, transforms).
32    pub env: ExecutionEnv,
33}
34
35impl ResolvedAgent {
36    /// Create a minimal ResolvedAgent (for tests).
37    pub fn new(
38        id: impl Into<String>,
39        upstream_model: impl Into<String>,
40        system_prompt: impl Into<String>,
41        llm_executor: Arc<dyn LlmExecutor>,
42    ) -> Self {
43        let upstream_model = upstream_model.into();
44        let spec = Arc::new(AgentSpec {
45            id: id.into(),
46            model_id: upstream_model.clone(),
47            system_prompt: system_prompt.into(),
48            max_rounds: 16,
49            max_continuation_retries: 2,
50            context_policy: None,
51            reasoning_effort: None,
52            plugin_ids: Vec::new(),
53            active_hook_filter: Default::default(),
54            allowed_tools: None,
55            excluded_tools: None,
56            endpoint: None,
57            delegates: Vec::new(),
58            sections: Default::default(),
59            registry: None,
60        });
61        Self {
62            spec,
63            upstream_model,
64            tools: HashMap::new(),
65            llm_executor,
66            tool_executor: Arc::new(SequentialToolExecutor),
67            context_summarizer: None,
68            env: ExecutionEnv::empty(),
69        }
70    }
71
72    // -- delegation accessors -------------------------------------------------
73
74    pub fn id(&self) -> &str {
75        &self.spec.id
76    }
77
78    pub fn model_id(&self) -> &str {
79        &self.spec.model_id
80    }
81
82    pub fn system_prompt(&self) -> &str {
83        &self.spec.system_prompt
84    }
85
86    pub fn max_rounds(&self) -> usize {
87        self.spec.max_rounds
88    }
89
90    pub fn context_policy(&self) -> Option<&ContextWindowPolicy> {
91        self.spec.context_policy.as_ref()
92    }
93
94    pub fn max_continuation_retries(&self) -> usize {
95        self.spec.max_continuation_retries
96    }
97
98    // -- builder methods ------------------------------------------------------
99
100    #[must_use]
101    pub fn with_tool_executor(mut self, executor: Arc<dyn ToolExecutor>) -> Self {
102        self.tool_executor = executor;
103        self
104    }
105
106    #[must_use]
107    pub fn with_max_rounds(mut self, max_rounds: usize) -> Self {
108        let mut spec = (*self.spec).clone();
109        spec.max_rounds = max_rounds;
110        self.spec = Arc::new(spec);
111        self
112    }
113
114    #[must_use]
115    pub fn with_tool(mut self, tool: Arc<dyn Tool>) -> Self {
116        let desc = tool.descriptor();
117        self.tools.insert(desc.id, tool);
118        self
119    }
120
121    #[must_use]
122    pub fn with_tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
123        for tool in tools {
124            let desc = tool.descriptor();
125            self.tools.insert(desc.id, tool);
126        }
127        self
128    }
129
130    #[must_use]
131    pub fn with_context_policy(mut self, policy: ContextWindowPolicy) -> Self {
132        let mut spec = (*self.spec).clone();
133        spec.context_policy = Some(policy);
134        self.spec = Arc::new(spec);
135        self
136    }
137
138    #[must_use]
139    pub fn with_context_summarizer(
140        mut self,
141        summarizer: Arc<dyn crate::context::ContextSummarizer>,
142    ) -> Self {
143        self.context_summarizer = Some(summarizer);
144        self
145    }
146
147    #[must_use]
148    pub fn with_max_continuation_retries(mut self, n: usize) -> Self {
149        let mut spec = (*self.spec).clone();
150        spec.max_continuation_retries = n;
151        self.spec = Arc::new(spec);
152        self
153    }
154
155    pub fn tool_descriptors(&self) -> Vec<awaken_contract::contract::tool::ToolDescriptor> {
156        let mut descs: Vec<_> = self.tools.values().map(|t| t.descriptor()).collect();
157        descs.sort_by(|a, b| a.id.cmp(&b.id));
158        descs
159    }
160}
161
162impl std::fmt::Debug for ResolvedAgent {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.debug_struct("ResolvedAgent")
165            .field("agent_id", &self.id())
166            .finish_non_exhaustive()
167    }
168}
169
170/// A resolved non-local execution target backed by a runtime backend.
171#[derive(Clone)]
172pub struct ResolvedBackendAgent {
173    pub spec: Arc<AgentSpec>,
174    target: ResolvedBackendTarget,
175    backend_cache: Arc<std::sync::Mutex<Option<Arc<dyn ExecutionBackend>>>>,
176}
177
178#[derive(Clone)]
179enum ResolvedBackendTarget {
180    Ready(Arc<dyn ExecutionBackend>),
181    Factory {
182        factory: Arc<dyn ExecutionBackendFactory>,
183        endpoint: RemoteEndpoint,
184    },
185}
186
187impl ResolvedBackendAgent {
188    #[must_use]
189    pub fn with_backend(spec: Arc<AgentSpec>, backend: Arc<dyn ExecutionBackend>) -> Self {
190        Self {
191            spec,
192            target: ResolvedBackendTarget::Ready(backend),
193            backend_cache: Arc::new(std::sync::Mutex::new(None)),
194        }
195    }
196
197    #[must_use]
198    pub fn with_factory(
199        spec: Arc<AgentSpec>,
200        factory: Arc<dyn ExecutionBackendFactory>,
201        endpoint: RemoteEndpoint,
202    ) -> Self {
203        Self {
204            spec,
205            target: ResolvedBackendTarget::Factory { factory, endpoint },
206            backend_cache: Arc::new(std::sync::Mutex::new(None)),
207        }
208    }
209
210    pub fn backend(&self) -> Result<Arc<dyn ExecutionBackend>, ExecutionBackendError> {
211        match &self.target {
212            ResolvedBackendTarget::Ready(backend) => Ok(backend.clone()),
213            ResolvedBackendTarget::Factory { factory, endpoint } => {
214                if let Some(backend) = self
215                    .backend_cache
216                    .lock()
217                    .map_err(|_| {
218                        ExecutionBackendError::ExecutionFailed("backend cache lock poisoned".into())
219                    })?
220                    .clone()
221                {
222                    return Ok(backend);
223                }
224
225                let backend = factory.build(endpoint).map_err(|error| {
226                    ExecutionBackendError::ExecutionFailed(format!(
227                        "failed to build backend '{}': {error}",
228                        factory.backend()
229                    ))
230                })?;
231                *self.backend_cache.lock().map_err(|_| {
232                    ExecutionBackendError::ExecutionFailed("backend cache lock poisoned".into())
233                })? = Some(backend.clone());
234                Ok(backend)
235            }
236        }
237    }
238}
239
240/// Unified resolved execution plan for local and non-local agents.
241#[derive(Clone)]
242pub enum ResolvedExecution {
243    Local(Box<ResolvedAgent>),
244    NonLocal(ResolvedBackendAgent),
245}
246
247impl ResolvedExecution {
248    pub fn local(agent: ResolvedAgent) -> Self {
249        Self::Local(Box::new(agent))
250    }
251
252    pub fn spec(&self) -> &AgentSpec {
253        match self {
254            Self::Local(agent) => agent.spec.as_ref(),
255            Self::NonLocal(agent) => agent.spec.as_ref(),
256        }
257    }
258
259    pub fn as_local(&self) -> Option<&ResolvedAgent> {
260        match self {
261            Self::Local(agent) => Some(agent),
262            Self::NonLocal(_) => None,
263        }
264    }
265
266    pub fn into_local(self) -> Result<ResolvedAgent, RuntimeError> {
267        match self {
268            Self::Local(agent) => Ok(*agent),
269            Self::NonLocal(agent) => Err(RuntimeError::ResolveFailed {
270                message: format!(
271                    "agent '{}' is endpoint-backed and cannot be resolved locally",
272                    agent.spec.id
273                ),
274            }),
275        }
276    }
277}
278
279impl std::fmt::Debug for ResolvedExecution {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        match self {
282            Self::Local(agent) => f
283                .debug_tuple("ResolvedExecution::Local")
284                .field(agent)
285                .finish(),
286            Self::NonLocal(agent) => f
287                .debug_struct("ResolvedExecution::NonLocal")
288                .field("agent_id", &agent.spec.id)
289                .finish_non_exhaustive(),
290        }
291    }
292}
293
294/// Resolves an agent by ID, producing a ready-to-execute config + environment.
295///
296/// Implementations look up `AgentSpec` from a registry, resolve the model → provider
297/// chain to obtain `LlmExecutor`, filter tools, install plugins, and build the
298/// `ExecutionEnv`. The loop runner calls this at startup and at step boundaries
299/// when handoff is detected (via `ActiveAgentKey`).
300pub trait AgentResolver: Send + Sync {
301    fn resolve(&self, agent_id: &str) -> Result<ResolvedAgent, RuntimeError>;
302
303    /// List known agent IDs for discovery endpoints.
304    ///
305    /// Implementations that cannot enumerate agents may return an empty list.
306    fn agent_ids(&self) -> Vec<String> {
307        Vec::new()
308    }
309}
310
311/// Resolves an agent into a local or non-local execution plan.
312pub trait ExecutionResolver: AgentResolver {
313    fn resolve_execution(&self, agent_id: &str) -> Result<ResolvedExecution, RuntimeError>;
314}
315
316/// Compatibility wrapper that upgrades a local-only `AgentResolver` into an
317/// `ExecutionResolver` by treating every resolution as `ResolvedExecution::Local`.
318pub struct LocalExecutionResolver {
319    resolver: Arc<dyn AgentResolver>,
320}
321
322impl LocalExecutionResolver {
323    pub fn new(resolver: Arc<dyn AgentResolver>) -> Self {
324        Self { resolver }
325    }
326}
327
328impl AgentResolver for LocalExecutionResolver {
329    fn resolve(&self, agent_id: &str) -> Result<ResolvedAgent, RuntimeError> {
330        self.resolver.resolve(agent_id)
331    }
332
333    fn agent_ids(&self) -> Vec<String> {
334        self.resolver.agent_ids()
335    }
336}
337
338impl ExecutionResolver for LocalExecutionResolver {
339    fn resolve_execution(&self, agent_id: &str) -> Result<ResolvedExecution, RuntimeError> {
340        self.resolve(agent_id).map(ResolvedExecution::local)
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use async_trait::async_trait;
348    use awaken_contract::contract::executor::{InferenceExecutionError, InferenceRequest};
349    use awaken_contract::contract::inference::{StopReason, StreamResult, TokenUsage};
350    use awaken_contract::contract::tool::{
351        ToolCallContext, ToolDescriptor, ToolError, ToolOutput, ToolResult,
352    };
353    use serde_json::Value;
354
355    struct MockLlm;
356
357    #[async_trait]
358    impl LlmExecutor for MockLlm {
359        async fn execute(
360            &self,
361            _request: InferenceRequest,
362        ) -> Result<StreamResult, InferenceExecutionError> {
363            Ok(StreamResult {
364                content: vec![],
365                tool_calls: vec![],
366                usage: Some(TokenUsage::default()),
367                stop_reason: Some(StopReason::EndTurn),
368                has_incomplete_tool_calls: false,
369            })
370        }
371        fn name(&self) -> &str {
372            "mock"
373        }
374    }
375
376    struct TestTool {
377        id: String,
378    }
379
380    #[async_trait]
381    impl Tool for TestTool {
382        fn descriptor(&self) -> ToolDescriptor {
383            ToolDescriptor::new(&self.id, &self.id, format!("{} tool", self.id))
384        }
385        async fn execute(
386            &self,
387            args: Value,
388            _ctx: &ToolCallContext,
389        ) -> Result<ToolOutput, ToolError> {
390            Ok(ToolResult::success(&self.id, args).into())
391        }
392    }
393
394    fn mock_executor() -> Arc<dyn LlmExecutor> {
395        Arc::new(MockLlm)
396    }
397
398    #[test]
399    fn new_defaults() {
400        let agent = ResolvedAgent::new("agent-1", "model-1", "system prompt", mock_executor());
401        assert_eq!(agent.id(), "agent-1");
402        assert_eq!(agent.upstream_model, "model-1");
403        assert_eq!(agent.system_prompt(), "system prompt");
404        assert_eq!(agent.max_rounds(), 16);
405        assert!(agent.tools.is_empty());
406        assert!(agent.context_policy().is_none());
407        assert!(agent.context_summarizer.is_none());
408        assert_eq!(agent.max_continuation_retries(), 2);
409    }
410
411    #[test]
412    fn with_max_rounds() {
413        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_max_rounds(100);
414        assert_eq!(agent.max_rounds(), 100);
415    }
416
417    #[test]
418    fn with_tool() {
419        let tool: Arc<dyn Tool> = Arc::new(TestTool { id: "echo".into() });
420        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_tool(tool);
421        assert_eq!(agent.tools.len(), 1);
422        assert!(agent.tools.contains_key("echo"));
423    }
424
425    #[test]
426    fn with_tools() {
427        let tools: Vec<Arc<dyn Tool>> = vec![
428            Arc::new(TestTool { id: "echo".into() }),
429            Arc::new(TestTool {
430                id: "search".into(),
431            }),
432        ];
433        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_tools(tools);
434        assert_eq!(agent.tools.len(), 2);
435        assert!(agent.tools.contains_key("echo"));
436        assert!(agent.tools.contains_key("search"));
437    }
438
439    #[test]
440    fn tool_descriptors() {
441        let tools: Vec<Arc<dyn Tool>> = vec![
442            Arc::new(TestTool { id: "echo".into() }),
443            Arc::new(TestTool {
444                id: "search".into(),
445            }),
446        ];
447        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_tools(tools);
448        let descriptors = agent.tool_descriptors();
449        assert_eq!(descriptors.len(), 2);
450        let ids: Vec<&str> = descriptors.iter().map(|d| d.id.as_str()).collect();
451        assert!(ids.contains(&"echo"));
452        assert!(ids.contains(&"search"));
453    }
454
455    #[test]
456    fn with_context_policy() {
457        use awaken_contract::contract::inference::ContextWindowPolicy;
458
459        let policy = ContextWindowPolicy {
460            max_context_tokens: 8000,
461            max_output_tokens: 2000,
462            min_recent_messages: 4,
463            enable_prompt_cache: true,
464            autocompact_threshold: Some(4096),
465            compaction_mode: Default::default(),
466            compaction_raw_suffix_messages: 3,
467        };
468        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_context_policy(policy);
469        assert!(agent.context_policy().is_some());
470        assert_eq!(agent.context_policy().unwrap().max_context_tokens, 8000);
471    }
472
473    #[test]
474    fn with_max_continuation_retries() {
475        let agent =
476            ResolvedAgent::new("a", "m", "s", mock_executor()).with_max_continuation_retries(5);
477        assert_eq!(agent.max_continuation_retries(), 5);
478    }
479
480    #[test]
481    fn model_id_equals_model_by_default() {
482        let agent = ResolvedAgent::new("a", "claude-3", "s", mock_executor());
483        assert_eq!(agent.model_id(), "claude-3");
484        assert_eq!(agent.upstream_model, "claude-3");
485    }
486
487    #[test]
488    fn clone_works() {
489        let agent = ResolvedAgent::new("a", "m", "s", mock_executor())
490            .with_max_rounds(50)
491            .with_max_continuation_retries(3);
492        let cloned = agent.clone();
493        assert_eq!(cloned.id(), "a");
494        assert_eq!(cloned.max_rounds(), 50);
495        assert_eq!(cloned.max_continuation_retries(), 3);
496    }
497
498    #[test]
499    fn with_tool_executor() {
500        let agent = ResolvedAgent::new("a", "m", "s", mock_executor())
501            .with_tool_executor(Arc::new(crate::execution::SequentialToolExecutor));
502        assert_eq!(agent.tool_executor.name(), "sequential");
503    }
504
505    #[test]
506    fn chained_builder() {
507        let agent = ResolvedAgent::new("agent", "model", "system", mock_executor())
508            .with_max_rounds(10)
509            .with_max_continuation_retries(0)
510            .with_tool(Arc::new(TestTool { id: "t1".into() }))
511            .with_tool(Arc::new(TestTool { id: "t2".into() }));
512
513        assert_eq!(agent.max_rounds(), 10);
514        assert_eq!(agent.max_continuation_retries(), 0);
515        assert_eq!(agent.tools.len(), 2);
516    }
517
518    #[test]
519    fn tool_descriptors_sorted_by_id() {
520        let tools: Vec<Arc<dyn Tool>> = vec![
521            Arc::new(TestTool { id: "zebra".into() }),
522            Arc::new(TestTool { id: "alpha".into() }),
523            Arc::new(TestTool {
524                id: "middle".into(),
525            }),
526        ];
527        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_tools(tools);
528        let descriptors = agent.tool_descriptors();
529        let ids: Vec<&str> = descriptors.iter().map(|d| d.id.as_str()).collect();
530        assert_eq!(ids, vec!["alpha", "middle", "zebra"]);
531    }
532
533    #[test]
534    fn tool_descriptors_empty() {
535        let agent = ResolvedAgent::new("a", "m", "s", mock_executor());
536        let descriptors = agent.tool_descriptors();
537        assert!(descriptors.is_empty());
538    }
539
540    #[test]
541    fn duplicate_tool_id_overwrites() {
542        let t1: Arc<dyn Tool> = Arc::new(TestTool { id: "echo".into() });
543        let t2: Arc<dyn Tool> = Arc::new(TestTool { id: "echo".into() });
544        let agent = ResolvedAgent::new("a", "m", "s", mock_executor())
545            .with_tool(t1)
546            .with_tool(t2);
547        assert_eq!(agent.tools.len(), 1, "duplicate tool ID should overwrite");
548    }
549
550    #[test]
551    fn with_tools_deduplicates_by_id() {
552        let tools: Vec<Arc<dyn Tool>> = vec![
553            Arc::new(TestTool { id: "echo".into() }),
554            Arc::new(TestTool { id: "echo".into() }),
555            Arc::new(TestTool {
556                id: "search".into(),
557            }),
558        ];
559        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_tools(tools);
560        assert_eq!(agent.tools.len(), 2);
561    }
562
563    #[test]
564    fn system_prompt_preserved_verbatim() {
565        let prompt = "You are a helpful assistant.\nBe concise.\nDo not hallucinate.";
566        let agent = ResolvedAgent::new("a", "m", prompt, mock_executor());
567        assert_eq!(agent.system_prompt(), prompt);
568    }
569
570    #[test]
571    fn with_context_summarizer() {
572        use crate::context::ContextSummarizer;
573        use crate::context::summarizer::SummarizationError;
574
575        struct MockSummarizer;
576        #[async_trait]
577        impl ContextSummarizer for MockSummarizer {
578            async fn summarize(
579                &self,
580                _transcript: &str,
581                _previous_summary: Option<&str>,
582                _executor: &dyn awaken_contract::contract::executor::LlmExecutor,
583            ) -> Result<String, SummarizationError> {
584                Ok("summary".into())
585            }
586        }
587
588        let agent = ResolvedAgent::new("a", "m", "s", mock_executor())
589            .with_context_summarizer(Arc::new(MockSummarizer));
590        assert!(agent.context_summarizer.is_some());
591    }
592
593    #[test]
594    fn default_max_continuation_retries() {
595        let agent = ResolvedAgent::new("a", "m", "s", mock_executor());
596        assert_eq!(agent.max_continuation_retries(), 2);
597    }
598
599    #[test]
600    fn zero_max_rounds() {
601        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_max_rounds(0);
602        assert_eq!(agent.max_rounds(), 0);
603    }
604
605    #[test]
606    fn builder_all_options_set() {
607        use awaken_contract::contract::inference::ContextWindowPolicy;
608
609        let policy = ContextWindowPolicy {
610            max_context_tokens: 16000,
611            max_output_tokens: 4000,
612            min_recent_messages: 8,
613            enable_prompt_cache: false,
614            autocompact_threshold: Some(8000),
615            compaction_mode: Default::default(),
616            compaction_raw_suffix_messages: 5,
617        };
618        let agent = ResolvedAgent::new("full-agent", "gpt-4", "Be helpful.", mock_executor())
619            .with_max_rounds(32)
620            .with_max_continuation_retries(10)
621            .with_tool(Arc::new(TestTool { id: "a".into() }))
622            .with_tool(Arc::new(TestTool { id: "b".into() }))
623            .with_tool(Arc::new(TestTool { id: "c".into() }))
624            .with_context_policy(policy)
625            .with_tool_executor(Arc::new(crate::execution::SequentialToolExecutor));
626
627        assert_eq!(agent.id(), "full-agent");
628        assert_eq!(agent.upstream_model, "gpt-4");
629        assert_eq!(agent.system_prompt(), "Be helpful.");
630        assert_eq!(agent.max_rounds(), 32);
631        assert_eq!(agent.max_continuation_retries(), 10);
632        assert_eq!(agent.tools.len(), 3);
633        assert!(agent.context_policy().is_some());
634        assert_eq!(agent.context_policy().unwrap().max_context_tokens, 16000);
635        assert_eq!(agent.tool_executor.name(), "sequential");
636    }
637
638    #[test]
639    fn empty_system_prompt() {
640        let agent = ResolvedAgent::new("a", "m", "", mock_executor());
641        assert_eq!(agent.system_prompt(), "");
642    }
643
644    #[test]
645    fn system_prompt_with_unicode() {
646        let prompt = "You are \u{1F916} a helpful assistant. \u{2764}";
647        let agent = ResolvedAgent::new("a", "m", prompt, mock_executor());
648        assert_eq!(agent.system_prompt(), prompt);
649    }
650
651    #[test]
652    fn tool_descriptors_single_tool() {
653        let agent = ResolvedAgent::new("a", "m", "s", mock_executor())
654            .with_tool(Arc::new(TestTool { id: "only".into() }));
655        let descs = agent.tool_descriptors();
656        assert_eq!(descs.len(), 1);
657        assert_eq!(descs[0].id, "only");
658    }
659
660    #[test]
661    fn tool_descriptors_many_tools_sorted() {
662        let ids = ["zeta", "beta", "alpha", "gamma", "delta"];
663        let tools: Vec<Arc<dyn Tool>> = ids
664            .iter()
665            .map(|id| Arc::new(TestTool { id: (*id).into() }) as Arc<dyn Tool>)
666            .collect();
667        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_tools(tools);
668        let descs = agent.tool_descriptors();
669        let sorted_ids: Vec<&str> = descs.iter().map(|d| d.id.as_str()).collect();
670        assert_eq!(sorted_ids, vec!["alpha", "beta", "delta", "gamma", "zeta"]);
671    }
672
673    #[test]
674    fn with_tools_then_with_tool_merges() {
675        let tools: Vec<Arc<dyn Tool>> = vec![
676            Arc::new(TestTool { id: "a".into() }),
677            Arc::new(TestTool { id: "b".into() }),
678        ];
679        let agent = ResolvedAgent::new("a", "m", "s", mock_executor())
680            .with_tools(tools)
681            .with_tool(Arc::new(TestTool { id: "c".into() }));
682        assert_eq!(agent.tools.len(), 3);
683        assert!(agent.tools.contains_key("a"));
684        assert!(agent.tools.contains_key("b"));
685        assert!(agent.tools.contains_key("c"));
686    }
687
688    #[test]
689    fn default_tool_executor_is_sequential() {
690        let agent = ResolvedAgent::new("a", "m", "s", mock_executor());
691        assert_eq!(agent.tool_executor.name(), "sequential");
692        assert!(agent.tool_executor.requires_incremental_state());
693    }
694
695    #[test]
696    fn model_id_and_upstream_model_independent_after_creation() {
697        let mut agent = ResolvedAgent::new("a", "original-model", "s", mock_executor());
698        // Manually change upstream_model (simulating what resolve pipeline does)
699        agent.upstream_model = "resolved-model-name".into();
700        assert_eq!(agent.model_id(), "original-model");
701        assert_eq!(agent.upstream_model, "resolved-model-name");
702    }
703
704    #[test]
705    fn large_max_rounds() {
706        let agent = ResolvedAgent::new("a", "m", "s", mock_executor()).with_max_rounds(usize::MAX);
707        assert_eq!(agent.max_rounds(), usize::MAX);
708    }
709
710    #[test]
711    fn zero_continuation_retries_disables_recovery() {
712        let agent =
713            ResolvedAgent::new("a", "m", "s", mock_executor()).with_max_continuation_retries(0);
714        assert_eq!(agent.max_continuation_retries(), 0);
715    }
716}