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