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
16pub struct AgentHandle {
18 handle: JoinHandle<()>,
19}
20
21impl AgentHandle {
22 pub fn abort(&self) {
24 self.handle.abort();
25 }
26
27 pub fn is_finished(&self) -> bool {
29 self.handle.is_finished()
30 }
31
32 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 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 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 pub fn tool_timeout(mut self, timeout: Duration) -> Self {
124 self.tool_timeout = timeout;
125 self
126 }
127
128 pub fn compaction(mut self, config: CompactionConfig) -> Self {
149 self.compaction_config = Some(config);
150 self
151 }
152
153 pub fn disable_compaction(mut self) -> Self {
157 self.compaction_config = None;
158 self
159 }
160
161 pub fn max_auto_continues(mut self, max: u32) -> Self {
181 self.max_auto_continues = max;
182 self
183 }
184
185 pub fn retry(mut self, config: RetryConfig) -> Self {
187 self.retry_config = config;
188 self
189 }
190
191 pub fn prompt_cache_key(mut self, key: String) -> Self {
196 self.prompt_cache_key = Some(key);
197 self
198 }
199
200 pub fn context_window(mut self, context_window: Option<u32>) -> Self {
202 self.context_window = context_window;
203 self
204 }
205
206 pub fn model_settings(mut self, model_settings: ModelSettings) -> Self {
209 self.model_settings = model_settings;
210 self
211 }
212
213 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 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}