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