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