Skip to main content

git_iris/agents/
setup.rs

1//! Agent setup service for Git-Iris
2//!
3//! This service handles all the setup and initialization for the agent framework,
4//! including configuration loading, client creation, and agent setup.
5
6use anyhow::Result;
7use std::sync::Arc;
8
9use crate::agents::context::TaskContext;
10use crate::agents::iris::StructuredResponse;
11use crate::agents::tools::with_active_repo_root;
12use crate::agents::{AgentBackend, IrisAgent, IrisAgentBuilder};
13use crate::common::CommonParams;
14use crate::config::Config;
15use crate::git::GitRepo;
16use crate::providers::Provider;
17
18/// Service for setting up agents with proper configuration
19pub struct AgentSetupService {
20    config: Config,
21    git_repo: Option<GitRepo>,
22}
23
24impl AgentSetupService {
25    /// Create a new setup service with the given configuration
26    #[must_use]
27    pub fn new(config: Config) -> Self {
28        Self {
29            config,
30            git_repo: None,
31        }
32    }
33
34    /// Create setup service from common parameters (following existing patterns)
35    ///
36    /// # Errors
37    ///
38    /// Returns an error when config loading, argument application, or repository setup fails.
39    pub fn from_common_params(
40        common_params: &CommonParams,
41        repository_url: Option<String>,
42    ) -> Result<Self> {
43        let mut config = Config::load()?;
44
45        // Apply common parameters to config (following existing pattern)
46        common_params.apply_to_config(&mut config)?;
47
48        let mut setup_service = Self::new(config);
49
50        // Setup git repo if needed
51        if let Some(repo_url) = repository_url {
52            // Handle remote repository setup (following existing pattern)
53            setup_service.git_repo = Some(GitRepo::new_from_url(Some(repo_url))?);
54        } else {
55            // Use local repository
56            setup_service.git_repo = Some(GitRepo::new(&std::env::current_dir()?)?);
57        }
58
59        Ok(setup_service)
60    }
61
62    /// Create a configured Iris agent
63    ///
64    /// # Errors
65    ///
66    /// Returns an error when provider validation or agent construction fails.
67    pub fn create_iris_agent(&mut self) -> Result<IrisAgent> {
68        let backend = AgentBackend::from_config(&self.config)?;
69        // Validate environment (API keys etc) before creating agent
70        self.validate_provider(&backend)?;
71
72        let mut agent = IrisAgentBuilder::new()
73            .with_provider(&backend.provider_name)
74            .with_model(&backend.model)
75            .build()?;
76
77        // Pass config and fast model to agent
78        agent.set_config(self.config.clone());
79        agent.set_fast_model(backend.fast_model);
80
81        Ok(agent)
82    }
83
84    /// Validate provider configuration (API keys etc)
85    fn validate_provider(&self, backend: &AgentBackend) -> Result<()> {
86        let provider: Provider = backend
87            .provider_name
88            .parse()
89            .map_err(|_| anyhow::anyhow!("Unsupported provider: {}", backend.provider_name))?;
90
91        // Check API key - from config or environment
92        let has_api_key = self
93            .config
94            .get_provider_config(provider.name())
95            .is_some_and(crate::providers::ProviderConfig::has_api_key);
96
97        if !has_api_key && std::env::var(provider.api_key_env()).is_err() {
98            return Err(anyhow::anyhow!(
99                "No API key found for {}. Set {} or configure in ~/.config/git-iris/config.toml",
100                provider.name(),
101                provider.api_key_env()
102            ));
103        }
104
105        Ok(())
106    }
107
108    /// Get the git repository instance
109    #[must_use]
110    pub fn git_repo(&self) -> Option<&GitRepo> {
111        self.git_repo.as_ref()
112    }
113
114    /// Get the configuration
115    #[must_use]
116    pub fn config(&self) -> &Config {
117        &self.config
118    }
119}
120
121/// High-level function to handle tasks with agents using a common pattern
122/// This is a convenience function that sets up an agent and executes a task
123///
124/// # Errors
125///
126/// Returns an error when setup, agent execution, or the caller-provided handler fails.
127pub async fn handle_with_agent<F, Fut, T>(
128    common_params: CommonParams,
129    repository_url: Option<String>,
130    capability: &str,
131    task_prompt: &str,
132    handler: F,
133) -> Result<T>
134where
135    F: FnOnce(crate::agents::iris::StructuredResponse) -> Fut,
136    Fut: std::future::Future<Output = Result<T>>,
137{
138    // Create setup service
139    let mut setup_service = AgentSetupService::from_common_params(&common_params, repository_url)?;
140
141    // Create agent
142    let mut agent = setup_service.create_iris_agent()?;
143
144    // Execute task with capability - now returns StructuredResponse
145    let result = agent.execute_task(capability, task_prompt).await?;
146
147    // Call the handler with the result
148    handler(result).await
149}
150
151/// Simple factory function for creating agents with minimal configuration
152///
153/// # Errors
154///
155/// Returns an error when the provider configuration is invalid or agent construction fails.
156pub fn create_agent_with_defaults(provider: &str, model: &str) -> Result<IrisAgent> {
157    IrisAgentBuilder::new()
158        .with_provider(provider)
159        .with_model(model)
160        .build()
161}
162
163/// Create an agent from environment variables
164///
165/// # Errors
166///
167/// Returns an error when agent construction fails.
168pub fn create_agent_from_env() -> Result<IrisAgent> {
169    let provider_str = std::env::var("IRIS_PROVIDER").unwrap_or_else(|_| "openai".to_string());
170    let provider: Provider = provider_str.parse().unwrap_or_default();
171
172    let model =
173        std::env::var("IRIS_MODEL").unwrap_or_else(|_| provider.default_model().to_string());
174
175    create_agent_with_defaults(provider.name(), &model)
176}
177
178// =============================================================================
179// IrisAgentService - The primary interface for agent task execution
180// =============================================================================
181
182/// High-level service for executing agent tasks with structured context.
183///
184/// This is the primary interface for all agent-based operations in git-iris.
185/// It handles:
186/// - Configuration management
187/// - Agent lifecycle
188/// - Task context validation and formatting
189/// - Environment validation
190///
191/// # Example
192/// ```ignore
193/// let service = IrisAgentService::from_common_params(&params, None)?;
194/// let context = TaskContext::for_gen();
195/// let result = service.execute_task("commit", context).await?;
196/// ```
197pub struct IrisAgentService {
198    config: Config,
199    git_repo: Option<Arc<GitRepo>>,
200    provider: String,
201    model: String,
202    fast_model: String,
203}
204
205impl IrisAgentService {
206    /// Create a new service with explicit provider configuration
207    #[must_use]
208    pub fn new(config: Config, provider: String, model: String, fast_model: String) -> Self {
209        Self {
210            config,
211            git_repo: None,
212            provider,
213            model,
214            fast_model,
215        }
216    }
217
218    /// Create service from common CLI parameters
219    ///
220    /// This is the primary constructor for CLI usage. It:
221    /// - Loads and applies configuration
222    /// - Sets up the git repository (local or remote)
223    /// - Validates the environment
224    ///
225    /// # Errors
226    ///
227    /// Returns an error when config loading, agent backend resolution, or repository setup fails.
228    pub fn from_common_params(
229        common_params: &CommonParams,
230        repository_url: Option<String>,
231    ) -> Result<Self> {
232        let mut config = Config::load()?;
233        common_params.apply_to_config(&mut config)?;
234
235        // Determine backend (provider/model) from config
236        let backend = AgentBackend::from_config(&config)?;
237
238        let mut service = Self::new(
239            config,
240            backend.provider_name,
241            backend.model,
242            backend.fast_model,
243        );
244
245        // Setup git repo
246        if let Some(repo_url) = repository_url {
247            service.git_repo = Some(Arc::new(GitRepo::new_from_url(Some(repo_url))?));
248        } else {
249            service.git_repo = Some(Arc::new(GitRepo::new(&std::env::current_dir()?)?));
250        }
251
252        Ok(service)
253    }
254
255    /// Check that the environment is properly configured
256    ///
257    /// # Errors
258    ///
259    /// Returns an error when the active provider configuration is incomplete.
260    pub fn check_environment(&self) -> Result<()> {
261        self.config.check_environment()
262    }
263
264    /// Execute an agent task with structured context
265    ///
266    /// # Arguments
267    /// * `capability` - The capability to invoke (e.g., "commit", "review", "pr")
268    /// * `context` - Structured context describing what to analyze
269    ///
270    /// # Returns
271    /// The structured response from the agent
272    ///
273    /// # Errors
274    ///
275    /// Returns an error when agent construction or task execution fails.
276    pub async fn execute_task(
277        &self,
278        capability: &str,
279        context: TaskContext,
280    ) -> Result<StructuredResponse> {
281        let run_task = async {
282            let mut agent = self.create_agent()?;
283            let task_prompt = Self::build_task_prompt(
284                capability,
285                &context,
286                self.config.temp_instructions.as_deref(),
287            );
288            agent.execute_task(capability, &task_prompt).await
289        };
290
291        if let Some(repo) = &self.git_repo {
292            with_active_repo_root(repo.repo_path(), run_task).await
293        } else {
294            run_task.await
295        }
296    }
297
298    /// Execute a task with a custom prompt (for backwards compatibility)
299    ///
300    /// # Errors
301    ///
302    /// Returns an error when agent construction or task execution fails.
303    pub async fn execute_task_with_prompt(
304        &self,
305        capability: &str,
306        task_prompt: &str,
307    ) -> Result<StructuredResponse> {
308        let run_task = async {
309            let mut agent = self.create_agent()?;
310            agent.execute_task(capability, task_prompt).await
311        };
312
313        if let Some(repo) = &self.git_repo {
314            with_active_repo_root(repo.repo_path(), run_task).await
315        } else {
316            run_task.await
317        }
318    }
319
320    /// Execute an agent task with style overrides
321    ///
322    /// Allows runtime override of preset and gitmoji settings without
323    /// modifying the underlying config. Useful for UI flows where the
324    /// user can change settings per-invocation.
325    ///
326    /// # Arguments
327    /// * `capability` - The capability to invoke
328    /// * `context` - Structured context describing what to analyze
329    /// * `preset` - Optional preset name override (e.g., "conventional", "cosmic")
330    /// * `use_gitmoji` - Optional gitmoji setting override
331    /// * `instructions` - Optional custom instructions from the user
332    ///
333    /// # Errors
334    ///
335    /// Returns an error when agent construction or task execution fails.
336    pub async fn execute_task_with_style(
337        &self,
338        capability: &str,
339        context: TaskContext,
340        preset: Option<&str>,
341        use_gitmoji: Option<bool>,
342        instructions: Option<&str>,
343    ) -> Result<StructuredResponse> {
344        let run_task = async {
345            let mut config = self.config.clone();
346            if let Some(p) = preset {
347                config.temp_preset = Some(p.to_string());
348            }
349            if let Some(gitmoji) = use_gitmoji {
350                config.use_gitmoji = gitmoji;
351            }
352
353            let mut agent = IrisAgentBuilder::new()
354                .with_provider(&self.provider)
355                .with_model(&self.model)
356                .build()?;
357            agent.set_config(config);
358            agent.set_fast_model(self.fast_model.clone());
359
360            let task_prompt = Self::build_task_prompt(capability, &context, instructions);
361            agent.execute_task(capability, &task_prompt).await
362        };
363
364        if let Some(repo) = &self.git_repo {
365            with_active_repo_root(repo.repo_path(), run_task).await
366        } else {
367            run_task.await
368        }
369    }
370
371    /// Build a task prompt incorporating the context information and optional instructions
372    fn build_task_prompt(
373        capability: &str,
374        context: &TaskContext,
375        instructions: Option<&str>,
376    ) -> String {
377        let context_json = context.to_prompt_context();
378        let diff_hint = context.diff_hint();
379
380        // Build instruction suffix if provided
381        let instruction_suffix = instructions
382            .filter(|i| !i.trim().is_empty())
383            .map(|i| format!("\n\n## Custom Instructions\n{}", i))
384            .unwrap_or_default();
385
386        // Extract version and date info if this is a Changelog context
387        let version_info = if let TaskContext::Changelog {
388            version_name, date, ..
389        } = context
390        {
391            let version_str = version_name
392                .as_ref()
393                .map_or_else(|| "(derive from git refs)".to_string(), String::clone);
394            format!(
395                "\n\n## Version Information\n- Version: {}\n- Release Date: {}\n\nIMPORTANT: Use the exact version name and date provided above. Do NOT guess or make up version numbers or dates.",
396                version_str, date
397            )
398        } else {
399            String::new()
400        };
401        let existing_pr_body = context.existing_pull_request_body().map_or_else(
402            String::new,
403            |body| {
404                format!(
405                    "\n\n## Existing Pull Request Description\nRevise this existing PR description instead of blindly replacing it. Preserve accurate reviewer-facing sections, remove stale content, and update it for the current changes:\n\n----- BEGIN EXISTING PR DESCRIPTION -----\n{body}\n----- END EXISTING PR DESCRIPTION -----"
406                )
407            },
408        );
409        let pr_template = context.pull_request_template().map_or_else(
410            String::new,
411            |template| {
412                format!(
413                    "\n\n## Pull Request Template\nAdapt the generated description around this GitHub PR template from `{}`. Preserve the template's required headings, checklist items, and prompts when they apply. Fill useful sections with evidence from the changes, remove placeholder text, and omit sections only when they are clearly irrelevant:\n\n----- BEGIN PR TEMPLATE -----\n{}\n----- END PR TEMPLATE -----",
414                    template.path, template.body
415                )
416            },
417        );
418
419        match capability {
420            "commit" => format!(
421                "Generate a commit message for the following context:\n{}\n\nUse: {}{}",
422                context_json, diff_hint, instruction_suffix
423            ),
424            "review" => format!(
425                "Review the code changes for the following context:\n{}\n\nUse: {}{}",
426                context_json, diff_hint, instruction_suffix
427            ),
428            "pr" => format!(
429                "Generate a pull request description for:\n{}\n\nUse: {}{}{}{}",
430                context_json, diff_hint, pr_template, existing_pr_body, instruction_suffix
431            ),
432            "changelog" => format!(
433                "Generate a changelog for:\n{}\n\nUse: {}{}{}",
434                context_json, diff_hint, version_info, instruction_suffix
435            ),
436            "release_notes" => format!(
437                "Generate release notes for:\n{}\n\nUse: {}{}{}",
438                context_json, diff_hint, version_info, instruction_suffix
439            ),
440            _ => format!(
441                "Execute task with context:\n{}\n\nHint: {}{}",
442                context_json, diff_hint, instruction_suffix
443            ),
444        }
445    }
446
447    /// Create a configured Iris agent
448    fn create_agent(&self) -> Result<IrisAgent> {
449        let mut agent = IrisAgentBuilder::new()
450            .with_provider(&self.provider)
451            .with_model(&self.model)
452            .build()?;
453
454        // Pass config and fast model to agent
455        agent.set_config(self.config.clone());
456        agent.set_fast_model(self.fast_model.clone());
457
458        Ok(agent)
459    }
460
461    /// Create a configured Iris agent with content update tools (for Studio chat)
462    fn create_agent_with_content_updates(
463        &self,
464        sender: crate::agents::tools::ContentUpdateSender,
465    ) -> Result<IrisAgent> {
466        let mut agent = self.create_agent()?;
467        agent.set_content_update_sender(sender);
468        Ok(agent)
469    }
470
471    /// Execute a chat task with content update capabilities
472    ///
473    /// This is used by Studio to enable Iris to update content via tool calls.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error when agent construction or chat execution fails.
478    pub async fn execute_chat_with_updates(
479        &self,
480        task_prompt: &str,
481        content_update_sender: crate::agents::tools::ContentUpdateSender,
482    ) -> Result<StructuredResponse> {
483        let run_task = async {
484            let mut agent = self.create_agent_with_content_updates(content_update_sender)?;
485            agent.execute_task("chat", task_prompt).await
486        };
487
488        if let Some(repo) = &self.git_repo {
489            with_active_repo_root(repo.repo_path(), run_task).await
490        } else {
491            run_task.await
492        }
493    }
494
495    /// Execute a chat task with streaming and content update capabilities
496    ///
497    /// Combines streaming output with tool-based content updates for the TUI chat.
498    ///
499    /// # Errors
500    ///
501    /// Returns an error when agent construction or chat streaming fails.
502    pub async fn execute_chat_streaming<F>(
503        &self,
504        task_prompt: &str,
505        content_update_sender: crate::agents::tools::ContentUpdateSender,
506        on_chunk: F,
507    ) -> Result<StructuredResponse>
508    where
509        F: FnMut(&str, &str) + Send,
510    {
511        let run_task = async {
512            let mut agent = self.create_agent_with_content_updates(content_update_sender)?;
513            agent
514                .execute_task_streaming("chat", task_prompt, on_chunk)
515                .await
516        };
517
518        if let Some(repo) = &self.git_repo {
519            with_active_repo_root(repo.repo_path(), run_task).await
520        } else {
521            run_task.await
522        }
523    }
524
525    /// Execute an agent task with streaming
526    ///
527    /// This method streams LLM output in real-time, calling the callback with each
528    /// text chunk as it arrives. Ideal for TUI display of generation progress.
529    ///
530    /// # Arguments
531    /// * `capability` - The capability to invoke (e.g., "review", "pr", "changelog")
532    /// * `context` - Structured context describing what to analyze
533    /// * `on_chunk` - Callback receiving `(chunk, aggregated_text)` for each delta
534    ///
535    /// # Returns
536    /// The final structured response after streaming completes
537    ///
538    /// # Errors
539    ///
540    /// Returns an error when agent construction or streaming execution fails.
541    pub async fn execute_task_streaming<F>(
542        &self,
543        capability: &str,
544        context: TaskContext,
545        on_chunk: F,
546    ) -> Result<StructuredResponse>
547    where
548        F: FnMut(&str, &str) + Send,
549    {
550        let run_task = async {
551            let mut agent = self.create_agent()?;
552            let task_prompt = Self::build_task_prompt(
553                capability,
554                &context,
555                self.config.temp_instructions.as_deref(),
556            );
557            agent
558                .execute_task_streaming(capability, &task_prompt, on_chunk)
559                .await
560        };
561
562        if let Some(repo) = &self.git_repo {
563            with_active_repo_root(repo.repo_path(), run_task).await
564        } else {
565            run_task.await
566        }
567    }
568
569    /// Get the configuration
570    #[must_use]
571    pub fn config(&self) -> &Config {
572        &self.config
573    }
574
575    /// Get a mutable reference to the configuration
576    pub fn config_mut(&mut self) -> &mut Config {
577        &mut self.config
578    }
579
580    /// Attach a git repository to this service
581    pub fn set_git_repo(&mut self, repo: GitRepo) {
582        self.git_repo = Some(Arc::new(repo));
583    }
584
585    /// Get the git repository if available
586    #[must_use]
587    pub fn git_repo(&self) -> Option<&Arc<GitRepo>> {
588        self.git_repo.as_ref()
589    }
590
591    /// Get the provider name
592    #[must_use]
593    pub fn provider(&self) -> &str {
594        &self.provider
595    }
596
597    /// Get the model name
598    #[must_use]
599    pub fn model(&self) -> &str {
600        &self.model
601    }
602
603    /// Get the fast model name (for subagents and simple tasks)
604    #[must_use]
605    pub fn fast_model(&self) -> &str {
606        &self.fast_model
607    }
608
609    /// Get the API key for the current provider from config
610    #[must_use]
611    pub fn api_key(&self) -> Option<String> {
612        self.config
613            .get_provider_config(&self.provider)
614            .and_then(|pc| pc.api_key_if_set())
615            .map(String::from)
616    }
617}