Skip to main content

aether_core/core/
agent_builder.rs

1use super::agent::{AgentConfig, AutoContinue, RetryConfig};
2use crate::agent_spec::AgentSpec;
3use crate::context::CompactionConfig;
4use crate::core::{Agent, Prompt, PromptCache, Result};
5use crate::events::{AgentMessage, Command};
6use crate::mcp::run_mcp_task::McpCommand;
7use aether_auth::OAuthCredentialStorage;
8use llm::parser::ModelProviderParser;
9use llm::types::IsoString;
10use llm::{ChatMessage, Context, ModelSettings, StreamingModelProvider, ToolDefinition};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::mpsc::{self, Receiver, Sender};
14use tokio::task::JoinHandle;
15
16/// Handle for communicating with a running Agent
17pub struct AgentHandle {
18    handle: JoinHandle<()>,
19}
20
21impl AgentHandle {
22    /// Abort the agent task immediately.
23    pub fn abort(&self) {
24        self.handle.abort();
25    }
26
27    /// Returns `true` if the agent task has finished.
28    pub fn is_finished(&self) -> bool {
29        self.handle.is_finished()
30    }
31
32    /// Wait for the agent task to complete.
33    pub async fn await_completion(self) {
34        let _ = self.handle.await;
35    }
36}
37
38pub struct AgentBuilder {
39    llm: Arc<dyn StreamingModelProvider>,
40    prompts: Vec<Prompt>,
41    tool_definitions: Vec<ToolDefinition>,
42    initial_messages: Vec<ChatMessage>,
43    mcp_tx: Option<Sender<McpCommand>>,
44    channel_capacity: usize,
45    tool_timeout: Duration,
46    compaction_config: Option<CompactionConfig>,
47    max_auto_continues: u32,
48    retry_config: RetryConfig,
49    prompt_cache_key: Option<String>,
50    context_window: Option<u32>,
51    model_settings: ModelSettings,
52}
53
54impl AgentBuilder {
55    pub fn new(llm: Arc<dyn StreamingModelProvider>) -> Self {
56        Self {
57            llm,
58            prompts: Vec::new(),
59            tool_definitions: Vec::new(),
60            initial_messages: Vec::new(),
61            mcp_tx: None,
62            channel_capacity: 1000,
63            tool_timeout: Duration::from_mins(20),
64            compaction_config: Some(CompactionConfig::default()),
65            max_auto_continues: 3,
66            retry_config: RetryConfig::default(),
67            prompt_cache_key: None,
68            context_window: None,
69            model_settings: ModelSettings::default(),
70        }
71    }
72
73    /// Create a builder from a resolved `AgentSpec`.
74    ///
75    /// The LLM provider is derived from `spec.model` via `ModelProviderParser`.
76    /// `base_prompts` are prepended before the spec's own prompts.
77    pub async fn from_spec(
78        spec: &AgentSpec,
79        base_prompts: Vec<Prompt>,
80        oauth_store: Option<Arc<dyn OAuthCredentialStorage>>,
81    ) -> Result<Self> {
82        let parser = ModelProviderParser::default().with_provider_connections(spec.provider_connections.clone());
83        let parser = match oauth_store {
84            Some(store) => parser.with_codex_provider(store),
85            None => parser,
86        };
87        let (provider, _) = parser.parse(&spec.model).await?;
88        let mut builder = Self::new(Arc::from(provider))
89            .context_window(spec.context_window)
90            .model_settings(spec.model_settings.clone());
91
92        for prompt in base_prompts {
93            builder = builder.system_prompt(prompt);
94        }
95
96        for prompt in &spec.prompts {
97            builder = builder.system_prompt(prompt.clone());
98        }
99
100        Ok(builder)
101    }
102
103    /// Add a prompt to the system prompt.
104    ///
105    /// Multiple prompts are concatenated with double newlines.
106    pub fn system_prompt(mut self, prompt: Prompt) -> Self {
107        self.prompts.push(prompt);
108        self
109    }
110
111    pub fn tools(mut self, tx: Sender<McpCommand>, tools: Vec<ToolDefinition>) -> Self {
112        self.tool_definitions = tools;
113        self.mcp_tx = Some(tx);
114        self
115    }
116
117    /// Set the timeout for tool execution
118    ///
119    /// If a tool does not return a result within this duration, it will be marked as failed
120    /// and the agent will continue processing.
121    ///
122    /// Default: 20 minutes
123    pub fn tool_timeout(mut self, timeout: Duration) -> Self {
124        self.tool_timeout = timeout;
125        self
126    }
127
128    /// Configure context compaction settings.
129    ///
130    /// By default, agents automatically compact context when token usage exceeds
131    /// 85% of the context window, preventing overflow during long-running tasks.
132    ///
133    /// # Examples
134    /// ```ignore
135    /// // Custom threshold
136    /// agent(llm).compaction(CompactionConfig::with_threshold(0.9))
137    ///
138    /// // Disable compaction entirely
139    /// agent(llm).compaction(CompactionConfig::disabled())
140    ///
141    /// // Full customization
142    /// agent(llm).compaction(
143    ///     CompactionConfig::with_threshold(0.85)
144    ///         .keep_recent_tool_results(3)
145    ///         .min_messages(20)
146    /// )
147    /// ```
148    pub fn compaction(mut self, config: CompactionConfig) -> Self {
149        self.compaction_config = Some(config);
150        self
151    }
152
153    /// Disable context compaction entirely.
154    ///
155    /// Overflow errors from the model will be surfaced directly to callers.
156    pub fn disable_compaction(mut self) -> Self {
157        self.compaction_config = None;
158        self
159    }
160
161    /// Configure the maximum number of auto-continue attempts.
162    ///
163    /// When the LLM stops without making tool calls, the agent may inject a
164    /// continuation prompt and restart the LLM stream for resumable stop
165    /// reasons (for example, token length limits).
166    ///
167    /// This setting limits how many times the agent will attempt to continue
168    /// before giving up and returning `AgentMessage::Done`.
169    ///
170    /// Default: 3
171    ///
172    /// # Example
173    /// ```ignore
174    /// // Allow up to 5 auto-continue attempts
175    /// agent(llm).max_auto_continues(5)
176    ///
177    /// // Disable auto-continue entirely
178    /// agent(llm).max_auto_continues(0)
179    /// ```
180    pub fn max_auto_continues(mut self, max: u32) -> Self {
181        self.max_auto_continues = max;
182        self
183    }
184
185    /// Configure retry behavior for transient LLM provider failures.
186    pub fn retry(mut self, config: RetryConfig) -> Self {
187        self.retry_config = config;
188        self
189    }
190
191    /// Set a prompt cache key for LLM provider request routing.
192    ///
193    /// This is typically a session ID (UUID) that remains stable across all
194    /// turns within a conversation, improving prompt cache hit rates.
195    pub fn prompt_cache_key(mut self, key: String) -> Self {
196        self.prompt_cache_key = Some(key);
197        self
198    }
199
200    /// Override the effective model context window in tokens.
201    pub fn context_window(mut self, context_window: Option<u32>) -> Self {
202        self.context_window = context_window;
203        self
204    }
205
206    /// Set the sampling controls (`temperature`, `top_p`, `max_tokens`) applied to
207    /// every model call this agent makes.
208    pub fn model_settings(mut self, model_settings: ModelSettings) -> Self {
209        self.model_settings = model_settings;
210        self
211    }
212
213    /// Pre-populate the context with conversation history (e.g. from a restored session).
214    ///
215    /// These messages are inserted after the system prompt.
216    pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
217        self.initial_messages = messages;
218        self
219    }
220
221    pub async fn spawn(self) -> Result<(Sender<Command>, Receiver<AgentMessage>, AgentHandle)> {
222        let mut prompt_cache = PromptCache::new(self.prompts);
223        let system_content = prompt_cache.render().await?;
224
225        let mut messages = Vec::new();
226        if !system_content.is_empty() {
227            messages.push(ChatMessage::System { content: system_content, timestamp: IsoString::now() });
228        }
229
230        messages.extend(self.initial_messages);
231
232        let (command_tx, command_rx) = mpsc::channel::<Command>(self.channel_capacity);
233
234        let (message_tx, agent_message_rx) = mpsc::channel::<AgentMessage>(self.channel_capacity);
235
236        let mut context = Context::new(messages, self.tool_definitions);
237        context.set_prompt_cache_key(self.prompt_cache_key);
238        context.set_model_settings(self.model_settings);
239
240        let config = AgentConfig {
241            llm: self.llm,
242            context,
243            mcp_command_tx: self.mcp_tx,
244            tool_timeout: self.tool_timeout,
245            compaction_config: self.compaction_config,
246            auto_continue: AutoContinue::new(self.max_auto_continues),
247            retry_config: self.retry_config,
248            context_window: self.context_window,
249            prompt_cache,
250        };
251
252        let agent = Agent::new(config, command_rx, message_tx);
253
254        let agent_handle = tokio::spawn(agent.run());
255
256        Ok((command_tx, agent_message_rx, AgentHandle { handle: agent_handle }))
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::agent_spec::{AgentSpecExposure, ToolFilter};
264    use crate::events::{AgentCommand, UserCommand};
265    use llm::testing::FakeLlmProvider;
266    use llm::{LlmResponse, ProviderConnectionOverrides};
267
268    #[tokio::test]
269    async fn test_agent_handle_is_finished() {
270        let handle = AgentHandle { handle: tokio::spawn(async {}) };
271        handle.await_completion().await;
272    }
273
274    #[tokio::test]
275    async fn test_agent_handle_abort() {
276        let handle = AgentHandle {
277            handle: tokio::spawn(async {
278                tokio::time::sleep(Duration::from_mins(1)).await;
279            }),
280        };
281        assert!(!handle.is_finished());
282        handle.abort();
283        // Give the runtime a moment to process the abort
284        tokio::time::sleep(Duration::from_millis(10)).await;
285        assert!(handle.is_finished());
286    }
287
288    #[tokio::test]
289    async fn context_window_override_supplies_unknown_provider_limit() {
290        let llm = Arc::new(FakeLlmProvider::with_single_response(vec![
291            LlmResponse::start("msg"),
292            LlmResponse::usage(100_000, 10),
293            LlmResponse::done(),
294        ]));
295
296        let (tx, mut rx, handle) = AgentBuilder::new(llm).context_window(Some(200_000)).spawn().await.unwrap();
297        tx.send(Command::UserCommand(UserCommand::Text { content: vec![llm::ContentBlock::text("hello")] }))
298            .await
299            .unwrap();
300
301        let update = next_context_usage(&mut rx).await;
302        assert_eq!(update.context_limit, Some(200_000));
303        assert_eq!(update.usage_ratio, Some(0.5));
304        assert_eq!(update.input_tokens, 100_000);
305        handle.abort();
306    }
307
308    #[tokio::test]
309    async fn context_window_override_beats_provider_limit() {
310        let llm = Arc::new(
311            FakeLlmProvider::with_single_response(vec![
312                LlmResponse::start("msg"),
313                LlmResponse::usage(100_000, 10),
314                LlmResponse::done(),
315            ])
316            .with_context_window(Some(128_000)),
317        );
318
319        let (tx, mut rx, handle) = AgentBuilder::new(llm).context_window(Some(200_000)).spawn().await.unwrap();
320        tx.send(Command::UserCommand(UserCommand::Text { content: vec![llm::ContentBlock::text("hello")] }))
321            .await
322            .unwrap();
323
324        let update = next_context_usage(&mut rx).await;
325        assert_eq!(update.context_limit, Some(200_000));
326        assert_eq!(update.usage_ratio, Some(0.5));
327        handle.abort();
328    }
329
330    #[tokio::test]
331    async fn context_window_override_survives_model_switch() {
332        let llm = Arc::new(FakeLlmProvider::new(vec![]).with_context_window(Some(128_000)));
333        let (tx, mut rx, handle) = AgentBuilder::new(llm).context_window(Some(200_000)).spawn().await.unwrap();
334
335        tx.send(Command::AgentCommand(AgentCommand::SwitchModel(Box::new(
336            FakeLlmProvider::new(vec![]).with_display_name("new fake").with_context_window(Some(32_000)),
337        ))))
338        .await
339        .unwrap();
340
341        let update = next_context_usage(&mut rx).await;
342        assert_eq!(update.context_limit, Some(200_000));
343        assert_eq!(update.usage_ratio, Some(0.0));
344        handle.abort();
345    }
346
347    async fn next_context_usage(rx: &mut Receiver<AgentMessage>) -> ContextUsageUpdate {
348        loop {
349            if let AgentMessage::ContextUsageUpdate { usage } =
350                rx.recv().await.expect("agent should emit context usage")
351            {
352                return ContextUsageUpdate {
353                    usage_ratio: usage.usage_ratio,
354                    context_limit: usage.context_limit,
355                    input_tokens: usage.input_tokens,
356                };
357            }
358        }
359    }
360
361    struct ContextUsageUpdate {
362        usage_ratio: Option<f64>,
363        context_limit: Option<u32>,
364        input_tokens: u32,
365    }
366
367    #[tokio::test]
368    async fn system_prompt_preserves_add_order() {
369        let builder = AgentBuilder::new(Arc::new(llm::testing::FakeLlmProvider::new(vec![])))
370            .system_prompt(Prompt::text("first"))
371            .system_prompt(Prompt::text("second"))
372            .system_prompt(Prompt::text("third"));
373
374        let rendered = Prompt::build_all(&builder.prompts).await.unwrap();
375
376        assert_eq!(rendered, "first\n\nsecond\n\nthird");
377    }
378
379    #[tokio::test]
380    async fn from_spec_applies_context_window_and_model_settings() {
381        let settings = ModelSettings { temperature: Some(0.0), max_tokens: Some(128), ..Default::default() };
382        let spec = AgentSpec {
383            name: "alloy".to_string(),
384            description: "alloy".to_string(),
385            model: "ollama:llama3.2,llamacpp:local".to_string(),
386            reasoning_effort: None,
387            model_settings: settings.clone(),
388            context_window: Some(200_000),
389            prompts: vec![],
390            provider_connections: ProviderConnectionOverrides::default(),
391            mcp_config_sources: Vec::new(),
392            exposure: AgentSpecExposure::both(),
393            tools: ToolFilter::default(),
394        };
395
396        let builder = AgentBuilder::from_spec(&spec, vec![], None).await.unwrap();
397
398        assert_eq!(builder.context_window, Some(200_000));
399        assert_eq!(builder.model_settings, settings);
400    }
401
402    #[tokio::test]
403    async fn spawn_applies_model_settings_to_context() {
404        let fake = FakeLlmProvider::with_single_response(vec![
405            LlmResponse::start("msg"),
406            LlmResponse::usage(10, 10),
407            LlmResponse::done(),
408        ]);
409
410        let captured = fake.captured_contexts();
411        let settings = ModelSettings { temperature: Some(0.0), max_tokens: Some(64), ..Default::default() };
412
413        let (tx, mut rx, handle) =
414            AgentBuilder::new(Arc::new(fake)).model_settings(settings.clone()).spawn().await.unwrap();
415
416        tx.send(Command::UserCommand(UserCommand::Text { content: vec![llm::ContentBlock::text("hello")] }))
417            .await
418            .unwrap();
419
420        let _ = next_context_usage(&mut rx).await;
421        let contexts = captured.lock().unwrap();
422
423        assert_eq!(contexts[0].model_settings(), &settings);
424        handle.abort();
425    }
426
427    #[tokio::test]
428    async fn from_spec_accepts_alloy_model_specs() {
429        let spec = AgentSpec {
430            name: "alloy".to_string(),
431            description: "alloy".to_string(),
432            model: "ollama:llama3.2,llamacpp:local".to_string(),
433            reasoning_effort: None,
434            model_settings: ModelSettings::default(),
435            context_window: None,
436            prompts: vec![],
437            provider_connections: ProviderConnectionOverrides::default(),
438            mcp_config_sources: Vec::new(),
439            exposure: AgentSpecExposure::both(),
440            tools: ToolFilter::default(),
441        };
442
443        let builder = AgentBuilder::from_spec(&spec, vec![], None).await;
444        assert!(builder.is_ok());
445    }
446}