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, Result};
5use crate::events::{AgentMessage, UserMessage};
6use crate::mcp::run_mcp_task::McpCommand;
7use aether_auth::OAuthCredentialStorage;
8use llm::parser::ModelProviderParser;
9use llm::types::IsoString;
10use llm::{ChatMessage, Context, 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}
51
52impl AgentBuilder {
53    pub fn new(llm: Arc<dyn StreamingModelProvider>) -> Self {
54        Self {
55            llm,
56            prompts: Vec::new(),
57            tool_definitions: Vec::new(),
58            initial_messages: Vec::new(),
59            mcp_tx: None,
60            channel_capacity: 1000,
61            tool_timeout: Duration::from_mins(20),
62            compaction_config: Some(CompactionConfig::default()),
63            max_auto_continues: 3,
64            retry_config: RetryConfig::default(),
65            prompt_cache_key: None,
66        }
67    }
68
69    /// Create a builder from a resolved `AgentSpec`.
70    ///
71    /// The LLM provider is derived from `spec.model` via `ModelProviderParser`.
72    /// `base_prompts` are prepended before the spec's own prompts.
73    pub async fn from_spec(
74        spec: &AgentSpec,
75        base_prompts: Vec<Prompt>,
76        oauth_store: Option<Arc<dyn OAuthCredentialStorage>>,
77    ) -> Result<Self> {
78        let parser = ModelProviderParser::default();
79        let parser = match oauth_store {
80            Some(store) => parser.with_codex_provider(store),
81            None => parser,
82        };
83        let (provider, _) = parser.parse(&spec.model).await?;
84        let mut builder = Self::new(Arc::from(provider));
85        for prompt in base_prompts {
86            builder = builder.system_prompt(prompt);
87        }
88        for prompt in &spec.prompts {
89            builder = builder.system_prompt(prompt.clone());
90        }
91        Ok(builder)
92    }
93
94    /// Add a prompt to the system prompt.
95    ///
96    /// Multiple prompts are concatenated with double newlines.
97    pub fn system_prompt(mut self, prompt: Prompt) -> Self {
98        self.prompts.push(prompt);
99        self
100    }
101
102    pub fn tools(mut self, tx: Sender<McpCommand>, tools: Vec<ToolDefinition>) -> Self {
103        self.tool_definitions = tools;
104        self.mcp_tx = Some(tx);
105        self
106    }
107
108    /// Set the timeout for tool execution
109    ///
110    /// If a tool does not return a result within this duration, it will be marked as failed
111    /// and the agent will continue processing.
112    ///
113    /// Default: 20 minutes
114    pub fn tool_timeout(mut self, timeout: Duration) -> Self {
115        self.tool_timeout = timeout;
116        self
117    }
118
119    /// Configure context compaction settings.
120    ///
121    /// By default, agents automatically compact context when token usage exceeds
122    /// 85% of the context window, preventing overflow during long-running tasks.
123    ///
124    /// # Examples
125    /// ```ignore
126    /// // Custom threshold
127    /// agent(llm).compaction(CompactionConfig::with_threshold(0.9))
128    ///
129    /// // Disable compaction entirely
130    /// agent(llm).compaction(CompactionConfig::disabled())
131    ///
132    /// // Full customization
133    /// agent(llm).compaction(
134    ///     CompactionConfig::with_threshold(0.85)
135    ///         .keep_recent_tool_results(3)
136    ///         .min_messages(20)
137    /// )
138    /// ```
139    pub fn compaction(mut self, config: CompactionConfig) -> Self {
140        self.compaction_config = Some(config);
141        self
142    }
143
144    /// Disable context compaction entirely.
145    ///
146    /// Overflow errors from the model will be surfaced directly to callers.
147    pub fn disable_compaction(mut self) -> Self {
148        self.compaction_config = None;
149        self
150    }
151
152    /// Configure the maximum number of auto-continue attempts.
153    ///
154    /// When the LLM stops without making tool calls, the agent may inject a
155    /// continuation prompt and restart the LLM stream for resumable stop
156    /// reasons (for example, token length limits).
157    ///
158    /// This setting limits how many times the agent will attempt to continue
159    /// before giving up and returning `AgentMessage::Done`.
160    ///
161    /// Default: 3
162    ///
163    /// # Example
164    /// ```ignore
165    /// // Allow up to 5 auto-continue attempts
166    /// agent(llm).max_auto_continues(5)
167    ///
168    /// // Disable auto-continue entirely
169    /// agent(llm).max_auto_continues(0)
170    /// ```
171    pub fn max_auto_continues(mut self, max: u32) -> Self {
172        self.max_auto_continues = max;
173        self
174    }
175
176    /// Configure retry behavior for transient LLM provider failures.
177    pub fn retry(mut self, config: RetryConfig) -> Self {
178        self.retry_config = config;
179        self
180    }
181
182    /// Set a prompt cache key for LLM provider request routing.
183    ///
184    /// This is typically a session ID (UUID) that remains stable across all
185    /// turns within a conversation, improving prompt cache hit rates.
186    pub fn prompt_cache_key(mut self, key: String) -> Self {
187        self.prompt_cache_key = Some(key);
188        self
189    }
190
191    /// Pre-populate the context with conversation history (e.g. from a restored session).
192    ///
193    /// These messages are inserted after the system prompt.
194    pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
195        self.initial_messages = messages;
196        self
197    }
198
199    pub async fn spawn(self) -> Result<(Sender<UserMessage>, Receiver<AgentMessage>, AgentHandle)> {
200        let mut messages = Vec::new();
201
202        if !self.prompts.is_empty() {
203            let system_content = Prompt::build_all(&self.prompts).await?;
204            if !system_content.is_empty() {
205                messages.push(ChatMessage::System { content: system_content, timestamp: IsoString::now() });
206            }
207        }
208
209        messages.extend(self.initial_messages);
210
211        let (user_message_tx, user_message_rx) = mpsc::channel::<UserMessage>(self.channel_capacity);
212
213        let (message_tx, agent_message_rx) = mpsc::channel::<AgentMessage>(self.channel_capacity);
214
215        let mut context = Context::new(messages, self.tool_definitions);
216        context.set_prompt_cache_key(self.prompt_cache_key);
217
218        let config = AgentConfig {
219            llm: self.llm,
220            context,
221            mcp_command_tx: self.mcp_tx,
222            tool_timeout: self.tool_timeout,
223            compaction_config: self.compaction_config,
224            auto_continue: AutoContinue::new(self.max_auto_continues),
225            retry_config: self.retry_config,
226        };
227
228        let agent = Agent::new(config, user_message_rx, message_tx);
229
230        let agent_handle = tokio::spawn(agent.run());
231
232        Ok((user_message_tx, agent_message_rx, AgentHandle { handle: agent_handle }))
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::agent_spec::{AgentSpecExposure, ToolFilter};
240
241    #[tokio::test]
242    async fn test_agent_handle_is_finished() {
243        let handle = AgentHandle { handle: tokio::spawn(async {}) };
244        handle.await_completion().await;
245    }
246
247    #[tokio::test]
248    async fn test_agent_handle_abort() {
249        let handle = AgentHandle {
250            handle: tokio::spawn(async {
251                tokio::time::sleep(Duration::from_mins(1)).await;
252            }),
253        };
254        assert!(!handle.is_finished());
255        handle.abort();
256        // Give the runtime a moment to process the abort
257        tokio::time::sleep(Duration::from_millis(10)).await;
258        assert!(handle.is_finished());
259    }
260
261    #[tokio::test]
262    async fn system_prompt_preserves_add_order() {
263        let builder = AgentBuilder::new(Arc::new(llm::testing::FakeLlmProvider::new(vec![])))
264            .system_prompt(Prompt::text("first"))
265            .system_prompt(Prompt::text("second"))
266            .system_prompt(Prompt::text("third"));
267
268        let rendered = Prompt::build_all(&builder.prompts).await.unwrap();
269
270        assert_eq!(rendered, "first\n\nsecond\n\nthird");
271    }
272
273    #[tokio::test]
274    async fn from_spec_accepts_alloy_model_specs() {
275        let spec = AgentSpec {
276            name: "alloy".to_string(),
277            description: "alloy".to_string(),
278            model: "ollama:llama3.2,llamacpp:local".to_string(),
279            reasoning_effort: None,
280            prompts: vec![],
281            mcp_config_sources: Vec::new(),
282            exposure: AgentSpecExposure::both(),
283            tools: ToolFilter::default(),
284        };
285
286        let builder = AgentBuilder::from_spec(&spec, vec![], None).await;
287        assert!(builder.is_ok());
288    }
289}