1use std::fs;
10use std::path::Path;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result};
15
16use crate::config::models::Provider;
17use crate::config::types::{AgentConfig, SessionInfo};
18use crate::models_manager::ModelsManager;
19use crate::utils::error_messages::{ERR_CREATE_DIR, ERR_GET_METADATA};
20
21use crate::core::decision_tracker::DecisionTracker;
22use crate::core::error_recovery::ErrorRecoveryManager;
23use crate::ctx_err;
24use crate::llm::client::{AnyClient, ProviderClientAdapter};
25use crate::llm::factory::{ProviderConfig, create_provider_with_config, infer_provider_from_model};
26use crate::tools::ToolRegistry;
27use tracing::warn;
28
29pub struct AgentComponentSet {
35 pub client: AnyClient,
36 pub tool_registry: Arc<ToolRegistry>,
37 pub decision_tracker: DecisionTracker,
38 pub error_recovery: ErrorRecoveryManager,
39 pub models_manager: Arc<ModelsManager>,
40
41 pub session_info: SessionInfo,
42}
43
44pub struct AgentComponentBuilder<'config> {
49 config: &'config AgentConfig,
50 client: Option<AnyClient>,
51 tool_registry: Option<Arc<ToolRegistry>>,
52 decision_tracker: Option<DecisionTracker>,
53 error_recovery: Option<ErrorRecoveryManager>,
54 models_manager: Option<Arc<ModelsManager>>,
55
56 session_info: Option<SessionInfo>,
57}
58
59impl<'config> AgentComponentBuilder<'config> {
60 pub fn new(config: &'config AgentConfig) -> Self {
62 Self {
63 config,
64 client: None,
65 tool_registry: None,
66 decision_tracker: None,
67 error_recovery: None,
68 models_manager: None,
69 session_info: None,
70 }
71 }
72
73 pub fn with_client(mut self, client: AnyClient) -> Self {
75 self.client = Some(client);
76 self
77 }
78
79 pub fn with_tool_registry(mut self, registry: Arc<ToolRegistry>) -> Self {
81 self.tool_registry = Some(registry);
82 self
83 }
84
85 pub fn with_decision_tracker(mut self, tracker: DecisionTracker) -> Self {
87 self.decision_tracker = Some(tracker);
88 self
89 }
90
91 pub fn with_error_recovery(mut self, manager: ErrorRecoveryManager) -> Self {
93 self.error_recovery = Some(manager);
94 self
95 }
96
97 pub fn with_models_manager(mut self, manager: Arc<ModelsManager>) -> Self {
99 self.models_manager = Some(manager);
100 self
101 }
102
103 pub fn with_session_info(mut self, session_info: SessionInfo) -> Self {
105 self.session_info = Some(session_info);
106 self
107 }
108
109 pub async fn build(mut self) -> Result<AgentComponentSet> {
111 ensure_workspace_ready(&self.config.workspace)?;
112
113 let client = match self.client.take() {
114 Some(client) => client,
115 None => create_llm_client(self.config)?,
116 };
117
118 let session_info = match self.session_info.take() {
119 Some(info) => info,
120 None => create_session_info()
121 .context("Failed to initialize agent session metadata for bootstrap")?,
122 };
123
124 let tool_registry = match self.tool_registry {
125 Some(registry) => registry,
126 None => {
127 let registry = ToolRegistry::new(self.config.workspace.clone()).await;
128 registry.set_harness_session(session_info.session_id.clone());
129 Arc::new(registry)
130 }
131 };
132
133 let models_manager = self.models_manager.take().unwrap_or_else(|| {
136 Arc::new(ModelsManager::with_provider(
138 self.config
139 .provider
140 .parse::<Provider>()
141 .ok()
142 .unwrap_or_default(),
143 ))
144 });
145
146 let decision_tracker = self.decision_tracker.unwrap_or_default();
147
148 let error_recovery = self.error_recovery.unwrap_or_default();
149
150 Ok(AgentComponentSet {
151 client,
152 tool_registry,
153 decision_tracker,
154 error_recovery,
155 models_manager,
156
157 session_info,
158 })
159 }
160}
161
162fn create_llm_client(config: &AgentConfig) -> Result<AnyClient> {
163 let provider_name = if config.provider.trim().is_empty() {
164 infer_provider_from_model(&config.model)
165 .map(|provider| provider.to_string())
166 .ok_or_else(|| {
167 anyhow::anyhow!("Cannot determine provider for model: {}", config.model)
168 })?
169 } else {
170 config.provider.to_lowercase()
171 };
172
173 let provider = create_provider_with_config(
174 &provider_name,
175 ProviderConfig {
176 api_key: Some(config.api_key.clone()),
177 openai_chatgpt_auth: config.openai_chatgpt_auth.clone(),
178 copilot_auth: None,
179 base_url: None,
180 model: Some(config.model.clone()),
181 prompt_cache: Some(config.prompt_cache.clone()),
182 timeouts: None,
183 openai: None,
184 anthropic: None,
185 model_behavior: config.model_behavior.clone(),
186 workspace_root: Some(config.workspace.clone()),
187 },
188 )
189 .with_context(|| format!("Failed to initialize provider '{}'", provider_name))?;
190
191 Ok(Box::new(ProviderClientAdapter::new(
192 provider,
193 config.model.clone(),
194 )))
195}
196
197fn create_session_info() -> Result<SessionInfo> {
198 let now = SystemTime::now()
199 .duration_since(UNIX_EPOCH)
200 .map_err(|err| err.duration());
201
202 Ok(build_session_info(now))
203}
204
205fn build_session_info(duration: Result<Duration, Duration>) -> SessionInfo {
206 let (start_time, session_id) = match duration {
207 Ok(duration) => {
208 let secs = duration.as_secs();
209 (secs, format!("session_{}", secs))
210 }
211 Err(delta) => {
212 let fallback = delta.as_secs();
213 warn!(
214 fallback_seconds = fallback,
215 "System time is before UNIX epoch; using fallback session id"
216 );
217 (fallback, format!("session_fallback_{}", fallback))
218 }
219 };
220
221 SessionInfo {
222 session_id,
223 start_time,
224 total_turns: 0,
225 total_decisions: 0,
226 error_count: 0,
227 }
228}
229
230fn ensure_workspace_ready(workspace_root: &Path) -> Result<()> {
231 if workspace_root.exists() {
232 let metadata = fs::metadata(workspace_root)
233 .with_context(|| ctx_err!(ERR_GET_METADATA, workspace_root.display()))?;
234
235 anyhow::ensure!(
236 metadata.is_dir(),
237 "Workspace path is not a directory: {}",
238 workspace_root.display()
239 );
240 } else {
241 fs::create_dir_all(workspace_root)
242 .with_context(|| ctx_err!(ERR_CREATE_DIR, workspace_root.display()))?;
243 }
244
245 Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::config::constants::models;
252 use crate::config::core::PromptCachingConfig;
253 use crate::config::models::Provider;
254 use crate::config::types::{ModelSelectionSource, ReasoningEffortLevel, UiSurfacePreference};
255 use crate::core::agent::snapshots::{
256 DEFAULT_CHECKPOINTS_ENABLED, DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SNAPSHOTS,
257 };
258 use std::collections::BTreeMap;
259
260 #[tokio::test]
261 async fn builds_default_component_set() {
262 let temp_dir = tempfile::tempdir().expect("temp dir");
263 let agent_config = AgentConfig {
264 model: models::google::GEMINI_3_FLASH_PREVIEW.to_string(),
265 api_key: "test-api-key".to_owned(),
266 provider: Provider::Gemini.to_string(),
267 api_key_env: Provider::Gemini.default_api_key_env().to_string(),
268 workspace: temp_dir.path().to_path_buf(),
269 verbose: false,
270 theme: "default".to_owned(),
271 reasoning_effort: ReasoningEffortLevel::default(),
272 ui_surface: UiSurfacePreference::Inline,
273 prompt_cache: PromptCachingConfig::default(),
274 model_source: ModelSelectionSource::WorkspaceConfig,
275 custom_api_keys: BTreeMap::new(),
276 checkpointing_enabled: DEFAULT_CHECKPOINTS_ENABLED,
277 checkpointing_storage_dir: None,
278 checkpointing_max_snapshots: DEFAULT_MAX_SNAPSHOTS,
279 checkpointing_max_age_days: Some(DEFAULT_MAX_AGE_DAYS),
280 quiet: false,
281 max_conversation_turns: 1000,
282 model_behavior: None,
283 openai_chatgpt_auth: None,
284 };
285
286 let components = AgentComponentBuilder::new(&agent_config)
287 .build()
288 .await
289 .expect("component build succeeds");
290
291 assert!(components.session_info.session_id.starts_with("session_"));
292 assert_eq!(components.session_info.total_turns, 0);
293 assert!(!components.tool_registry.available_tools().await.is_empty());
294 }
295
296 #[tokio::test]
297 async fn allows_overriding_components() {
298 let temp_dir = tempfile::tempdir().expect("temp dir");
299 let agent_config = AgentConfig {
300 model: models::google::GEMINI_3_FLASH_PREVIEW.to_string(),
301 api_key: "test-api-key".to_owned(),
302 provider: Provider::Gemini.to_string(),
303 api_key_env: Provider::Gemini.default_api_key_env().to_string(),
304 workspace: temp_dir.path().to_path_buf(),
305 verbose: true,
306 theme: "custom".to_owned(),
307 reasoning_effort: ReasoningEffortLevel::High,
308 ui_surface: UiSurfacePreference::Alternate,
309 prompt_cache: PromptCachingConfig::default(),
310 model_source: ModelSelectionSource::WorkspaceConfig,
311 custom_api_keys: BTreeMap::new(),
312 checkpointing_enabled: DEFAULT_CHECKPOINTS_ENABLED,
313 checkpointing_storage_dir: None,
314 checkpointing_max_snapshots: DEFAULT_MAX_SNAPSHOTS,
315 checkpointing_max_age_days: Some(DEFAULT_MAX_AGE_DAYS),
316 quiet: false,
317 max_conversation_turns: 1000,
318 model_behavior: None,
319 openai_chatgpt_auth: None,
320 };
321
322 let custom_session = SessionInfo {
323 session_id: "session_custom".to_owned(),
324 start_time: 42,
325 total_turns: 1,
326 total_decisions: 2,
327 error_count: 3,
328 };
329
330 let registry = Arc::new(ToolRegistry::new(agent_config.workspace.clone()).await);
331
332 let components = AgentComponentBuilder::new(&agent_config)
333 .with_session_info(custom_session.clone())
334 .with_tool_registry(Arc::clone(®istry))
335 .build()
336 .await
337 .expect("component build succeeds with overrides");
338
339 assert_eq!(
340 components.session_info.session_id,
341 custom_session.session_id
342 );
343 assert_eq!(
344 components.session_info.start_time,
345 custom_session.start_time
346 );
347 assert_eq!(
348 Arc::as_ptr(&components.tool_registry),
349 Arc::as_ptr(®istry)
350 );
351 }
352
353 #[test]
354 fn session_info_uses_fallback_when_clock_is_before_epoch() {
355 let info = build_session_info(Err(Duration::from_secs(42)));
356 assert_eq!(info.session_id, "session_fallback_42");
357 assert_eq!(info.start_time, 42);
358 assert_eq!(info.total_turns, 0);
359 }
360
361 #[tokio::test]
362 async fn rejects_non_directory_workspace() {
363 let temp_dir = tempfile::tempdir().expect("temp dir");
364 let file_path = temp_dir.path().join("not_dir");
365 fs::write(&file_path, "not a dir").expect("write file");
366
367 let agent_config = AgentConfig {
368 workspace: file_path.clone(),
369 ..sample_config(temp_dir.path())
370 };
371
372 let result = AgentComponentBuilder::new(&agent_config).build().await;
373 assert!(result.is_err(), "expected workspace validation to fail");
374 }
375
376 fn sample_config(workspace: &Path) -> AgentConfig {
377 AgentConfig {
378 model: models::google::GEMINI_3_FLASH_PREVIEW.to_string(),
379 api_key: "test-api-key".to_owned(),
380 provider: Provider::Gemini.to_string(),
381 api_key_env: Provider::Gemini.default_api_key_env().to_string(),
382 workspace: workspace.to_path_buf(),
383 verbose: false,
384 theme: "default".to_owned(),
385 reasoning_effort: ReasoningEffortLevel::default(),
386 ui_surface: UiSurfacePreference::Inline,
387 prompt_cache: PromptCachingConfig::default(),
388 model_source: ModelSelectionSource::WorkspaceConfig,
389 custom_api_keys: BTreeMap::new(),
390 checkpointing_enabled: DEFAULT_CHECKPOINTS_ENABLED,
391 checkpointing_storage_dir: None,
392 checkpointing_max_snapshots: DEFAULT_MAX_SNAPSHOTS,
393 checkpointing_max_age_days: Some(DEFAULT_MAX_AGE_DAYS),
394 quiet: false,
395 max_conversation_turns: 1000,
396 model_behavior: None,
397 openai_chatgpt_auth: None,
398 }
399 }
400}