1use super::agent::{AgentConfig, AutoContinue, RetryConfig};
2use crate::agent_spec::AgentSpec;
3use crate::context::{CompactionConfig, SessionUsageTracker};
4use crate::core::{Agent, AgentDeps, Prompt, PromptCache, Result};
5use crate::events::{AgentEvent, AgentObserver, Command};
6use crate::mcp::McpHandle;
7use llm::parser::ModelProviderParser;
8use llm::types::IsoString;
9use llm::{ChatMessage, Context, ModelSettings, SessionUsageEvent, StreamingModelProvider, ToolDefinition};
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::sync::mpsc::{self, Receiver, Sender};
13use tokio::task::JoinHandle;
14
15pub struct AgentHandle {
17 handle: JoinHandle<()>,
18}
19
20impl AgentHandle {
21 pub fn abort(&self) {
23 self.handle.abort();
24 }
25
26 pub fn is_finished(&self) -> bool {
28 self.handle.is_finished()
29 }
30
31 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: Option<McpHandle>,
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 session_usage: SessionUsageTracker,
52 session_affinity_key: String,
53}
54
55impl AgentBuilder {
56 pub fn new(llm: Arc<dyn StreamingModelProvider>) -> Self {
57 Self {
58 llm,
59 prompts: Vec::new(),
60 tool_definitions: Vec::new(),
61 initial_messages: Vec::new(),
62 mcp: None,
63 channel_capacity: 1000,
64 tool_timeout: Duration::from_mins(60),
65 compaction_config: Some(CompactionConfig::default()),
66 max_auto_continues: 3,
67 retry_config: RetryConfig::default(),
68 context_window: None,
69 model_settings: ModelSettings::default(),
70 observers: Vec::new(),
71 session_usage: SessionUsageTracker::new("agent"),
72 session_affinity_key: uuid::Uuid::new_v4().to_string(),
73 }
74 }
75
76 pub async fn from_spec(spec: &AgentSpec, base_prompts: Vec<Prompt>, deps: &AgentDeps) -> Result<Self> {
81 let parser = ModelProviderParser::default().with_provider_connections(spec.provider_connections.clone());
82 let parser = match deps.oauth_credential_store.clone() {
83 Some(store) => parser.with_codex_provider(store),
84 None => parser,
85 };
86 let (provider, _) = parser.parse(&spec.model).await?;
87 let mut builder = Self::new(Arc::from(provider))
88 .context_window(spec.context_window)
89 .model_settings(spec.model_settings.clone())
90 .session_usage(SessionUsageTracker::new(&spec.name));
91
92 if let Some(key) = &deps.session_affinity_key {
93 builder = builder.session_affinity_key(key.clone());
94 }
95 if let Some(observer) = deps.observer(&spec.name) {
96 builder = builder.observer(observer);
97 }
98
99 for prompt in base_prompts {
100 builder = builder.system_prompt(prompt);
101 }
102
103 for prompt in &spec.prompts {
104 builder = builder.system_prompt(prompt.clone());
105 }
106
107 Ok(builder)
108 }
109
110 pub fn system_prompt(mut self, prompt: Prompt) -> Self {
114 self.prompts.push(prompt);
115 self
116 }
117
118 pub fn tools(mut self, mcp: McpHandle, tools: Vec<ToolDefinition>) -> Self {
119 self.tool_definitions = tools;
120 self.mcp = Some(mcp);
121 self
122 }
123
124 pub fn tool_timeout(mut self, timeout: Duration) -> Self {
131 self.tool_timeout = timeout;
132 self
133 }
134
135 pub fn compaction(mut self, config: CompactionConfig) -> Self {
156 self.compaction_config = Some(config);
157 self
158 }
159
160 pub fn disable_compaction(mut self) -> Self {
164 self.compaction_config = None;
165 self
166 }
167
168 pub fn max_auto_continues(mut self, max: u32) -> Self {
188 self.max_auto_continues = max;
189 self
190 }
191
192 pub fn retry(mut self, config: RetryConfig) -> Self {
194 self.retry_config = config;
195 self
196 }
197
198 pub fn context_window(mut self, context_window: Option<u32>) -> Self {
200 self.context_window = context_window;
201 self
202 }
203
204 pub fn model_settings(mut self, model_settings: ModelSettings) -> Self {
207 self.model_settings = model_settings;
208 self
209 }
210
211 pub fn session_affinity_key(mut self, key: impl Into<String>) -> Self {
212 self.session_affinity_key = key.into();
213 self
214 }
215
216 pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
220 self.initial_messages = messages;
221 self
222 }
223
224 pub fn observer(mut self, observer: Box<dyn AgentObserver>) -> Self {
226 self.observers.push(observer);
227 self
228 }
229
230 pub fn session_usage(mut self, tracker: SessionUsageTracker) -> Self {
232 self.session_usage = tracker;
233 self
234 }
235
236 pub fn resume_usage(mut self, last: &SessionUsageEvent) -> Self {
239 self.session_usage.resume_from(last);
240 self
241 }
242
243 pub async fn spawn(self) -> Result<(Sender<Command>, Receiver<AgentEvent>, AgentHandle)> {
244 let mut prompt_cache = PromptCache::new(self.prompts);
245 let system_content = prompt_cache.render().await?;
246 let mut messages = Vec::new();
247
248 if !system_content.is_empty() {
249 messages.push(ChatMessage::System { content: system_content, timestamp: IsoString::now() });
250 }
251
252 messages.extend(self.initial_messages);
253 let (command_tx, command_rx) = mpsc::channel::<Command>(self.channel_capacity);
254 let (message_tx, agent_event_rx) = mpsc::channel::<AgentEvent>(self.channel_capacity);
255 let mut context = Context::new(messages, self.tool_definitions);
256 context.set_model_settings(self.model_settings);
257 context.set_session_affinity_key(Some(self.session_affinity_key));
258
259 let config = AgentConfig {
260 llm: self.llm,
261 context,
262 mcp: self.mcp,
263 tool_timeout: self.tool_timeout,
264 compaction_config: self.compaction_config,
265 auto_continue: AutoContinue::new(self.max_auto_continues),
266 retry_config: self.retry_config,
267 context_window: self.context_window,
268 prompt_cache,
269 observers: self.observers,
270 session_usage: self.session_usage,
271 };
272
273 let agent = Agent::new(config, command_rx, message_tx);
274 let agent_handle = tokio::spawn(agent.run());
275
276 Ok((command_tx, agent_event_rx, AgentHandle { handle: agent_handle }))
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::agent_spec::AgentSpecExposure;
284 use llm::ProviderConnectionOverrides;
285 use mcp_utils::client::ToolFilter;
286
287 #[tokio::test]
288 async fn test_agent_handle_is_finished() {
289 let handle = AgentHandle { handle: tokio::spawn(async {}) };
290 handle.await_completion().await;
291 }
292
293 #[tokio::test]
294 async fn test_agent_handle_abort() {
295 let handle = AgentHandle { handle: tokio::spawn(std::future::pending::<()>()) };
296 assert!(!handle.is_finished());
297 handle.abort();
298 while !handle.is_finished() {
299 tokio::task::yield_now().await;
300 }
301 }
302
303 #[tokio::test]
304 async fn system_prompt_preserves_add_order() {
305 let builder = AgentBuilder::new(Arc::new(llm::testing::FakeLlmProvider::new(vec![])))
306 .system_prompt(Prompt::text("first"))
307 .system_prompt(Prompt::text("second"))
308 .system_prompt(Prompt::text("third"));
309
310 let rendered = Prompt::build_all(&builder.prompts).await.unwrap();
311
312 assert_eq!(rendered, "first\n\nsecond\n\nthird");
313 }
314
315 #[tokio::test]
316 async fn from_spec_applies_context_window_and_model_settings() {
317 let settings = ModelSettings { temperature: Some(0.0), max_tokens: Some(128), ..Default::default() };
318 let spec = AgentSpec {
319 name: "alloy".to_string(),
320 description: "alloy".to_string(),
321 model: "ollama:llama3.2,llamacpp:local".to_string(),
322 reasoning_effort: None,
323 model_settings: settings.clone(),
324 context_window: Some(200_000),
325 prompts: vec![],
326 provider_connections: ProviderConnectionOverrides::default(),
327 mcp_config_sources: Vec::new(),
328 exposure: AgentSpecExposure::both(),
329 tools: ToolFilter::default(),
330 };
331
332 let dependencies = AgentDeps::default().with_session_affinity_key("conversation-123");
333 let builder = AgentBuilder::from_spec(&spec, vec![], &dependencies).await.unwrap();
334
335 assert_eq!(builder.context_window, Some(200_000));
336 assert_eq!(builder.model_settings, settings);
337 assert_eq!(builder.session_affinity_key, "conversation-123");
338 }
339
340 #[tokio::test]
341 async fn from_spec_accepts_alloy_model_specs() {
342 let spec = AgentSpec {
343 name: "alloy".to_string(),
344 description: "alloy".to_string(),
345 model: "ollama:llama3.2,llamacpp:local".to_string(),
346 reasoning_effort: None,
347 model_settings: ModelSettings::default(),
348 context_window: None,
349 prompts: vec![],
350 provider_connections: ProviderConnectionOverrides::default(),
351 mcp_config_sources: Vec::new(),
352 exposure: AgentSpecExposure::both(),
353 tools: ToolFilter::default(),
354 };
355
356 let builder = AgentBuilder::from_spec(&spec, vec![], &AgentDeps::default()).await;
357 assert!(builder.is_ok());
358 }
359}