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