Skip to main content

git_iris/agents/
iris.rs

1//! Iris Agent - The unified AI agent for Git-Iris operations
2//!
3//! This agent can handle any Git workflow task through capability-based prompts
4//! and multi-turn execution using Rig. One agent to rule them all! ✨
5
6use anyhow::Result;
7use rig::agent::{AgentBuilder, PromptResponse};
8use rig::completion::CompletionModel;
9use schemars::JsonSchema;
10use serde::de::DeserializeOwned;
11use serde::{Deserialize, Serialize};
12use std::borrow::Cow;
13use std::collections::HashMap;
14use std::fmt;
15
16/// Macro to build a streaming agent for any provider.
17///
18/// All three providers (`OpenAI`, `Anthropic`, `Gemini`) share identical setup logic —
19/// subagent creation, tool attachment, optional content update tools — differing
20/// only in the provider builder function. This macro eliminates that duplication.
21macro_rules! build_streaming_agent {
22    ($self:expr, $builder_fn:path, $fast_model:expr, $api_key:expr, $subagent_timeout:expr) => {{
23        use crate::agents::debug_tool::DebugTool;
24
25        // Build subagent
26        let sub_builder = $builder_fn($fast_model, $api_key)?
27            .name("analyze_subagent")
28            .preamble("You are a specialized analysis sub-agent.");
29        let sub_builder = $self.apply_completion_params(
30            sub_builder,
31            $fast_model,
32            4096,
33            CompletionProfile::Subagent,
34        )?;
35        let sub_agent = crate::attach_core_tools!(sub_builder).build();
36
37        // Build main agent with tools
38        let builder = $builder_fn(&$self.model, $api_key)?
39            .preamble($self.preamble.as_deref().unwrap_or("You are Iris."));
40        let builder = $self.apply_completion_params(
41            builder,
42            &$self.model,
43            16384,
44            CompletionProfile::MainAgent,
45        )?;
46
47        let builder = crate::attach_core_tools!(builder)
48            .tool(DebugTool::new(GitRepoInfo))
49            .tool(DebugTool::new($self.workspace.clone()))
50            .tool(DebugTool::new(ParallelAnalyze::with_timeout(
51                &$self.provider,
52                $fast_model,
53                $subagent_timeout,
54                $api_key,
55                $self.current_provider_additional_params().cloned(),
56            )?))
57            .tool(sub_agent);
58
59        // Conditionally attach content update tools for chat mode
60        if let Some(sender) = &$self.content_update_sender {
61            use crate::agents::tools::{UpdateCommitTool, UpdatePRTool, UpdateReviewTool};
62            Ok(builder
63                .tool(DebugTool::new(UpdateCommitTool::new(sender.clone())))
64                .tool(DebugTool::new(UpdatePRTool::new(sender.clone())))
65                .tool(DebugTool::new(UpdateReviewTool::new(sender.clone())))
66                .build())
67        } else {
68            Ok(builder.build())
69        }
70    }};
71}
72
73// Embed capability TOML files at compile time so they're always available
74const CAPABILITY_COMMIT: &str = include_str!("capabilities/commit.toml");
75const CAPABILITY_PR: &str = include_str!("capabilities/pr.toml");
76const CAPABILITY_REVIEW: &str = include_str!("capabilities/review.toml");
77const CAPABILITY_CHANGELOG: &str = include_str!("capabilities/changelog.toml");
78const CAPABILITY_RELEASE_NOTES: &str = include_str!("capabilities/release_notes.toml");
79const CAPABILITY_CHAT: &str = include_str!("capabilities/chat.toml");
80const CAPABILITY_SEMANTIC_BLAME: &str = include_str!("capabilities/semantic_blame.toml");
81
82/// Default preamble for Iris agent
83const DEFAULT_PREAMBLE: &str = "\
84You are Iris, a helpful AI assistant specialized in Git operations and workflows.
85
86You have access to Git tools, code analysis tools, and powerful sub-agent capabilities for handling large analyses.
87
88**File Access Tools:**
89- **file_read** - Read file contents directly. Use `start_line` and `num_lines` for large files.
90- **project_docs** - Load a compact snapshot of README and agent instructions. Use targeted doc types for full docs when needed.
91- **code_search** - Search for patterns across files. Use sparingly; prefer file_read for known files.
92
93**Sub-Agent Tools:**
94
951. **parallel_analyze** - Run multiple analysis tasks CONCURRENTLY with independent context windows
96   - Best for: Large changesets (>500 lines or >20 files), batch commit analysis
97   - Each task runs in its own subagent, preventing context overflow
98   - Example: parallel_analyze({ \"tasks\": [\"Analyze auth/ changes for security\", \"Review db/ for performance\", \"Check api/ for breaking changes\"] })
99
1002. **analyze_subagent** - Delegate a single focused task to a sub-agent
101   - Best for: Deep dive on specific files or focused analysis
102
103**Best Practices:**
104- Use git_diff to get changes first - it includes file content
105- Use file_read to read files directly instead of multiple code_search calls
106- Use project_docs when repository conventions or product framing matter; do not front-load docs if the diff already answers the question
107- Use parallel_analyze for large changesets to avoid context overflow";
108
109fn streaming_response_instructions(capability: &str) -> &'static str {
110    if capability == "chat" {
111        "After using the available tools, respond in plain text.\n\
112         Keep it concise and do not repeat full content that tools already updated."
113    } else {
114        "After using the available tools, respond with your analysis in markdown format.\n\
115         Keep it clear, well-structured, and informative."
116    }
117}
118
119use crate::agents::provider::{self, CompletionProfile, DynAgent};
120use crate::agents::tools::{GitRepoInfo, ParallelAnalyze, Workspace};
121
122/// Trait for streaming callback to handle real-time response processing
123#[async_trait::async_trait]
124pub trait StreamingCallback: Send + Sync {
125    /// Called when a new chunk of text is received
126    async fn on_chunk(
127        &self,
128        chunk: &str,
129        tokens: Option<crate::agents::status::TokenMetrics>,
130    ) -> Result<()>;
131
132    /// Called when the response is complete
133    async fn on_complete(
134        &self,
135        full_response: &str,
136        final_tokens: crate::agents::status::TokenMetrics,
137    ) -> Result<()>;
138
139    /// Called when an error occurs
140    async fn on_error(&self, error: &anyhow::Error) -> Result<()>;
141
142    /// Called for status updates
143    async fn on_status_update(&self, message: &str) -> Result<()>;
144}
145
146/// Unified response type that can hold any structured output
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub enum StructuredResponse {
149    CommitMessage(crate::types::GeneratedMessage),
150    PullRequest(crate::types::MarkdownPullRequest),
151    Changelog(crate::types::MarkdownChangelog),
152    ReleaseNotes(crate::types::MarkdownReleaseNotes),
153    /// Markdown-based review (LLM-driven structure)
154    MarkdownReview(crate::types::MarkdownReview),
155    /// Semantic blame explanation (plain text)
156    SemanticBlame(String),
157    PlainText(String),
158}
159
160impl fmt::Display for StructuredResponse {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        match self {
163            StructuredResponse::CommitMessage(msg) => {
164                write!(f, "{}", crate::types::format_commit_message(msg))
165            }
166            StructuredResponse::PullRequest(pr) => {
167                write!(f, "{}", pr.raw_content())
168            }
169            StructuredResponse::Changelog(cl) => {
170                write!(f, "{}", cl.raw_content())
171            }
172            StructuredResponse::ReleaseNotes(rn) => {
173                write!(f, "{}", rn.raw_content())
174            }
175            StructuredResponse::MarkdownReview(review) => {
176                write!(f, "{}", review.format())
177            }
178            StructuredResponse::SemanticBlame(explanation) => {
179                write!(f, "{explanation}")
180            }
181            StructuredResponse::PlainText(text) => {
182                write!(f, "{text}")
183            }
184        }
185    }
186}
187
188/// Locate the first balanced `{ ... }` pair in `s`, returning `(start, end)` byte
189/// offsets where `end` is exclusive. Returns `None` if no balanced pair exists.
190///
191/// The scanner is intentionally simple — it does not track string literals, so
192/// braces embedded inside strings may still close an enclosing object. Callers
193/// compensate by trying subsequent candidates when parsing fails.
194fn find_balanced_braces(s: &str) -> Option<(usize, usize)> {
195    let mut depth: i32 = 0;
196    let mut start: Option<usize> = None;
197    for (i, ch) in s.char_indices() {
198        match ch {
199            '{' => {
200                if depth == 0 {
201                    start = Some(i);
202                }
203                depth += 1;
204            }
205            '}' if depth > 0 => {
206                depth -= 1;
207                if depth == 0 {
208                    return start.map(|s_idx| (s_idx, i + 1));
209                }
210            }
211            _ => {}
212        }
213    }
214    None
215}
216
217/// Extract JSON from a potentially verbose response that might contain explanations
218fn extract_json_from_response(response: &str) -> Result<String> {
219    use crate::agents::debug;
220
221    debug::debug_section("JSON Extraction");
222
223    let trimmed_response = response.trim();
224
225    // First, try parsing the entire response as JSON (for well-behaved responses)
226    if trimmed_response.starts_with('{')
227        && serde_json::from_str::<serde_json::Value>(trimmed_response).is_ok()
228    {
229        debug::debug_context_management(
230            "Response is pure JSON",
231            &format!("{} characters", trimmed_response.len()),
232        );
233        return Ok(trimmed_response.to_string());
234    }
235
236    // Try to find JSON within markdown code blocks
237    if let Some(start) = response.find("```json") {
238        let content_start = start + "```json".len();
239        // Find the closing ``` on its own line (to avoid matching ``` inside JSON strings)
240        // First try with newline prefix to find standalone closing marker
241        let json_end = if let Some(end) = response[content_start..].find("\n```") {
242            // Found it with newline - the JSON ends before the newline
243            end
244        } else {
245            // Fallback: try to find ``` at start of response section or end of string
246            response[content_start..]
247                .find("```")
248                .unwrap_or(response.len() - content_start)
249        };
250
251        let json_content = &response[content_start..content_start + json_end];
252        let trimmed = json_content.trim().to_string();
253
254        debug::debug_context_management(
255            "Found JSON in markdown code block",
256            &format!("{} characters", trimmed.len()),
257        );
258
259        // Save extracted JSON for debugging
260        if let Err(e) = debug::write_debug_artifact("iris_extracted.json", &trimmed) {
261            debug::debug_warning(&format!("Failed to write extracted JSON: {}", e));
262        }
263
264        debug::debug_json_parse_attempt(&trimmed);
265        return Ok(trimmed);
266    }
267
268    // Look for JSON objects by scanning for balanced `{ ... }` pairs.
269    //
270    // The response may contain several `{` characters that are NOT the real JSON
271    // payload — for example `${{ github.ref_name }}` lifted verbatim from a diff,
272    // or template placeholders the model echoes in its prose. We try each balanced
273    // candidate in order and return the first one that parses. If every candidate
274    // fails, we fall through with an error built from the last attempt.
275    let mut last_error: Option<anyhow::Error> = None;
276    let mut cursor = 0;
277    while cursor < response.len() {
278        let Some((rel_start, rel_end)) = find_balanced_braces(&response[cursor..]) else {
279            break;
280        };
281        let start = cursor + rel_start;
282        let end = cursor + rel_end;
283        let json_content = &response[start..end];
284        debug::debug_json_parse_attempt(json_content);
285
286        let sanitized = sanitize_json_response(json_content);
287        match serde_json::from_str::<serde_json::Value>(&sanitized) {
288            Ok(_) => {
289                debug::debug_context_management(
290                    "Found valid JSON object",
291                    &format!("{} characters", json_content.len()),
292                );
293                return Ok(sanitized.into_owned());
294            }
295            Err(e) => {
296                debug::debug_json_parse_error(&format!(
297                    "Candidate at offset {} is not valid JSON: {}",
298                    start, e
299                ));
300                let preview = if json_content.len() > 200 {
301                    format!("{}...", &json_content[..200])
302                } else {
303                    json_content.to_string()
304                };
305                last_error = Some(anyhow::anyhow!(
306                    "Found JSON-like content but it's not valid JSON: {}\nPreview: {}",
307                    e,
308                    preview
309                ));
310                // Advance past the opening brace of this failed candidate so we
311                // can try the next `{` in the response.
312                cursor = start + 1;
313            }
314        }
315    }
316
317    if let Some(err) = last_error {
318        return Err(err);
319    }
320
321    // If no JSON found, check if the response is raw markdown that we can wrap
322    // This handles cases where the model returns markdown directly without JSON wrapper
323    let trimmed = response.trim();
324    if trimmed.starts_with('#') || trimmed.starts_with("##") {
325        debug::debug_context_management(
326            "Detected raw markdown response",
327            "Wrapping in JSON structure",
328        );
329        // Escape the markdown content for JSON and wrap it
330        let escaped_content = serde_json::to_string(trimmed)?;
331        // escaped_content includes quotes, so we need to use it directly as the value
332        let wrapped = format!(r#"{{"content": {}}}"#, escaped_content);
333        debug::debug_json_parse_attempt(&wrapped);
334        return Ok(wrapped);
335    }
336
337    // If no JSON found, return error
338    debug::debug_json_parse_error("No valid JSON found in response");
339    Err(anyhow::anyhow!("No valid JSON found in response"))
340}
341
342/// Some providers (Anthropic) occasionally send literal control characters like newlines
343/// inside JSON strings, which violates strict JSON parsing rules. This helper sanitizes
344/// those responses by escaping control characters only within string literals while
345/// leaving the rest of the payload untouched.
346fn sanitize_json_response(raw: &str) -> Cow<'_, str> {
347    let mut needs_sanitization = false;
348    let mut in_string = false;
349    let mut escaped = false;
350
351    for ch in raw.chars() {
352        if in_string {
353            if escaped {
354                escaped = false;
355                continue;
356            }
357
358            match ch {
359                '\\' => escaped = true,
360                '"' => in_string = false,
361                '\n' | '\r' | '\t' => {
362                    needs_sanitization = true;
363                    break;
364                }
365                c if c.is_control() => {
366                    needs_sanitization = true;
367                    break;
368                }
369                _ => {}
370            }
371        } else if ch == '"' {
372            in_string = true;
373        }
374    }
375
376    if !needs_sanitization {
377        return Cow::Borrowed(raw);
378    }
379
380    let mut sanitized = String::with_capacity(raw.len());
381    in_string = false;
382    escaped = false;
383
384    for ch in raw.chars() {
385        if in_string {
386            if escaped {
387                sanitized.push(ch);
388                escaped = false;
389                continue;
390            }
391
392            match ch {
393                '\\' => {
394                    sanitized.push('\\');
395                    escaped = true;
396                }
397                '"' => {
398                    sanitized.push('"');
399                    in_string = false;
400                }
401                '\n' => sanitized.push_str("\\n"),
402                '\r' => sanitized.push_str("\\r"),
403                '\t' => sanitized.push_str("\\t"),
404                c if c.is_control() => {
405                    use std::fmt::Write as _;
406                    let _ = write!(&mut sanitized, "\\u{:04X}", u32::from(c));
407                }
408                _ => sanitized.push(ch),
409            }
410        } else {
411            sanitized.push(ch);
412            if ch == '"' {
413                in_string = true;
414                escaped = false;
415            }
416        }
417    }
418
419    Cow::Owned(sanitized)
420}
421
422/// Parse JSON with schema validation and error recovery
423///
424/// This function attempts to parse JSON with the following strategy:
425/// 1. Try direct parsing (fast path for well-formed responses)
426/// 2. If that fails, use the output validator for recovery
427/// 3. Log any warnings about recovered issues
428fn parse_with_recovery<T>(json_str: &str) -> Result<T>
429where
430    T: JsonSchema + DeserializeOwned,
431{
432    use crate::agents::debug as agent_debug;
433    use crate::agents::output_validator::validate_and_parse;
434
435    let validation_result = validate_and_parse::<T>(json_str)?;
436
437    // Log recovery warnings
438    if validation_result.recovered {
439        agent_debug::debug_context_management(
440            "JSON recovery applied",
441            &format!("{} issues fixed", validation_result.warnings.len()),
442        );
443        for warning in &validation_result.warnings {
444            agent_debug::debug_warning(warning);
445        }
446    }
447
448    validation_result
449        .value
450        .ok_or_else(|| anyhow::anyhow!("Failed to parse JSON even after recovery"))
451}
452
453/// The unified Iris agent that can handle any Git-Iris task
454///
455/// Note: This struct is Send + Sync safe - we don't store the client builder,
456/// instead we create it fresh when needed. This allows the agent to be used
457/// across async boundaries with `tokio::spawn`.
458pub struct IrisAgent {
459    provider: String,
460    model: String,
461    /// Fast model for subagents and simple tasks
462    fast_model: Option<String>,
463    /// Current capability/task being executed
464    current_capability: Option<String>,
465    /// Provider configuration
466    provider_config: HashMap<String, String>,
467    /// Custom preamble
468    preamble: Option<String>,
469    /// Configuration for features like gitmoji, presets, etc.
470    config: Option<crate::config::Config>,
471    /// Optional sender for content updates (used in Studio chat mode)
472    content_update_sender: Option<crate::agents::tools::ContentUpdateSender>,
473    /// Persistent workspace for notes and task tracking (shared across agent invocations)
474    workspace: Workspace,
475}
476
477impl IrisAgent {
478    /// Create a new Iris agent with the given provider and model
479    ///
480    /// # Errors
481    ///
482    /// Returns an error when the provider or model configuration is invalid.
483    pub fn new(provider: &str, model: &str) -> Result<Self> {
484        Ok(Self {
485            provider: provider.to_string(),
486            model: model.to_string(),
487            fast_model: None,
488            current_capability: None,
489            provider_config: HashMap::new(),
490            preamble: None,
491            config: None,
492            content_update_sender: None,
493            workspace: Workspace::new(),
494        })
495    }
496
497    /// Set the content update sender for Studio chat mode
498    ///
499    /// When set, the agent will have access to tools for updating
500    /// commit messages, PR descriptions, and reviews.
501    pub fn set_content_update_sender(&mut self, sender: crate::agents::tools::ContentUpdateSender) {
502        self.content_update_sender = Some(sender);
503    }
504
505    /// Get the effective fast model (configured or same as main model)
506    fn effective_fast_model(&self) -> &str {
507        self.fast_model.as_deref().unwrap_or(&self.model)
508    }
509
510    /// Get the API key for the current provider from config
511    fn get_api_key(&self) -> Option<&str> {
512        provider::current_provider_config(self.config.as_ref(), &self.provider)
513            .and_then(crate::providers::ProviderConfig::api_key_if_set)
514    }
515
516    fn current_provider(&self) -> Result<crate::providers::Provider> {
517        provider::provider_from_name(&self.provider)
518    }
519
520    fn current_provider_additional_params(&self) -> Option<&HashMap<String, String>> {
521        provider::current_provider_config(self.config.as_ref(), &self.provider)
522            .map(|provider_config| &provider_config.additional_params)
523    }
524
525    /// Build the actual agent for execution
526    ///
527    /// Uses provider-specific builders (rig-core 0.27+) with enum dispatch for runtime
528    /// provider selection. Each provider arm builds both the subagent and main agent
529    /// with proper typing.
530    fn build_agent(&self) -> Result<DynAgent> {
531        use crate::agents::debug_tool::DebugTool;
532
533        let preamble = self.preamble.as_deref().unwrap_or(DEFAULT_PREAMBLE);
534        let fast_model = self.effective_fast_model();
535        let api_key = self.get_api_key();
536        let subagent_timeout = self
537            .config
538            .as_ref()
539            .map_or(120, |c| c.subagent_timeout_secs);
540
541        // Macro to build and configure subagent with core tools
542        macro_rules! build_subagent {
543            ($builder:expr) => {{
544                let builder = $builder
545                    .name("analyze_subagent")
546                    .description("Delegate focused analysis tasks to a sub-agent with its own context window. Use for analyzing specific files, commits, or code sections independently. The sub-agent has access to Git tools (diff, log, status) and file analysis tools.")
547                    .preamble("You are a specialized analysis sub-agent for Iris. Your job is to complete focused analysis tasks and return concise, actionable summaries.
548
549Guidelines:
550- Use the available tools to gather information
551- Focus only on what's asked - don't expand scope
552- Return a clear, structured summary of findings
553- Highlight important issues, patterns, or insights
554- Keep your response focused and concise")
555                    ;
556                let builder = self.apply_completion_params(
557                    builder,
558                    fast_model,
559                    4096,
560                    CompletionProfile::Subagent,
561                )?;
562                crate::attach_core_tools!(builder).build()
563            }};
564        }
565
566        // Macro to attach main agent tools (excluding subagent which varies by type)
567        macro_rules! attach_main_tools {
568            ($builder:expr) => {{
569                crate::attach_core_tools!($builder)
570                    .tool(DebugTool::new(GitRepoInfo))
571                    .tool(DebugTool::new(self.workspace.clone()))
572                    .tool(DebugTool::new(ParallelAnalyze::with_timeout(
573                        &self.provider,
574                        fast_model,
575                        subagent_timeout,
576                        api_key,
577                        self.current_provider_additional_params().cloned(),
578                    )?))
579            }};
580        }
581
582        // Macro to optionally attach content update tools
583        macro_rules! maybe_attach_update_tools {
584            ($builder:expr) => {{
585                if let Some(sender) = &self.content_update_sender {
586                    use crate::agents::tools::{UpdateCommitTool, UpdatePRTool, UpdateReviewTool};
587                    $builder
588                        .tool(DebugTool::new(UpdateCommitTool::new(sender.clone())))
589                        .tool(DebugTool::new(UpdatePRTool::new(sender.clone())))
590                        .tool(DebugTool::new(UpdateReviewTool::new(sender.clone())))
591                        .build()
592                } else {
593                    $builder.build()
594                }
595            }};
596        }
597
598        match self.provider.as_str() {
599            "openai" => {
600                // Build subagent
601                let sub_agent = build_subagent!(provider::openai_builder(fast_model, api_key)?);
602
603                // Build main agent
604                let builder = provider::openai_builder(&self.model, api_key)?.preamble(preamble);
605                let builder = self.apply_completion_params(
606                    builder,
607                    &self.model,
608                    16384,
609                    CompletionProfile::MainAgent,
610                )?;
611                let builder = attach_main_tools!(builder).tool(sub_agent);
612                let agent = maybe_attach_update_tools!(builder);
613                Ok(DynAgent::OpenAI(agent))
614            }
615            "anthropic" => {
616                // Build subagent
617                let sub_agent = build_subagent!(provider::anthropic_builder(fast_model, api_key)?);
618
619                // Build main agent
620                let builder = provider::anthropic_builder(&self.model, api_key)?.preamble(preamble);
621                let builder = self.apply_completion_params(
622                    builder,
623                    &self.model,
624                    16384,
625                    CompletionProfile::MainAgent,
626                )?;
627                let builder = attach_main_tools!(builder).tool(sub_agent);
628                let agent = maybe_attach_update_tools!(builder);
629                Ok(DynAgent::Anthropic(agent))
630            }
631            "google" | "gemini" => {
632                // Build subagent
633                let sub_agent = build_subagent!(provider::gemini_builder(fast_model, api_key)?);
634
635                // Build main agent
636                let builder = provider::gemini_builder(&self.model, api_key)?.preamble(preamble);
637                let builder = self.apply_completion_params(
638                    builder,
639                    &self.model,
640                    16384,
641                    CompletionProfile::MainAgent,
642                )?;
643                let builder = attach_main_tools!(builder).tool(sub_agent);
644                let agent = maybe_attach_update_tools!(builder);
645                Ok(DynAgent::Gemini(agent))
646            }
647            _ => Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
648        }
649    }
650
651    fn apply_completion_params<M>(
652        &self,
653        builder: AgentBuilder<M>,
654        model: &str,
655        max_tokens: u64,
656        profile: CompletionProfile,
657    ) -> Result<AgentBuilder<M>>
658    where
659        M: CompletionModel,
660    {
661        let provider = self.current_provider()?;
662        Ok(provider::apply_completion_params(
663            builder,
664            provider,
665            model,
666            max_tokens,
667            self.current_provider_additional_params(),
668            profile,
669        ))
670    }
671
672    /// Execute task using agent with tools and parse structured JSON response
673    /// This is the core method that enables Iris to use tools and generate structured outputs
674    async fn execute_with_agent<T>(&self, system_prompt: &str, user_prompt: &str) -> Result<T>
675    where
676        T: JsonSchema + for<'a> serde::Deserialize<'a> + serde::Serialize + Send + Sync + 'static,
677    {
678        use crate::agents::debug;
679        use crate::agents::status::IrisPhase;
680        use crate::messages::get_capability_message;
681        use schemars::schema_for;
682
683        let capability = self.current_capability().unwrap_or("commit");
684
685        debug::debug_phase_change(&format!("AGENT EXECUTION: {}", std::any::type_name::<T>()));
686
687        // Update status - building agent (capability-aware)
688        let msg = get_capability_message(capability);
689        crate::iris_status_dynamic!(IrisPhase::Planning, msg.text, 2, 4);
690
691        // Build agent with all tools attached
692        let agent = self.build_agent()?;
693        debug::debug_context_management(
694            "Agent built with tools",
695            &format!(
696                "Provider: {}, Model: {} (fast: {})",
697                self.provider,
698                self.model,
699                self.effective_fast_model()
700            ),
701        );
702
703        // Create JSON schema for the response type
704        let schema = schema_for!(T);
705        let schema_json = serde_json::to_string_pretty(&schema)?;
706        debug::debug_context_management(
707            "JSON schema created",
708            &format!("Type: {}", std::any::type_name::<T>()),
709        );
710
711        // Enhanced prompt that instructs Iris to use tools and respond with JSON
712        let full_prompt = format!(
713            "{system_prompt}\n\n{user_prompt}\n\n\
714            === CRITICAL: RESPONSE FORMAT ===\n\
715            After using the available tools to gather necessary information, you MUST respond with ONLY a valid JSON object.\n\n\
716            REQUIRED JSON SCHEMA:\n\
717            {schema_json}\n\n\
718            CRITICAL INSTRUCTIONS:\n\
719            - Return ONLY the raw JSON object - nothing else\n\
720            - NO explanations before the JSON\n\
721            - NO explanations after the JSON\n\
722            - NO markdown code blocks (just raw JSON)\n\
723            - NO preamble text like 'Here is the JSON:' or 'Let me generate:'\n\
724            - Start your response with {{ and end with }}\n\
725            - The JSON must be complete and valid\n\n\
726            Your entire response should be ONLY the JSON object."
727        );
728
729        debug::debug_llm_request(&full_prompt, Some(16384));
730
731        // Update status - generation phase (capability-aware)
732        let gen_msg = get_capability_message(capability);
733        crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
734
735        // Prompt the agent with multi-turn support
736        // Set multi_turn to allow the agent to call multiple tools (default is 0 = single-shot)
737        // For complex tasks like PRs and release notes, Iris may need many tool calls to analyze all changes
738        // The agent knows when to stop, so we give it plenty of room (50 rounds)
739        let timer = debug::DebugTimer::start("Agent prompt execution");
740
741        debug::debug_context_management(
742            "LLM request",
743            "Sending prompt to agent with multi_turn(50)",
744        );
745        let prompt_response: PromptResponse = agent.prompt_extended(&full_prompt, 50).await?;
746
747        timer.finish();
748
749        // Extract usage stats for debug output
750        let usage = &prompt_response.usage;
751        debug::debug_context_management(
752            "Token usage",
753            &format!(
754                "input: {} | output: {} | total: {}",
755                usage.input_tokens, usage.output_tokens, usage.total_tokens
756            ),
757        );
758
759        let response = &prompt_response.output;
760        #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
761        let total_tokens_usize = usage.total_tokens as usize;
762        debug::debug_llm_response(
763            response,
764            std::time::Duration::from_secs(0),
765            Some(total_tokens_usize),
766        );
767
768        // Update status - synthesis phase
769        crate::iris_status_dynamic!(
770            IrisPhase::Synthesis,
771            "✨ Iris is synthesizing results...",
772            4,
773            4
774        );
775
776        // Extract and parse JSON from the response
777        let json_str = extract_json_from_response(response)?;
778        let sanitized_json = sanitize_json_response(&json_str);
779        let sanitized_ref = sanitized_json.as_ref();
780
781        if matches!(sanitized_json, Cow::Borrowed(_)) {
782            debug::debug_json_parse_attempt(sanitized_ref);
783        } else {
784            debug::debug_context_management(
785                "Sanitized JSON response",
786                &format!("{} → {} characters", json_str.len(), sanitized_ref.len()),
787            );
788            debug::debug_json_parse_attempt(sanitized_ref);
789        }
790
791        // Use the output validator for robust parsing with error recovery
792        let result: T = parse_with_recovery(sanitized_ref)?;
793
794        debug::debug_json_parse_success(std::any::type_name::<T>());
795
796        // Update status - completed
797        crate::iris_status_completed!();
798
799        Ok(result)
800    }
801
802    /// Inject style instructions into the system prompt based on config and capability
803    ///
804    /// Key distinction:
805    /// - Commits: preset controls format (conventional = no emojis)
806    /// - Non-commits (PR, review, changelog, `release_notes`): `use_gitmoji` controls emojis
807    fn inject_style_instructions(&self, system_prompt: &mut String, capability: &str) {
808        let Some(config) = &self.config else {
809            return;
810        };
811
812        let preset_name = config.get_effective_preset_name();
813        let is_conventional = preset_name == "conventional";
814        let is_default_mode = preset_name == "default" || preset_name.is_empty();
815
816        // For commits in default mode with no explicit gitmoji override, use style detection
817        let use_style_detection =
818            capability == "commit" && is_default_mode && config.gitmoji_override.is_none();
819
820        // Commit emoji: respects preset (conventional = no emoji)
821        let commit_emoji = config.use_gitmoji && !is_conventional && !use_style_detection;
822
823        // Output emoji: independent of preset, only respects use_gitmoji setting
824        // CLI --gitmoji/--no-gitmoji override is already applied to config.use_gitmoji
825        let output_emoji = config.gitmoji_override.unwrap_or(config.use_gitmoji);
826
827        // Inject instruction preset if configured (skip for default mode)
828        if !preset_name.is_empty() && !is_default_mode {
829            let library = crate::instruction_presets::get_instruction_preset_library();
830            if let Some(preset) = library.get_preset(preset_name) {
831                tracing::info!("📋 Injecting '{}' preset style instructions", preset_name);
832                system_prompt.push_str("\n\n=== STYLE INSTRUCTIONS ===\n");
833                system_prompt.push_str(&preset.instructions);
834                system_prompt.push('\n');
835            } else {
836                tracing::warn!("⚠️ Preset '{}' not found in library", preset_name);
837            }
838        }
839
840        // Handle commit-specific styling (structured JSON output with emoji field)
841        // Default mode (use_style_detection): no style injection here — the agent
842        // detects format from git_log via commit.toml §Local Style Detection.
843        if capability == "commit" {
844            if commit_emoji {
845                system_prompt.push_str("\n\n=== GITMOJI INSTRUCTIONS ===\n");
846                system_prompt.push_str("Set the 'emoji' field to a single relevant gitmoji. ");
847                system_prompt.push_str(
848                    "DO NOT include the emoji in the 'message' or 'title' text - only set the 'emoji' field. ",
849                );
850                system_prompt.push_str("Choose the closest match from this compact guide:\n\n");
851                system_prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
852                system_prompt.push_str("\n\nThe emoji should match the primary type of change.");
853            } else if is_conventional {
854                system_prompt.push_str("\n\n=== CONVENTIONAL COMMITS FORMAT ===\n");
855                system_prompt.push_str("IMPORTANT: This uses Conventional Commits format. ");
856                system_prompt
857                    .push_str("DO NOT include any emojis in the commit message or PR title. ");
858                system_prompt.push_str("The 'emoji' field should be null.");
859            }
860        }
861
862        // Handle non-commit outputs: use output_emoji (independent of preset)
863        if capability == "pr" || capability == "review" {
864            if output_emoji {
865                Self::inject_pr_review_emoji_styling(system_prompt);
866            } else {
867                Self::inject_no_emoji_styling(system_prompt);
868            }
869        }
870
871        if capability == "release_notes" && output_emoji {
872            Self::inject_release_notes_emoji_styling(system_prompt);
873        } else if capability == "release_notes" {
874            Self::inject_no_emoji_styling(system_prompt);
875        }
876
877        if capability == "changelog" && output_emoji {
878            Self::inject_changelog_emoji_styling(system_prompt);
879        } else if capability == "changelog" {
880            Self::inject_no_emoji_styling(system_prompt);
881        }
882    }
883
884    fn inject_pr_review_emoji_styling(prompt: &mut String) {
885        prompt.push_str("\n\n=== EMOJI STYLING ===\n");
886        prompt.push_str("Use emojis to make the output visually scannable and engaging:\n");
887        prompt.push_str("- H1 title: ONE gitmoji at the start (✨, 🐛, ♻️, etc.)\n");
888        prompt.push_str("- Section headers: Add relevant emojis (🎯 What's New, ⚙️ How It Works, 📋 Commits, ⚠️ Breaking Changes)\n");
889        prompt.push_str("- Commit list entries: Include gitmoji where appropriate\n");
890        prompt.push_str("- Body text: Keep clean - no scattered emojis within prose\n\n");
891        prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
892    }
893
894    fn inject_release_notes_emoji_styling(prompt: &mut String) {
895        prompt.push_str("\n\n=== EMOJI STYLING ===\n");
896        prompt.push_str("Use at most one emoji per highlight/section title. No emojis in bullet descriptions, upgrade notes, or metrics. ");
897        prompt.push_str("Pick from the approved gitmoji list (e.g., 🌟 Highlights, 🤖 Agents, 🔧 Tooling, 🐛 Fixes, ⚡ Performance). ");
898        prompt.push_str("Never sprinkle emojis within sentences or JSON keys.\n\n");
899        prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
900    }
901
902    fn inject_changelog_emoji_styling(prompt: &mut String) {
903        prompt.push_str("\n\n=== EMOJI STYLING ===\n");
904        prompt.push_str("Section keys must remain plain text (Added/Changed/Deprecated/Removed/Fixed/Security). ");
905        prompt.push_str(
906            "You may include one emoji within a change description to reinforce meaning. ",
907        );
908        prompt.push_str(
909            "Never add emojis to JSON keys, section names, metrics, or upgrade notes.\n\n",
910        );
911        prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
912    }
913
914    fn inject_no_emoji_styling(prompt: &mut String) {
915        prompt.push_str("\n\n=== NO EMOJI STYLING ===\n");
916        prompt.push_str(
917            "DO NOT include any emojis anywhere in the output. Keep all content plain text.",
918        );
919    }
920
921    /// Execute a task with the given capability and user prompt
922    ///
923    /// This now automatically uses structured output based on the capability type
924    ///
925    /// # Errors
926    ///
927    /// Returns an error when capability loading, agent construction, or generation fails.
928    pub async fn execute_task(
929        &mut self,
930        capability: &str,
931        user_prompt: &str,
932    ) -> Result<StructuredResponse> {
933        use crate::agents::status::IrisPhase;
934        use crate::messages::get_capability_message;
935
936        // Show initializing status with a capability-specific message
937        let waiting_msg = get_capability_message(capability);
938        crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
939
940        // Load the capability config to get both prompt and output type
941        let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
942
943        // Inject style instructions (presets, gitmoji, conventional commits)
944        self.inject_style_instructions(&mut system_prompt, capability);
945
946        // Set the current capability
947        self.current_capability = Some(capability.to_string());
948
949        // Update status - analyzing with agent
950        crate::iris_status_dynamic!(
951            IrisPhase::Analysis,
952            "🔍 Iris is analyzing your changes...",
953            2,
954            4
955        );
956
957        // Use agent with tools for all structured outputs
958        // The agent will use tools as needed and respond with JSON
959        match output_type.as_str() {
960            "GeneratedMessage" => {
961                let response = self
962                    .execute_with_agent::<crate::types::GeneratedMessage>(
963                        &system_prompt,
964                        user_prompt,
965                    )
966                    .await?;
967                Ok(StructuredResponse::CommitMessage(response))
968            }
969            "MarkdownPullRequest" => {
970                let response = self
971                    .execute_with_agent::<crate::types::MarkdownPullRequest>(
972                        &system_prompt,
973                        user_prompt,
974                    )
975                    .await?;
976                Ok(StructuredResponse::PullRequest(response))
977            }
978            "MarkdownChangelog" => {
979                let response = self
980                    .execute_with_agent::<crate::types::MarkdownChangelog>(
981                        &system_prompt,
982                        user_prompt,
983                    )
984                    .await?;
985                Ok(StructuredResponse::Changelog(response))
986            }
987            "MarkdownReleaseNotes" => {
988                let response = self
989                    .execute_with_agent::<crate::types::MarkdownReleaseNotes>(
990                        &system_prompt,
991                        user_prompt,
992                    )
993                    .await?;
994                Ok(StructuredResponse::ReleaseNotes(response))
995            }
996            "MarkdownReview" => {
997                let response = self
998                    .execute_with_agent::<crate::types::MarkdownReview>(&system_prompt, user_prompt)
999                    .await?;
1000                Ok(StructuredResponse::MarkdownReview(response))
1001            }
1002            "SemanticBlame" => {
1003                // For semantic blame, we want plain text response
1004                let agent = self.build_agent()?;
1005                let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
1006                let response = agent.prompt_multi_turn(&full_prompt, 10).await?;
1007                Ok(StructuredResponse::SemanticBlame(response))
1008            }
1009            _ => {
1010                // Fallback to regular agent for unknown types
1011                let agent = self.build_agent()?;
1012                let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
1013                // Use multi_turn to allow tool calls even for unknown capability types
1014                let response = agent.prompt_multi_turn(&full_prompt, 50).await?;
1015                Ok(StructuredResponse::PlainText(response))
1016            }
1017        }
1018    }
1019
1020    /// Execute a task with streaming, calling the callback with each text chunk
1021    ///
1022    /// This enables real-time display of LLM output in the TUI.
1023    /// The callback receives `(chunk, aggregated_text)` for each delta.
1024    ///
1025    /// Returns the final structured response after streaming completes.
1026    ///
1027    /// # Errors
1028    ///
1029    /// Returns an error when capability loading, agent construction, or streaming fails.
1030    pub async fn execute_task_streaming<F>(
1031        &mut self,
1032        capability: &str,
1033        user_prompt: &str,
1034        mut on_chunk: F,
1035    ) -> Result<StructuredResponse>
1036    where
1037        F: FnMut(&str, &str) + Send,
1038    {
1039        use crate::agents::status::IrisPhase;
1040        use crate::messages::get_capability_message;
1041        use futures::StreamExt;
1042        use rig::agent::MultiTurnStreamItem;
1043        use rig::streaming::{StreamedAssistantContent, StreamingPrompt};
1044
1045        // Show initializing status
1046        let waiting_msg = get_capability_message(capability);
1047        crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
1048
1049        // Load the capability config
1050        let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
1051
1052        // Inject style instructions
1053        self.inject_style_instructions(&mut system_prompt, capability);
1054
1055        // Set current capability
1056        self.current_capability = Some(capability.to_string());
1057
1058        // Update status
1059        crate::iris_status_dynamic!(
1060            IrisPhase::Analysis,
1061            "🔍 Iris is analyzing your changes...",
1062            2,
1063            4
1064        );
1065
1066        // Build the full prompt (simplified for streaming - no JSON schema enforcement)
1067        let full_prompt = format!(
1068            "{}\n\n{}\n\n{}",
1069            system_prompt,
1070            user_prompt,
1071            streaming_response_instructions(capability)
1072        );
1073
1074        // Update status
1075        let gen_msg = get_capability_message(capability);
1076        crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
1077
1078        // Macro to consume a stream and aggregate text
1079        macro_rules! consume_stream {
1080            ($stream:expr) => {{
1081                let mut aggregated_text = String::new();
1082                let mut stream = $stream;
1083                while let Some(item) = stream.next().await {
1084                    match item {
1085                        Ok(MultiTurnStreamItem::StreamAssistantItem(
1086                            StreamedAssistantContent::Text(text),
1087                        )) => {
1088                            aggregated_text.push_str(&text.text);
1089                            on_chunk(&text.text, &aggregated_text);
1090                        }
1091                        Ok(MultiTurnStreamItem::StreamAssistantItem(
1092                            StreamedAssistantContent::ToolCall { tool_call, .. },
1093                        )) => {
1094                            let tool_name = &tool_call.function.name;
1095                            let reason = format!("Calling {}", tool_name);
1096                            crate::iris_status_dynamic!(
1097                                IrisPhase::ToolExecution {
1098                                    tool_name: tool_name.clone(),
1099                                    reason: reason.clone()
1100                                },
1101                                format!("🔧 {}", reason),
1102                                3,
1103                                4
1104                            );
1105                        }
1106                        Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
1107                        Err(e) => return Err(anyhow::anyhow!("Streaming error: {}", e)),
1108                        _ => {}
1109                    }
1110                }
1111                aggregated_text
1112            }};
1113        }
1114
1115        // Build and stream per-provider (streaming types are model-specific)
1116        let aggregated_text = match self.provider.as_str() {
1117            "openai" => {
1118                let agent = self.build_openai_agent_for_streaming(&full_prompt)?;
1119                let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1120                consume_stream!(stream)
1121            }
1122            "anthropic" => {
1123                let agent = self.build_anthropic_agent_for_streaming(&full_prompt)?;
1124                let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1125                consume_stream!(stream)
1126            }
1127            "google" | "gemini" => {
1128                let agent = self.build_gemini_agent_for_streaming(&full_prompt)?;
1129                let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1130                consume_stream!(stream)
1131            }
1132            _ => return Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
1133        };
1134
1135        // Update status
1136        crate::iris_status_dynamic!(
1137            IrisPhase::Synthesis,
1138            "✨ Iris is synthesizing results...",
1139            4,
1140            4
1141        );
1142
1143        let response = Self::text_to_structured_response(&output_type, aggregated_text);
1144        crate::iris_status_completed!();
1145        Ok(response)
1146    }
1147
1148    /// Convert raw text to the appropriate structured response type
1149    fn text_to_structured_response(output_type: &str, text: String) -> StructuredResponse {
1150        match output_type {
1151            "MarkdownReview" => {
1152                StructuredResponse::MarkdownReview(crate::types::MarkdownReview { content: text })
1153            }
1154            "MarkdownPullRequest" => {
1155                StructuredResponse::PullRequest(crate::types::MarkdownPullRequest { content: text })
1156            }
1157            "MarkdownChangelog" => {
1158                StructuredResponse::Changelog(crate::types::MarkdownChangelog { content: text })
1159            }
1160            "MarkdownReleaseNotes" => {
1161                StructuredResponse::ReleaseNotes(crate::types::MarkdownReleaseNotes {
1162                    content: text,
1163                })
1164            }
1165            "SemanticBlame" => StructuredResponse::SemanticBlame(text),
1166            _ => StructuredResponse::PlainText(text),
1167        }
1168    }
1169
1170    /// Shared streaming agent configuration
1171    fn streaming_agent_config(&self) -> (&str, Option<&str>, u64) {
1172        let fast_model = self.effective_fast_model();
1173        let api_key = self.get_api_key();
1174        let subagent_timeout = self
1175            .config
1176            .as_ref()
1177            .map_or(120, |c| c.subagent_timeout_secs);
1178        (fast_model, api_key, subagent_timeout)
1179    }
1180
1181    /// Build `OpenAI` agent for streaming (with tools attached)
1182    fn build_openai_agent_for_streaming(
1183        &self,
1184        _prompt: &str,
1185    ) -> Result<rig::agent::Agent<provider::OpenAIModel>> {
1186        let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1187        build_streaming_agent!(
1188            self,
1189            provider::openai_builder,
1190            fast_model,
1191            api_key,
1192            subagent_timeout
1193        )
1194    }
1195
1196    /// Build Anthropic agent for streaming (with tools attached)
1197    fn build_anthropic_agent_for_streaming(
1198        &self,
1199        _prompt: &str,
1200    ) -> Result<rig::agent::Agent<provider::AnthropicModel>> {
1201        let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1202        build_streaming_agent!(
1203            self,
1204            provider::anthropic_builder,
1205            fast_model,
1206            api_key,
1207            subagent_timeout
1208        )
1209    }
1210
1211    /// Build Gemini agent for streaming (with tools attached)
1212    fn build_gemini_agent_for_streaming(
1213        &self,
1214        _prompt: &str,
1215    ) -> Result<rig::agent::Agent<provider::GeminiModel>> {
1216        let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1217        build_streaming_agent!(
1218            self,
1219            provider::gemini_builder,
1220            fast_model,
1221            api_key,
1222            subagent_timeout
1223        )
1224    }
1225
1226    /// Load capability configuration from embedded TOML, returning both prompt and output type
1227    fn load_capability_config(&self, capability: &str) -> Result<(String, String)> {
1228        let _ = self; // Keep &self for method syntax consistency
1229        // Use embedded capability strings - always available regardless of working directory
1230        let content = match capability {
1231            "commit" => CAPABILITY_COMMIT,
1232            "pr" => CAPABILITY_PR,
1233            "review" => CAPABILITY_REVIEW,
1234            "changelog" => CAPABILITY_CHANGELOG,
1235            "release_notes" => CAPABILITY_RELEASE_NOTES,
1236            "chat" => CAPABILITY_CHAT,
1237            "semantic_blame" => CAPABILITY_SEMANTIC_BLAME,
1238            _ => {
1239                // Return generic prompt for unknown capabilities
1240                return Ok((
1241                    format!(
1242                        "You are helping with a {capability} task. Use the available Git tools to assist the user."
1243                    ),
1244                    "PlainText".to_string(),
1245                ));
1246            }
1247        };
1248
1249        // Parse TOML to extract both task_prompt and output_type
1250        let parsed: toml::Value = toml::from_str(content)?;
1251
1252        let task_prompt = parsed
1253            .get("task_prompt")
1254            .and_then(|v| v.as_str())
1255            .ok_or_else(|| anyhow::anyhow!("No task_prompt found in capability file"))?;
1256
1257        let output_type = parsed
1258            .get("output_type")
1259            .and_then(|v| v.as_str())
1260            .unwrap_or("PlainText")
1261            .to_string();
1262
1263        Ok((task_prompt.to_string(), output_type))
1264    }
1265
1266    /// Get the current capability being executed
1267    #[must_use]
1268    pub fn current_capability(&self) -> Option<&str> {
1269        self.current_capability.as_deref()
1270    }
1271
1272    /// Simple single-turn execution for basic queries
1273    ///
1274    /// # Errors
1275    ///
1276    /// Returns an error when the provider request fails.
1277    pub async fn chat(&self, message: &str) -> Result<String> {
1278        let agent = self.build_agent()?;
1279        let response = agent.prompt(message).await?;
1280        Ok(response)
1281    }
1282
1283    /// Set the current capability
1284    pub fn set_capability(&mut self, capability: &str) {
1285        self.current_capability = Some(capability.to_string());
1286    }
1287
1288    /// Get provider configuration
1289    #[must_use]
1290    pub fn provider_config(&self) -> &HashMap<String, String> {
1291        &self.provider_config
1292    }
1293
1294    /// Set provider configuration
1295    pub fn set_provider_config(&mut self, config: HashMap<String, String>) {
1296        self.provider_config = config;
1297    }
1298
1299    /// Set custom preamble
1300    pub fn set_preamble(&mut self, preamble: String) {
1301        self.preamble = Some(preamble);
1302    }
1303
1304    /// Set configuration
1305    pub fn set_config(&mut self, config: crate::config::Config) {
1306        self.config = Some(config);
1307    }
1308
1309    /// Set fast model for subagents
1310    pub fn set_fast_model(&mut self, fast_model: String) {
1311        self.fast_model = Some(fast_model);
1312    }
1313}
1314
1315/// Builder for creating `IrisAgent` instances with different configurations
1316pub struct IrisAgentBuilder {
1317    provider: String,
1318    model: String,
1319    preamble: Option<String>,
1320}
1321
1322impl IrisAgentBuilder {
1323    /// Create a new builder
1324    #[must_use]
1325    pub fn new() -> Self {
1326        Self {
1327            provider: "openai".to_string(),
1328            model: "gpt-5.4".to_string(),
1329            preamble: None,
1330        }
1331    }
1332
1333    /// Set the provider to use
1334    pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
1335        self.provider = provider.into();
1336        self
1337    }
1338
1339    /// Set the model to use
1340    pub fn with_model(mut self, model: impl Into<String>) -> Self {
1341        self.model = model.into();
1342        self
1343    }
1344
1345    /// Set a custom preamble
1346    pub fn with_preamble(mut self, preamble: impl Into<String>) -> Self {
1347        self.preamble = Some(preamble.into());
1348        self
1349    }
1350
1351    /// Build the `IrisAgent`
1352    ///
1353    /// # Errors
1354    ///
1355    /// Returns an error when the configured provider or model cannot build an agent.
1356    pub fn build(self) -> Result<IrisAgent> {
1357        let mut agent = IrisAgent::new(&self.provider, &self.model)?;
1358
1359        // Apply custom preamble if provided
1360        if let Some(preamble) = self.preamble {
1361            agent.set_preamble(preamble);
1362        }
1363
1364        Ok(agent)
1365    }
1366}
1367
1368impl Default for IrisAgentBuilder {
1369    fn default() -> Self {
1370        Self::new()
1371    }
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376    use super::{
1377        IrisAgent, extract_json_from_response, find_balanced_braces, sanitize_json_response,
1378        streaming_response_instructions,
1379    };
1380    use serde_json::Value;
1381    use std::borrow::Cow;
1382
1383    #[test]
1384    fn sanitize_json_response_is_noop_for_valid_payloads() {
1385        let raw = r#"{"title":"Test","description":"All good"}"#;
1386        let sanitized = sanitize_json_response(raw);
1387        assert!(matches!(sanitized, Cow::Borrowed(_)));
1388        serde_json::from_str::<Value>(sanitized.as_ref()).expect("valid JSON");
1389    }
1390
1391    #[test]
1392    fn sanitize_json_response_escapes_literal_newlines() {
1393        let raw = "{\"description\": \"Line1
1394Line2\"}";
1395        let sanitized = sanitize_json_response(raw);
1396        assert_eq!(sanitized.as_ref(), "{\"description\": \"Line1\\nLine2\"}");
1397        serde_json::from_str::<Value>(sanitized.as_ref()).expect("json sanitized");
1398    }
1399
1400    #[test]
1401    fn chat_streaming_instructions_avoid_markdown_suffix() {
1402        let instructions = streaming_response_instructions("chat");
1403        assert!(instructions.contains("plain text"));
1404        assert!(instructions.contains("do not repeat full content"));
1405        assert!(!instructions.contains("markdown format"));
1406    }
1407
1408    #[test]
1409    fn structured_streaming_instructions_still_use_markdown_suffix() {
1410        let instructions = streaming_response_instructions("review");
1411        assert!(instructions.contains("markdown format"));
1412        assert!(instructions.contains("well-structured"));
1413    }
1414
1415    #[test]
1416    fn find_balanced_braces_returns_first_balanced_pair() {
1417        let (start, end) = find_balanced_braces("prefix {\"a\":1} suffix").expect("balanced pair");
1418        assert_eq!(&"prefix {\"a\":1} suffix"[start..end], "{\"a\":1}");
1419    }
1420
1421    #[test]
1422    fn find_balanced_braces_returns_none_for_unbalanced() {
1423        assert_eq!(find_balanced_braces("no braces here"), None);
1424        assert_eq!(find_balanced_braces("{ unclosed"), None);
1425    }
1426
1427    #[test]
1428    fn extract_json_skips_github_actions_expression_false_positive() {
1429        // Regression for a real failure: a diff hunk that adds
1430        // `commit_message: "Update to ${{ github.ref_name }}"` to a workflow
1431        // lands in the model's response. The old scanner grabbed `{{ github.ref_name }}`
1432        // as its first balanced pair and errored out before seeing the real JSON.
1433        let response = r#"Looking at the diff, I see the new value `${{ github.ref_name }}` replacing the old bash expansion. Here's the commit:
1434
1435{"emoji": "🔧", "title": "Upgrade AUR deploy action", "message": "Bump to v4.1.2 to fix bash --command error."}
1436"#;
1437        let extracted = extract_json_from_response(response).expect("should recover real JSON");
1438        let parsed: Value = serde_json::from_str(&extracted).expect("extracted value is JSON");
1439        assert_eq!(parsed["emoji"], "🔧");
1440        assert_eq!(parsed["title"], "Upgrade AUR deploy action");
1441    }
1442
1443    #[test]
1444    fn extract_json_from_pure_json_response() {
1445        let response = r##"{"content": "# Heading\n\nBody text."}"##;
1446        let extracted = extract_json_from_response(response).expect("pure JSON passes through");
1447        assert_eq!(extracted, response);
1448    }
1449
1450    #[test]
1451    fn extract_json_errors_when_no_candidate_parses() {
1452        // A single malformed candidate and no other braces: we surface the
1453        // parse error with a preview so the user sees what went wrong.
1454        let response = "prose ${{ template }} more prose";
1455        let err = extract_json_from_response(response).expect_err("should fail");
1456        let msg = err.to_string();
1457        assert!(
1458            msg.contains("Preview:"),
1459            "error should include a preview: {msg}"
1460        );
1461    }
1462
1463    #[test]
1464    fn pr_review_emoji_styling_uses_a_compact_gitmoji_guide() {
1465        let mut prompt = String::new();
1466        IrisAgent::inject_pr_review_emoji_styling(&mut prompt);
1467
1468        assert!(prompt.contains("Common gitmoji choices:"));
1469        assert!(prompt.contains("`:feat:`"));
1470        assert!(prompt.contains("`:fix:`"));
1471        assert!(!prompt.contains("`:accessibility:`"));
1472        assert!(!prompt.contains("`:analytics:`"));
1473    }
1474}