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;
15use std::sync::OnceLock;
16
17/// Macro to build a streaming agent for any provider.
18///
19/// All three providers (`OpenAI`, `Anthropic`, `Gemini`) share identical setup logic —
20/// subagent creation, tool attachment, optional content update tools — differing
21/// only in the provider builder function. This macro eliminates that duplication.
22macro_rules! build_streaming_agent {
23    ($self:expr, $builder_fn:path, $fast_model:expr, $api_key:expr, $subagent_timeout:expr, $subagent_max_turns:expr) => {{
24        use crate::agents::debug_tool::DebugTool;
25
26        // Build subagent
27        let sub_builder = $builder_fn($fast_model, $api_key)?
28            .name("analyze_subagent")
29            .preamble("You are a specialized analysis sub-agent.");
30        let sub_builder = $self.apply_completion_params(
31            sub_builder,
32            $fast_model,
33            4096,
34            CompletionProfile::Subagent,
35        )?;
36        let sub_agent = crate::attach_core_tools!(sub_builder).build();
37
38        // Build main agent with tools
39        let builder = $builder_fn(&$self.model, $api_key)?
40            .preamble($self.preamble.as_deref().unwrap_or("You are Iris."));
41        let builder = $self.apply_completion_params(
42            builder,
43            &$self.model,
44            16384,
45            CompletionProfile::MainAgent,
46        )?;
47
48        let builder = crate::attach_core_tools!(builder)
49            .tool(DebugTool::new(GitRepoInfo))
50            .tool(DebugTool::new($self.workspace.clone()))
51            .tool(DebugTool::new(ParallelAnalyze::with_limits(
52                &$self.provider,
53                $fast_model,
54                $subagent_timeout,
55                $subagent_max_turns,
56                $api_key,
57                $self.current_provider_additional_params().cloned(),
58            )?))
59            .tool(sub_agent);
60
61        // Conditionally attach content update tools for chat mode
62        if let Some(sender) = &$self.content_update_sender {
63            use crate::agents::tools::{UpdateCommitTool, UpdatePRTool, UpdateReviewTool};
64            Ok(builder
65                .tool(DebugTool::new(UpdateCommitTool::new(sender.clone())))
66                .tool(DebugTool::new(UpdatePRTool::new(sender.clone())))
67                .tool(DebugTool::new(UpdateReviewTool::new(sender.clone())))
68                .build())
69        } else {
70            Ok(builder.build())
71        }
72    }};
73}
74
75// Embed capability TOML files at compile time so they're always available
76const CAPABILITY_COMMIT: &str = include_str!("capabilities/commit.toml");
77const CAPABILITY_PR: &str = include_str!("capabilities/pr.toml");
78const CAPABILITY_REVIEW: &str = include_str!("capabilities/review.toml");
79const CAPABILITY_CHANGELOG: &str = include_str!("capabilities/changelog.toml");
80const CAPABILITY_RELEASE_NOTES: &str = include_str!("capabilities/release_notes.toml");
81const CAPABILITY_CHAT: &str = include_str!("capabilities/chat.toml");
82const CAPABILITY_SEMANTIC_BLAME: &str = include_str!("capabilities/semantic_blame.toml");
83const CAPABILITY_VERIFY: &str = include_str!("capabilities/verify.toml");
84static VERIFY_CAPABILITY_CONFIG: OnceLock<(String, String)> = OnceLock::new();
85
86/// Default preamble for Iris agent
87const DEFAULT_PREAMBLE: &str = "\
88You are Iris, a helpful AI assistant specialized in Git operations and workflows.
89
90You have access to Git tools, code analysis tools, and powerful sub-agent capabilities for handling large analyses.
91
92**File Access Tools:**
93- **file_read** - Read file contents directly. Use `start_line` and `num_lines` for large files.
94- **project_docs** - Load a compact snapshot of README and agent instructions. Use targeted doc types for full docs when needed.
95- **code_search** - Search for patterns across files. Use sparingly; prefer file_read for known files.
96- **repo_map** - Build a compact ranked map of source files, definitions, imports, and changed-file signals.
97- **git_show** - Inspect a historical commit's message, stat, and patch.
98- **git_blame** - Get line-level history and recent commits touching a file.
99- **static_analysis** - Run installed linters directly for review evidence.
100
101**Sub-Agent Tools:**
102
1031. **parallel_analyze** - Run multiple analysis tasks CONCURRENTLY with independent context windows
104   - Best for: Large changesets (>500 lines or >20 files), batch commit analysis
105   - Each task runs in its own subagent, preventing context overflow
106   - Example: parallel_analyze({ \"tasks\": [\"Analyze auth/ changes for security\", \"Review db/ for performance\", \"Check api/ for breaking changes\"] })
107
1082. **analyze_subagent** - Delegate a single focused task to a sub-agent
109   - Best for: Deep dive on specific files or focused analysis
110
111**Best Practices:**
112- Use git_diff to get changes first - it includes file content
113- Use file_read to read files directly instead of multiple code_search calls
114- Use repo_map when you need repository structure or cross-file orientation before targeted reads
115- Use git_show after git_log or git_blame when a historical commit's exact patch would clarify intent or regression risk
116- Use git_blame when history, ownership, or prior intent would improve commit messages, PR descriptions, or semantic explanations
117- Use static_analysis during code review when linter/typechecker findings would sharpen or de-noise the review
118- Use project_docs when repository conventions or product framing matter; do not front-load docs if the diff already answers the question
119- Use parallel_analyze for large changesets to avoid context overflow
120
121**Voice and Tone (applies to all output):**
122
123Write directly. Avoid the common LLM tells that make output read as AI slop:
124
125- No em dashes (—). Use commas, colons, periods, or parentheses instead. Hyphens (-) in compound words are fine.
126- No hedge phrases like \"it's worth noting\", \"it's important to remember\", \"ultimately\", \"at the end of the day\", \"in essence\".
127- No filler intros or outros: \"I'd be happy to\", \"let me explain\", \"in conclusion\", \"overall\", \"to summarize\".
128- No hype vocabulary: \"robust\", \"comprehensive\", \"seamless\", \"leverage\", \"delve into\", \"unlock\", \"elevate\", \"powerful\", \"cutting-edge\", \"game-changing\".
129- No vague intensifiers (\"very\", \"really\", \"extremely\", \"quite\") and no tricolon padding (\"fast, reliable, and scalable\" when one adjective fits).
130- No meta-commentary openers: don't start with \"This commit adds...\", \"This PR introduces...\", \"This change refactors...\". Start with the verb: \"Add...\", \"Refactor...\".
131- No stacked emoji. One project-style emoji is plenty when the repo uses gitmoji; never combos like 🚀✨🎉.
132- \"In order to\" → \"to\". Prefer plain words over Latinate or marketing alternatives.
133
134If 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.";
135
136fn streaming_response_instructions(capability: &str) -> &'static str {
137    if capability == "chat" {
138        "After using the available tools, respond in plain text.\n\
139         Keep it concise and do not repeat full content that tools already updated."
140    } else {
141        "After using the available tools, respond with your analysis in markdown format.\n\
142         Keep it clear, well-structured, and informative."
143    }
144}
145
146use crate::agents::provider::{self, CompletionProfile, DynAgent};
147use crate::agents::tools::{GitRepoInfo, ParallelAnalyze, Workspace};
148
149/// Trait for streaming callback to handle real-time response processing
150#[async_trait::async_trait]
151pub trait StreamingCallback: Send + Sync {
152    /// Called when a new chunk of text is received
153    async fn on_chunk(
154        &self,
155        chunk: &str,
156        tokens: Option<crate::agents::status::TokenMetrics>,
157    ) -> Result<()>;
158
159    /// Called when the response is complete
160    async fn on_complete(
161        &self,
162        full_response: &str,
163        final_tokens: crate::agents::status::TokenMetrics,
164    ) -> Result<()>;
165
166    /// Called when an error occurs
167    async fn on_error(&self, error: &anyhow::Error) -> Result<()>;
168
169    /// Called for status updates
170    async fn on_status_update(&self, message: &str) -> Result<()>;
171}
172
173/// Unified response type that can hold any structured output
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub enum StructuredResponse {
176    CommitMessage(crate::types::GeneratedMessage),
177    PullRequest(crate::types::MarkdownPullRequest),
178    Changelog(crate::types::MarkdownChangelog),
179    ReleaseNotes(crate::types::MarkdownReleaseNotes),
180    /// Structured code review with parseable findings
181    Review(crate::types::Review),
182    /// Semantic blame explanation (plain text)
183    SemanticBlame(String),
184    PlainText(String),
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
188struct Critique {
189    #[serde(default)]
190    requires_revision: bool,
191    #[serde(default)]
192    issues: Vec<CritiqueIssue>,
193    #[serde(default)]
194    revision_prompt: String,
195    #[serde(default)]
196    confidence: u8,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
200struct CritiqueIssue {
201    title: String,
202    body: String,
203    severity: CritiqueSeverity,
204}
205
206#[derive(Debug, Clone, Copy, Serialize, JsonSchema, PartialEq, Eq)]
207#[serde(rename_all = "lowercase")]
208enum CritiqueSeverity {
209    Critical,
210    High,
211    Medium,
212    Low,
213}
214
215impl CritiqueSeverity {
216    fn from_model_value(value: &str) -> Self {
217        match value.trim().to_lowercase().as_str() {
218            "critical" => Self::Critical,
219            "high" => Self::High,
220            "low" => Self::Low,
221            _ => Self::Medium,
222        }
223    }
224}
225
226impl<'de> Deserialize<'de> for CritiqueSeverity {
227    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
228    where
229        D: serde::Deserializer<'de>,
230    {
231        let value = String::deserialize(deserializer)?;
232        Ok(Self::from_model_value(&value))
233    }
234}
235
236impl fmt::Display for CritiqueSeverity {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        match self {
239            Self::Critical => write!(f, "critical"),
240            Self::High => write!(f, "high"),
241            Self::Medium => write!(f, "medium"),
242            Self::Low => write!(f, "low"),
243        }
244    }
245}
246
247impl fmt::Display for StructuredResponse {
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        match self {
250            StructuredResponse::CommitMessage(msg) => {
251                write!(f, "{}", crate::types::format_commit_message(msg))
252            }
253            StructuredResponse::PullRequest(pr) => {
254                write!(f, "{}", pr.raw_content())
255            }
256            StructuredResponse::Changelog(cl) => {
257                write!(f, "{}", cl.raw_content())
258            }
259            StructuredResponse::ReleaseNotes(rn) => {
260                write!(f, "{}", rn.raw_content())
261            }
262            StructuredResponse::Review(review) => {
263                write!(f, "{}", review.format())
264            }
265            StructuredResponse::SemanticBlame(explanation) => {
266                write!(f, "{explanation}")
267            }
268            StructuredResponse::PlainText(text) => {
269                write!(f, "{text}")
270            }
271        }
272    }
273}
274
275/// Locate the first balanced `{ ... }` pair in `s`, returning `(start, end)` byte
276/// offsets where `end` is exclusive. Returns `None` if no balanced pair exists.
277///
278/// The scanner is intentionally simple — it does not track string literals, so
279/// braces embedded inside strings may still close an enclosing object. Callers
280/// compensate by trying subsequent candidates when parsing fails.
281fn find_balanced_braces(s: &str) -> Option<(usize, usize)> {
282    let mut depth: i32 = 0;
283    let mut start: Option<usize> = None;
284    for (i, ch) in s.char_indices() {
285        match ch {
286            '{' => {
287                if depth == 0 {
288                    start = Some(i);
289                }
290                depth += 1;
291            }
292            '}' if depth > 0 => {
293                depth -= 1;
294                if depth == 0 {
295                    return start.map(|s_idx| (s_idx, i + 1));
296                }
297            }
298            _ => {}
299        }
300    }
301    None
302}
303
304/// Extract JSON from a potentially verbose response that might contain explanations
305fn extract_json_from_response(response: &str) -> Result<String> {
306    use crate::agents::debug;
307
308    debug::debug_section("JSON Extraction");
309
310    let trimmed_response = response.trim();
311
312    // First, try parsing the entire response as JSON (for well-behaved responses)
313    if trimmed_response.starts_with('{')
314        && serde_json::from_str::<serde_json::Value>(trimmed_response).is_ok()
315    {
316        debug::debug_context_management(
317            "Response is pure JSON",
318            &format!("{} characters", trimmed_response.len()),
319        );
320        return Ok(trimmed_response.to_string());
321    }
322
323    // Try to find JSON within markdown code blocks
324    if let Some(start) = response.find("```json") {
325        let content_start = start + "```json".len();
326        // Find the closing ``` on its own line (to avoid matching ``` inside JSON strings)
327        // First try with newline prefix to find standalone closing marker
328        let json_end = if let Some(end) = response[content_start..].find("\n```") {
329            // Found it with newline - the JSON ends before the newline
330            end
331        } else {
332            // Fallback: try to find ``` at start of response section or end of string
333            response[content_start..]
334                .find("```")
335                .unwrap_or(response.len() - content_start)
336        };
337
338        let json_content = &response[content_start..content_start + json_end];
339        let trimmed = json_content.trim().to_string();
340
341        debug::debug_context_management(
342            "Found JSON in markdown code block",
343            &format!("{} characters", trimmed.len()),
344        );
345
346        // Save extracted JSON for debugging
347        if let Err(e) = debug::write_debug_artifact("iris_extracted.json", &trimmed) {
348            debug::debug_warning(&format!("Failed to write extracted JSON: {}", e));
349        }
350
351        debug::debug_json_parse_attempt(&trimmed);
352        return Ok(trimmed);
353    }
354
355    // Look for JSON objects by scanning for balanced `{ ... }` pairs.
356    //
357    // The response may contain several `{` characters that are NOT the real JSON
358    // payload — for example `${{ github.ref_name }}` lifted verbatim from a diff,
359    // or template placeholders the model echoes in its prose. We try each balanced
360    // candidate in order and return the first one that parses. If every candidate
361    // fails, we fall through with an error built from the last attempt.
362    let mut last_error: Option<anyhow::Error> = None;
363    let mut cursor = 0;
364    while cursor < response.len() {
365        let Some((rel_start, rel_end)) = find_balanced_braces(&response[cursor..]) else {
366            break;
367        };
368        let start = cursor + rel_start;
369        let end = cursor + rel_end;
370        let json_content = &response[start..end];
371        debug::debug_json_parse_attempt(json_content);
372
373        let sanitized = sanitize_json_response(json_content);
374        match serde_json::from_str::<serde_json::Value>(&sanitized) {
375            Ok(_) => {
376                debug::debug_context_management(
377                    "Found valid JSON object",
378                    &format!("{} characters", json_content.len()),
379                );
380                return Ok(sanitized.into_owned());
381            }
382            Err(e) => {
383                debug::debug_json_parse_error(&format!(
384                    "Candidate at offset {} is not valid JSON: {}",
385                    start, e
386                ));
387                let preview = if json_content.len() > 200 {
388                    format!("{}...", &json_content[..200])
389                } else {
390                    json_content.to_string()
391                };
392                last_error = Some(anyhow::anyhow!(
393                    "Found JSON-like content but it's not valid JSON: {}\nPreview: {}",
394                    e,
395                    preview
396                ));
397                // Advance past the opening brace of this failed candidate so we
398                // can try the next `{` in the response.
399                cursor = start + 1;
400            }
401        }
402    }
403
404    if let Some(err) = last_error {
405        return Err(err);
406    }
407
408    // If no JSON found, check if the response is raw markdown that we can wrap
409    // This handles cases where the model returns markdown directly without JSON wrapper
410    let trimmed = response.trim();
411    if trimmed.starts_with('#') || trimmed.starts_with("##") {
412        debug::debug_context_management(
413            "Detected raw markdown response",
414            "Wrapping in JSON structure",
415        );
416        // Escape the markdown content for JSON and wrap it
417        let escaped_content = serde_json::to_string(trimmed)?;
418        // escaped_content includes quotes, so we need to use it directly as the value
419        let wrapped = format!(r#"{{"content": {}}}"#, escaped_content);
420        debug::debug_json_parse_attempt(&wrapped);
421        return Ok(wrapped);
422    }
423
424    // If no JSON found, return error
425    debug::debug_json_parse_error("No valid JSON found in response");
426    Err(anyhow::anyhow!("No valid JSON found in response"))
427}
428
429/// Some providers (Anthropic) occasionally send literal control characters like newlines
430/// inside JSON strings, which violates strict JSON parsing rules. This helper sanitizes
431/// those responses by escaping control characters only within string literals while
432/// leaving the rest of the payload untouched.
433fn sanitize_json_response(raw: &str) -> Cow<'_, str> {
434    let mut needs_sanitization = false;
435    let mut in_string = false;
436    let mut escaped = false;
437
438    for ch in raw.chars() {
439        if in_string {
440            if escaped {
441                escaped = false;
442                continue;
443            }
444
445            match ch {
446                '\\' => escaped = true,
447                '"' => in_string = false,
448                '\n' | '\r' | '\t' => {
449                    needs_sanitization = true;
450                    break;
451                }
452                c if c.is_control() => {
453                    needs_sanitization = true;
454                    break;
455                }
456                _ => {}
457            }
458        } else if ch == '"' {
459            in_string = true;
460        }
461    }
462
463    if !needs_sanitization {
464        return Cow::Borrowed(raw);
465    }
466
467    let mut sanitized = String::with_capacity(raw.len());
468    in_string = false;
469    escaped = false;
470
471    for ch in raw.chars() {
472        if in_string {
473            if escaped {
474                sanitized.push(ch);
475                escaped = false;
476                continue;
477            }
478
479            match ch {
480                '\\' => {
481                    sanitized.push('\\');
482                    escaped = true;
483                }
484                '"' => {
485                    sanitized.push('"');
486                    in_string = false;
487                }
488                '\n' => sanitized.push_str("\\n"),
489                '\r' => sanitized.push_str("\\r"),
490                '\t' => sanitized.push_str("\\t"),
491                c if c.is_control() => {
492                    use std::fmt::Write as _;
493                    let _ = write!(&mut sanitized, "\\u{:04X}", u32::from(c));
494                }
495                _ => sanitized.push(ch),
496            }
497        } else {
498            sanitized.push(ch);
499            if ch == '"' {
500                in_string = true;
501                escaped = false;
502            }
503        }
504    }
505
506    Cow::Owned(sanitized)
507}
508
509/// Parse JSON with schema validation and error recovery
510///
511/// This function attempts to parse JSON with the following strategy:
512/// 1. Try direct parsing (fast path for well-formed responses)
513/// 2. If that fails, use the output validator for recovery
514/// 3. Log any warnings about recovered issues
515fn parse_with_recovery<T>(json_str: &str) -> Result<T>
516where
517    T: JsonSchema + DeserializeOwned,
518{
519    use crate::agents::debug as agent_debug;
520    use crate::agents::output_validator::validate_and_parse;
521
522    let validation_result = validate_and_parse::<T>(json_str)?;
523
524    // Log recovery warnings
525    if validation_result.recovered {
526        agent_debug::debug_context_management(
527            "JSON recovery applied",
528            &format!("{} issues fixed", validation_result.warnings.len()),
529        );
530        for warning in &validation_result.warnings {
531            agent_debug::debug_warning(warning);
532        }
533    }
534
535    validation_result
536        .value
537        .ok_or_else(|| anyhow::anyhow!("Failed to parse JSON even after recovery"))
538}
539
540/// The unified Iris agent that can handle any Git-Iris task
541///
542/// Note: This struct is Send + Sync safe - we don't store the client builder,
543/// instead we create it fresh when needed. This allows the agent to be used
544/// across async boundaries with `tokio::spawn`.
545pub struct IrisAgent {
546    provider: String,
547    model: String,
548    /// Fast model for subagents and simple tasks
549    fast_model: Option<String>,
550    /// Current capability/task being executed
551    current_capability: Option<String>,
552    /// Provider configuration
553    provider_config: HashMap<String, String>,
554    /// Custom preamble
555    preamble: Option<String>,
556    /// Configuration for features like gitmoji, presets, etc.
557    config: Option<crate::config::Config>,
558    /// Optional sender for content updates (used in Studio chat mode)
559    content_update_sender: Option<crate::agents::tools::ContentUpdateSender>,
560    /// Persistent workspace for notes and task tracking (shared across agent invocations)
561    workspace: Workspace,
562}
563
564impl IrisAgent {
565    /// Create a new Iris agent with the given provider and model
566    ///
567    /// # Errors
568    ///
569    /// Returns an error when the provider or model configuration is invalid.
570    pub fn new(provider: &str, model: &str) -> Result<Self> {
571        Ok(Self {
572            provider: provider.to_string(),
573            model: model.to_string(),
574            fast_model: None,
575            current_capability: None,
576            provider_config: HashMap::new(),
577            preamble: None,
578            config: None,
579            content_update_sender: None,
580            workspace: Workspace::new(),
581        })
582    }
583
584    /// Set the content update sender for Studio chat mode
585    ///
586    /// When set, the agent will have access to tools for updating
587    /// commit messages, PR descriptions, and reviews.
588    pub fn set_content_update_sender(&mut self, sender: crate::agents::tools::ContentUpdateSender) {
589        self.content_update_sender = Some(sender);
590    }
591
592    /// Get the effective fast model (configured or same as main model)
593    fn effective_fast_model(&self) -> &str {
594        self.fast_model.as_deref().unwrap_or(&self.model)
595    }
596
597    /// Get the API key for the current provider from config
598    fn get_api_key(&self) -> Option<&str> {
599        provider::current_provider_config(self.config.as_ref(), &self.provider)
600            .and_then(crate::providers::ProviderConfig::api_key_if_set)
601    }
602
603    fn current_provider(&self) -> Result<crate::providers::Provider> {
604        provider::provider_from_name(&self.provider)
605    }
606
607    fn current_provider_additional_params(&self) -> Option<&HashMap<String, String>> {
608        provider::current_provider_config(self.config.as_ref(), &self.provider)
609            .map(|provider_config| &provider_config.additional_params)
610    }
611
612    /// Build the actual agent for execution
613    ///
614    /// Uses provider-specific builders (rig-core 0.27+) with enum dispatch for runtime
615    /// provider selection. Each provider arm builds both the subagent and main agent
616    /// with proper typing.
617    #[allow(clippy::too_many_lines)]
618    fn build_agent(&self) -> Result<DynAgent> {
619        use crate::agents::debug_tool::DebugTool;
620
621        let preamble = self.preamble.as_deref().unwrap_or(DEFAULT_PREAMBLE);
622        let fast_model = self.effective_fast_model();
623        let api_key = self.get_api_key();
624        let subagent_timeout = self
625            .config
626            .as_ref()
627            .map_or(120, |c| c.subagent_timeout_secs);
628        let subagent_max_turns = self.config.as_ref().map_or(20, |c| c.subagent_max_turns);
629
630        // Macro to build and configure subagent with core tools
631        macro_rules! build_subagent {
632            ($builder:expr) => {{
633                let builder = $builder
634                    .name("analyze_subagent")
635                    .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.")
636                    .preamble("You are a specialized analysis sub-agent for Iris. Your job is to complete focused analysis tasks and return concise, actionable summaries.
637
638Guidelines:
639- Use the available tools to gather information
640- Focus only on what's asked - don't expand scope
641- Return a clear, structured summary of findings
642- Highlight important issues, patterns, or insights
643- Keep your response focused and concise")
644                    ;
645                let builder = self.apply_completion_params(
646                    builder,
647                    fast_model,
648                    4096,
649                    CompletionProfile::Subagent,
650                )?;
651                crate::attach_core_tools!(builder).build()
652            }};
653        }
654
655        // Macro to attach main agent tools (excluding subagent which varies by type)
656        macro_rules! attach_main_tools {
657            ($builder:expr) => {{
658                crate::attach_core_tools!($builder)
659                    .tool(DebugTool::new(GitRepoInfo))
660                    .tool(DebugTool::new(self.workspace.clone()))
661                    .tool(DebugTool::new(ParallelAnalyze::with_limits(
662                        &self.provider,
663                        fast_model,
664                        subagent_timeout,
665                        subagent_max_turns,
666                        api_key,
667                        self.current_provider_additional_params().cloned(),
668                    )?))
669            }};
670        }
671
672        // Macro to optionally attach content update tools
673        macro_rules! maybe_attach_update_tools {
674            ($builder:expr) => {{
675                if let Some(sender) = &self.content_update_sender {
676                    use crate::agents::tools::{UpdateCommitTool, UpdatePRTool, UpdateReviewTool};
677                    $builder
678                        .tool(DebugTool::new(UpdateCommitTool::new(sender.clone())))
679                        .tool(DebugTool::new(UpdatePRTool::new(sender.clone())))
680                        .tool(DebugTool::new(UpdateReviewTool::new(sender.clone())))
681                        .build()
682                } else {
683                    $builder.build()
684                }
685            }};
686        }
687
688        match self.provider.as_str() {
689            "openai" => {
690                // Build subagent
691                let sub_agent = build_subagent!(provider::openai_builder(fast_model, api_key)?);
692
693                // Build main agent
694                let builder = provider::openai_builder(&self.model, api_key)?.preamble(preamble);
695                let builder = self.apply_completion_params(
696                    builder,
697                    &self.model,
698                    16384,
699                    CompletionProfile::MainAgent,
700                )?;
701                let builder = attach_main_tools!(builder).tool(sub_agent);
702                let agent = maybe_attach_update_tools!(builder);
703                Ok(DynAgent::OpenAI(agent))
704            }
705            "anthropic" => {
706                // Build subagent
707                let sub_agent = build_subagent!(provider::anthropic_builder(fast_model, api_key)?);
708
709                // Build main agent
710                let builder = provider::anthropic_builder(&self.model, api_key)?.preamble(preamble);
711                let builder = self.apply_completion_params(
712                    builder,
713                    &self.model,
714                    16384,
715                    CompletionProfile::MainAgent,
716                )?;
717                let builder = attach_main_tools!(builder).tool(sub_agent);
718                let agent = maybe_attach_update_tools!(builder);
719                Ok(DynAgent::Anthropic(agent))
720            }
721            "google" | "gemini" => {
722                // Build subagent
723                let sub_agent = build_subagent!(provider::gemini_builder(fast_model, api_key)?);
724
725                // Build main agent
726                let builder = provider::gemini_builder(&self.model, api_key)?.preamble(preamble);
727                let builder = self.apply_completion_params(
728                    builder,
729                    &self.model,
730                    16384,
731                    CompletionProfile::MainAgent,
732                )?;
733                let builder = attach_main_tools!(builder).tool(sub_agent);
734                let agent = maybe_attach_update_tools!(builder);
735                Ok(DynAgent::Gemini(agent))
736            }
737            _ => Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
738        }
739    }
740
741    fn apply_completion_params<M>(
742        &self,
743        builder: AgentBuilder<M>,
744        model: &str,
745        max_tokens: u64,
746        profile: CompletionProfile,
747    ) -> Result<AgentBuilder<M>>
748    where
749        M: CompletionModel,
750    {
751        let provider = self.current_provider()?;
752        Ok(provider::apply_completion_params(
753            builder,
754            provider,
755            model,
756            max_tokens,
757            self.current_provider_additional_params(),
758            profile,
759        ))
760    }
761
762    /// Execute task using agent with tools and parse structured JSON response
763    /// This is the core method that enables Iris to use tools and generate structured outputs
764    async fn execute_with_agent<T>(&self, system_prompt: &str, user_prompt: &str) -> Result<T>
765    where
766        T: JsonSchema + for<'a> serde::Deserialize<'a> + serde::Serialize + Send + Sync + 'static,
767    {
768        use crate::agents::debug;
769        use crate::agents::status::IrisPhase;
770        use crate::messages::get_capability_message;
771        use schemars::schema_for;
772
773        let capability = self.current_capability().unwrap_or("commit");
774
775        debug::debug_phase_change(&format!("AGENT EXECUTION: {}", std::any::type_name::<T>()));
776
777        // Update status - building agent (capability-aware)
778        let msg = get_capability_message(capability);
779        crate::iris_status_dynamic!(IrisPhase::Planning, msg.text, 2, 4);
780
781        // Build agent with all tools attached
782        let agent = self.build_agent()?;
783        debug::debug_context_management(
784            "Agent built with tools",
785            &format!(
786                "Provider: {}, Model: {} (fast: {})",
787                self.provider,
788                self.model,
789                self.effective_fast_model()
790            ),
791        );
792
793        // Create JSON schema for the response type
794        let schema = schema_for!(T);
795        let schema_json = serde_json::to_string_pretty(&schema)?;
796        debug::debug_context_management(
797            "JSON schema created",
798            &format!("Type: {}", std::any::type_name::<T>()),
799        );
800
801        // Enhanced prompt that instructs Iris to use tools and respond with JSON
802        let full_prompt = format!(
803            "{system_prompt}\n\n{user_prompt}\n\n\
804            === CRITICAL: RESPONSE FORMAT ===\n\
805            After using the available tools to gather necessary information, you MUST respond with ONLY a valid JSON object.\n\n\
806            REQUIRED JSON SCHEMA:\n\
807            {schema_json}\n\n\
808            CRITICAL INSTRUCTIONS:\n\
809            - Return ONLY the raw JSON object - nothing else\n\
810            - NO explanations before the JSON\n\
811            - NO explanations after the JSON\n\
812            - NO markdown code blocks (just raw JSON)\n\
813            - NO preamble text like 'Here is the JSON:' or 'Let me generate:'\n\
814            - Start your response with {{ and end with }}\n\
815            - The JSON must be complete and valid\n\n\
816            Your entire response should be ONLY the JSON object."
817        );
818
819        debug::debug_llm_request(&full_prompt, Some(16384));
820
821        // Update status - generation phase (capability-aware)
822        let gen_msg = get_capability_message(capability);
823        crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
824
825        // Prompt the agent with multi-turn support
826        // Set multi_turn to allow the agent to call multiple tools (default is 0 = single-shot)
827        // For complex tasks like PRs and release notes, Iris may need many tool calls to analyze all changes
828        // The agent knows when to stop, so we give it plenty of room (50 rounds)
829        let timer = debug::DebugTimer::start("Agent prompt execution");
830
831        debug::debug_context_management(
832            "LLM request",
833            "Sending prompt to agent with multi_turn(50)",
834        );
835        let prompt_response: PromptResponse = agent.prompt_extended(&full_prompt, 50).await?;
836
837        timer.finish();
838
839        // Extract usage stats for debug output
840        let usage = &prompt_response.usage;
841        debug::debug_context_management(
842            "Token usage",
843            &format!(
844                "input: {} | output: {} | total: {} | cache write: {} | cache read: {}",
845                usage.input_tokens,
846                usage.output_tokens,
847                usage.total_tokens,
848                usage.cache_creation_input_tokens,
849                usage.cached_input_tokens,
850            ),
851        );
852
853        let response = &prompt_response.output;
854        #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
855        let total_tokens_usize = usage.total_tokens as usize;
856        debug::debug_llm_response(
857            response,
858            std::time::Duration::from_secs(0),
859            Some(total_tokens_usize),
860        );
861
862        // Update status - synthesis phase
863        crate::iris_status_dynamic!(
864            IrisPhase::Synthesis,
865            "✨ Iris is synthesizing results...",
866            4,
867            4
868        );
869
870        // Extract and parse JSON from the response
871        let json_str = extract_json_from_response(response)?;
872        let sanitized_json = sanitize_json_response(&json_str);
873        let sanitized_ref = sanitized_json.as_ref();
874
875        if matches!(sanitized_json, Cow::Borrowed(_)) {
876            debug::debug_json_parse_attempt(sanitized_ref);
877        } else {
878            debug::debug_context_management(
879                "Sanitized JSON response",
880                &format!("{} → {} characters", json_str.len(), sanitized_ref.len()),
881            );
882            debug::debug_json_parse_attempt(sanitized_ref);
883        }
884
885        // Use the output validator for robust parsing with error recovery
886        let result: T = parse_with_recovery(sanitized_ref)?;
887
888        debug::debug_json_parse_success(std::any::type_name::<T>());
889
890        // Update status - completed
891        crate::iris_status_completed!();
892
893        Ok(result)
894    }
895
896    /// Inject style instructions into the system prompt based on config and capability
897    ///
898    /// Key distinction:
899    /// - Commits: preset controls format (conventional = no emojis)
900    /// - Non-commits (PR, review, changelog, `release_notes`): `use_gitmoji` controls emojis
901    fn inject_style_instructions(&self, system_prompt: &mut String, capability: &str) {
902        let Some(config) = &self.config else {
903            return;
904        };
905
906        let preset_name = config.get_effective_preset_name();
907        let is_conventional = preset_name == "conventional";
908        let is_default_mode = preset_name == "default" || preset_name.is_empty();
909        let use_style_detection =
910            capability == "commit" && is_default_mode && config.gitmoji_override.is_none();
911        let commit_emoji = config.use_gitmoji && !is_conventional && !use_style_detection;
912        let output_emoji = config.gitmoji_override.unwrap_or(config.use_gitmoji);
913
914        Self::inject_instruction_preset(system_prompt, preset_name, is_default_mode);
915
916        if capability == "commit" {
917            Self::inject_commit_styling(system_prompt, commit_emoji, is_conventional);
918        }
919
920        Self::inject_markdown_output_styling(system_prompt, capability, output_emoji);
921    }
922
923    fn inject_instruction_preset(
924        system_prompt: &mut String,
925        preset_name: &str,
926        is_default_mode: bool,
927    ) {
928        if preset_name.is_empty() || is_default_mode {
929            return;
930        }
931
932        let library = crate::instruction_presets::get_instruction_preset_library();
933        if let Some(preset) = library.get_preset(preset_name) {
934            tracing::info!("📋 Injecting '{}' preset style instructions", preset_name);
935            system_prompt.push_str("\n\n=== STYLE INSTRUCTIONS ===\n");
936            system_prompt.push_str(&preset.instructions);
937            system_prompt.push('\n');
938        } else {
939            tracing::warn!("⚠️ Preset '{}' not found in library", preset_name);
940        }
941    }
942
943    fn inject_commit_styling(
944        system_prompt: &mut String,
945        commit_emoji: bool,
946        is_conventional: bool,
947    ) {
948        if commit_emoji {
949            system_prompt.push_str("\n\n=== GITMOJI INSTRUCTIONS ===\n");
950            system_prompt.push_str("Set the 'emoji' field to a single relevant gitmoji. ");
951            system_prompt.push_str(
952                "DO NOT include the emoji in the 'message' or 'title' text - only set the 'emoji' field. ",
953            );
954            system_prompt.push_str("Choose the closest match from this compact guide:\n\n");
955            system_prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
956            system_prompt.push_str("\n\nThe emoji should match the primary type of change.");
957        } else if is_conventional {
958            system_prompt.push_str("\n\n=== CONVENTIONAL COMMITS FORMAT ===\n");
959            system_prompt.push_str("IMPORTANT: This uses Conventional Commits format. ");
960            system_prompt.push_str("DO NOT include any emojis in the commit message or PR title. ");
961            system_prompt.push_str("The 'emoji' field should be null.");
962        }
963    }
964
965    fn inject_markdown_output_styling(
966        system_prompt: &mut String,
967        capability: &str,
968        output_emoji: bool,
969    ) {
970        match (capability, output_emoji) {
971            ("pr" | "review", true) => Self::inject_pr_review_emoji_styling(system_prompt),
972            ("release_notes", true) => Self::inject_release_notes_emoji_styling(system_prompt),
973            ("changelog", true) => Self::inject_changelog_emoji_styling(system_prompt),
974            ("pr" | "review" | "release_notes" | "changelog", false) => {
975                Self::inject_no_emoji_styling(system_prompt);
976            }
977            _ => {}
978        }
979    }
980
981    fn inject_pr_review_emoji_styling(prompt: &mut String) {
982        prompt.push_str("\n\n=== EMOJI STYLING ===\n");
983        prompt.push_str("Use emojis to make the output visually scannable and engaging:\n");
984        prompt.push_str("- H1 title: ONE gitmoji at the start (✨, 🐛, ♻️, etc.)\n");
985        prompt.push_str("- Section headers: Add relevant emojis (🎯 What's New, ⚙️ How It Works, 📋 Commits, ⚠️ Breaking Changes)\n");
986        prompt.push_str("- Commit list entries: Include gitmoji where appropriate\n");
987        prompt.push_str("- Body text: Keep clean - no scattered emojis within prose\n\n");
988        prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
989    }
990
991    fn inject_release_notes_emoji_styling(prompt: &mut String) {
992        prompt.push_str("\n\n=== EMOJI STYLING ===\n");
993        prompt.push_str("Use at most one emoji per highlight/section title. No emojis in bullet descriptions, upgrade notes, or metrics. ");
994        prompt.push_str("Pick from the approved gitmoji list (e.g., 🌟 Highlights, 🤖 Agents, 🔧 Tooling, 🐛 Fixes, ⚡ Performance). ");
995        prompt.push_str("Never sprinkle emojis within sentences or JSON keys.\n\n");
996        prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
997    }
998
999    fn inject_changelog_emoji_styling(prompt: &mut String) {
1000        prompt.push_str("\n\n=== EMOJI STYLING ===\n");
1001        prompt.push_str("Section keys must remain plain text (Added/Changed/Deprecated/Removed/Fixed/Security). ");
1002        prompt.push_str(
1003            "You may include one emoji within a change description to reinforce meaning. ",
1004        );
1005        prompt.push_str(
1006            "Never add emojis to JSON keys, section names, metrics, or upgrade notes.\n\n",
1007        );
1008        prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
1009    }
1010
1011    fn inject_no_emoji_styling(prompt: &mut String) {
1012        prompt.push_str("\n\n=== NO EMOJI STYLING ===\n");
1013        prompt.push_str(
1014            "DO NOT include any emojis anywhere in the output. Keep all content plain text.",
1015        );
1016    }
1017
1018    /// Execute a task with the given capability and user prompt
1019    ///
1020    /// This now automatically uses structured output based on the capability type
1021    ///
1022    /// # Errors
1023    ///
1024    /// Returns an error when capability loading, agent construction, or generation fails.
1025    pub async fn execute_task(
1026        &mut self,
1027        capability: &str,
1028        user_prompt: &str,
1029    ) -> Result<StructuredResponse> {
1030        use crate::agents::status::IrisPhase;
1031        use crate::messages::get_capability_message;
1032
1033        // Show initializing status with a capability-specific message
1034        let waiting_msg = get_capability_message(capability);
1035        crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
1036
1037        // Load the capability config to get both prompt and output type
1038        let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
1039
1040        // Inject style instructions (presets, gitmoji, conventional commits)
1041        self.inject_style_instructions(&mut system_prompt, capability);
1042
1043        // Set the current capability
1044        self.current_capability = Some(capability.to_string());
1045
1046        // Update status - analyzing with agent
1047        crate::iris_status_dynamic!(
1048            IrisPhase::Analysis,
1049            "🔍 Iris is analyzing your changes...",
1050            2,
1051            4
1052        );
1053
1054        let response = self
1055            .execute_output_type(&output_type, &system_prompt, user_prompt)
1056            .await?;
1057
1058        self.verify_response_if_enabled(
1059            capability,
1060            &output_type,
1061            &system_prompt,
1062            user_prompt,
1063            response,
1064        )
1065        .await
1066    }
1067
1068    async fn execute_output_type(
1069        &self,
1070        output_type: &str,
1071        system_prompt: &str,
1072        user_prompt: &str,
1073    ) -> Result<StructuredResponse> {
1074        match output_type {
1075            "GeneratedMessage" => {
1076                let response = self
1077                    .execute_with_agent::<crate::types::GeneratedMessage>(
1078                        system_prompt,
1079                        user_prompt,
1080                    )
1081                    .await?;
1082                Ok(StructuredResponse::CommitMessage(response))
1083            }
1084            "MarkdownPullRequest" => {
1085                let response = self
1086                    .execute_with_agent::<crate::types::MarkdownPullRequest>(
1087                        system_prompt,
1088                        user_prompt,
1089                    )
1090                    .await?;
1091                Ok(StructuredResponse::PullRequest(response))
1092            }
1093            "MarkdownChangelog" => {
1094                let response = self
1095                    .execute_with_agent::<crate::types::MarkdownChangelog>(
1096                        system_prompt,
1097                        user_prompt,
1098                    )
1099                    .await?;
1100                Ok(StructuredResponse::Changelog(response))
1101            }
1102            "MarkdownReleaseNotes" => {
1103                let response = self
1104                    .execute_with_agent::<crate::types::MarkdownReleaseNotes>(
1105                        system_prompt,
1106                        user_prompt,
1107                    )
1108                    .await?;
1109                Ok(StructuredResponse::ReleaseNotes(response))
1110            }
1111            "Review" => {
1112                let response = self
1113                    .execute_with_agent::<crate::types::Review>(system_prompt, user_prompt)
1114                    .await?;
1115                Ok(StructuredResponse::Review(response))
1116            }
1117            "SemanticBlame" => {
1118                let agent = self.build_agent()?;
1119                let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
1120                let response = agent.prompt_multi_turn(&full_prompt, 10).await?;
1121                Ok(StructuredResponse::SemanticBlame(response))
1122            }
1123            _ => {
1124                let agent = self.build_agent()?;
1125                let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
1126                let response = agent.prompt_multi_turn(&full_prompt, 50).await?;
1127                Ok(StructuredResponse::PlainText(response))
1128            }
1129        }
1130    }
1131
1132    async fn verify_response_if_enabled(
1133        &self,
1134        capability: &str,
1135        output_type: &str,
1136        system_prompt: &str,
1137        user_prompt: &str,
1138        response: StructuredResponse,
1139    ) -> Result<StructuredResponse> {
1140        if !self.should_run_critic(capability, output_type) {
1141            return Ok(response);
1142        }
1143
1144        let (critic_prompt, critic_output_type) = match self.load_capability_config("verify") {
1145            Ok(config) => config,
1146            Err(error) => {
1147                crate::agents::debug::debug_warning(&format!(
1148                    "Critic pass skipped: failed to load verify capability: {error}"
1149                ));
1150                return Ok(response);
1151            }
1152        };
1153        if critic_output_type != "Critique" {
1154            crate::agents::debug::debug_warning(&format!(
1155                "Critic pass skipped: verify capability returned unexpected output_type {critic_output_type}"
1156            ));
1157            return Ok(response);
1158        }
1159
1160        let critic_task = Self::build_critic_task(capability, user_prompt, &response);
1161        let critique = match self
1162            .execute_with_agent::<Critique>(&critic_prompt, &critic_task)
1163            .await
1164        {
1165            Ok(critique) => critique,
1166            Err(error) => {
1167                crate::agents::debug::debug_warning(&format!(
1168                    "Critic pass skipped after generation succeeded: {error}"
1169                ));
1170                return Ok(response);
1171            }
1172        };
1173
1174        if !critique.requires_revision {
1175            return Ok(response);
1176        }
1177
1178        if critique.revision_prompt.trim().is_empty() && critique.issues.is_empty() {
1179            crate::agents::debug::debug_warning(
1180                "Critic requested a revision without issues or revision_prompt; keeping original artifact",
1181            );
1182            return Ok(response);
1183        }
1184
1185        let revised_prompt = Self::build_revision_prompt(user_prompt, &critique);
1186        self.execute_output_type(output_type, system_prompt, &revised_prompt)
1187            .await
1188    }
1189
1190    fn should_run_critic(&self, capability: &str, output_type: &str) -> bool {
1191        let critic_enabled = self
1192            .config
1193            .as_ref()
1194            .is_none_or(|config| config.critic_enabled);
1195
1196        critic_enabled
1197            && matches!(
1198                (capability, output_type),
1199                ("commit", "GeneratedMessage")
1200                    | ("review", "Review")
1201                    | ("pr", "MarkdownPullRequest")
1202                    | ("changelog", "MarkdownChangelog")
1203                    | ("release_notes", "MarkdownReleaseNotes")
1204            )
1205    }
1206
1207    fn build_critic_task(
1208        capability: &str,
1209        user_prompt: &str,
1210        response: &StructuredResponse,
1211    ) -> String {
1212        let artifact = Self::serialize_artifact_for_critic(response);
1213        format!(
1214            "## Capability\n{capability}\n\n## Original Task\n{user_prompt}\n\n## Generated Artifact\n```json\n{artifact}\n```"
1215        )
1216    }
1217
1218    fn serialize_artifact_for_critic(response: &StructuredResponse) -> String {
1219        match response {
1220            StructuredResponse::CommitMessage(message) => serde_json::to_string_pretty(message),
1221            StructuredResponse::PullRequest(pr) => serde_json::to_string_pretty(pr),
1222            StructuredResponse::Changelog(changelog) => serde_json::to_string_pretty(changelog),
1223            StructuredResponse::ReleaseNotes(notes) => serde_json::to_string_pretty(notes),
1224            StructuredResponse::Review(review) => serde_json::to_string_pretty(review),
1225            StructuredResponse::SemanticBlame(text) | StructuredResponse::PlainText(text) => {
1226                serde_json::to_string_pretty(text)
1227            }
1228        }
1229        .unwrap_or_else(|_| response.to_string())
1230    }
1231
1232    fn build_revision_prompt(user_prompt: &str, critique: &Critique) -> String {
1233        let issues = if critique.issues.is_empty() {
1234            String::new()
1235        } else {
1236            format!(
1237                "\n\nIssues:\n{}",
1238                critique
1239                    .issues
1240                    .iter()
1241                    .map(|issue| format!("- [{}] {}: {}", issue.severity, issue.title, issue.body))
1242                    .collect::<Vec<_>>()
1243                    .join("\n")
1244            )
1245        };
1246        let revision_prompt = if critique.revision_prompt.trim().is_empty() {
1247            "Address the material issues listed above."
1248        } else {
1249            critique.revision_prompt.trim()
1250        };
1251        format!(
1252            "{user_prompt}\n\n## Critic Feedback\nThe first draft contained unsupported or misleading claims. Regenerate the artifact once, preserving the original task and fixing these issues.{issues}\n\nRevision instruction:\n{}",
1253            revision_prompt
1254        )
1255    }
1256
1257    /// Execute a task with streaming, calling the callback with each text chunk
1258    ///
1259    /// This enables real-time display of LLM output in the TUI.
1260    /// The callback receives `(chunk, aggregated_text)` for each delta.
1261    ///
1262    /// Returns the final structured response after streaming completes.
1263    ///
1264    /// # Errors
1265    ///
1266    /// Returns an error when capability loading, agent construction, or streaming fails.
1267    pub async fn execute_task_streaming<F>(
1268        &mut self,
1269        capability: &str,
1270        user_prompt: &str,
1271        mut on_chunk: F,
1272    ) -> Result<StructuredResponse>
1273    where
1274        F: FnMut(&str, &str) + Send,
1275    {
1276        use crate::agents::status::IrisPhase;
1277        use crate::messages::get_capability_message;
1278        use futures::StreamExt;
1279        use rig::agent::MultiTurnStreamItem;
1280        use rig::streaming::{StreamedAssistantContent, StreamingPrompt};
1281
1282        // Show initializing status
1283        let waiting_msg = get_capability_message(capability);
1284        crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
1285
1286        // Load the capability config
1287        let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
1288
1289        // Inject style instructions
1290        self.inject_style_instructions(&mut system_prompt, capability);
1291
1292        // Set current capability
1293        self.current_capability = Some(capability.to_string());
1294
1295        // Update status
1296        crate::iris_status_dynamic!(
1297            IrisPhase::Analysis,
1298            "🔍 Iris is analyzing your changes...",
1299            2,
1300            4
1301        );
1302
1303        // Build the full prompt (simplified for streaming - no JSON schema enforcement)
1304        let full_prompt = format!(
1305            "{}\n\n{}\n\n{}",
1306            system_prompt,
1307            user_prompt,
1308            streaming_response_instructions(capability)
1309        );
1310
1311        // Update status
1312        let gen_msg = get_capability_message(capability);
1313        crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
1314
1315        // Macro to consume a stream and aggregate text
1316        macro_rules! consume_stream {
1317            ($stream:expr) => {{
1318                let mut aggregated_text = String::new();
1319                let mut stream = $stream;
1320                while let Some(item) = stream.next().await {
1321                    match item {
1322                        Ok(MultiTurnStreamItem::StreamAssistantItem(
1323                            StreamedAssistantContent::Text(text),
1324                        )) => {
1325                            aggregated_text.push_str(&text.text);
1326                            on_chunk(&text.text, &aggregated_text);
1327                        }
1328                        Ok(MultiTurnStreamItem::StreamAssistantItem(
1329                            StreamedAssistantContent::ToolCall { tool_call, .. },
1330                        )) => {
1331                            let tool_name = &tool_call.function.name;
1332                            let reason = format!("Calling {}", tool_name);
1333                            crate::iris_status_dynamic!(
1334                                IrisPhase::ToolExecution {
1335                                    tool_name: tool_name.clone(),
1336                                    reason: reason.clone()
1337                                },
1338                                format!("🔧 {}", reason),
1339                                3,
1340                                4
1341                            );
1342                        }
1343                        Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
1344                        Err(e) => return Err(anyhow::anyhow!("Streaming error: {}", e)),
1345                        _ => {}
1346                    }
1347                }
1348                aggregated_text
1349            }};
1350        }
1351
1352        // Build and stream per-provider (streaming types are model-specific)
1353        let aggregated_text = match self.provider.as_str() {
1354            "openai" => {
1355                let agent = self.build_openai_agent_for_streaming(&full_prompt)?;
1356                let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1357                consume_stream!(stream)
1358            }
1359            "anthropic" => {
1360                let agent = self.build_anthropic_agent_for_streaming(&full_prompt)?;
1361                let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1362                consume_stream!(stream)
1363            }
1364            "google" | "gemini" => {
1365                let agent = self.build_gemini_agent_for_streaming(&full_prompt)?;
1366                let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1367                consume_stream!(stream)
1368            }
1369            _ => return Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
1370        };
1371
1372        // Update status
1373        crate::iris_status_dynamic!(
1374            IrisPhase::Synthesis,
1375            "✨ Iris is synthesizing results...",
1376            4,
1377            4
1378        );
1379
1380        let response = Self::text_to_structured_response(&output_type, aggregated_text);
1381        crate::iris_status_completed!();
1382        Ok(response)
1383    }
1384
1385    /// Convert raw text to the appropriate structured response type
1386    fn text_to_structured_response(output_type: &str, text: String) -> StructuredResponse {
1387        match output_type {
1388            "GeneratedMessage" => Self::parse_text_as_json::<crate::types::GeneratedMessage>(&text)
1389                .map_or_else(
1390                    || StructuredResponse::PlainText(text),
1391                    StructuredResponse::CommitMessage,
1392                ),
1393            "Review" => StructuredResponse::Review(crate::types::Review::from_unstructured(&text)),
1394            "MarkdownPullRequest" => {
1395                StructuredResponse::PullRequest(crate::types::MarkdownPullRequest { content: text })
1396            }
1397            "MarkdownChangelog" => {
1398                StructuredResponse::Changelog(crate::types::MarkdownChangelog { content: text })
1399            }
1400            "MarkdownReleaseNotes" => {
1401                StructuredResponse::ReleaseNotes(crate::types::MarkdownReleaseNotes {
1402                    content: text,
1403                })
1404            }
1405            "SemanticBlame" => StructuredResponse::SemanticBlame(text),
1406            _ => StructuredResponse::PlainText(text),
1407        }
1408    }
1409
1410    fn parse_text_as_json<T>(text: &str) -> Option<T>
1411    where
1412        T: JsonSchema + DeserializeOwned,
1413    {
1414        let json = extract_json_from_response(text).ok()?;
1415        let sanitized_json = sanitize_json_response(&json);
1416        parse_with_recovery(sanitized_json.as_ref()).ok()
1417    }
1418
1419    /// Shared streaming agent configuration
1420    fn streaming_agent_config(&self) -> (&str, Option<&str>, u64, usize) {
1421        let fast_model = self.effective_fast_model();
1422        let api_key = self.get_api_key();
1423        let subagent_timeout = self
1424            .config
1425            .as_ref()
1426            .map_or(120, |c| c.subagent_timeout_secs);
1427        let subagent_max_turns = self.config.as_ref().map_or(20, |c| c.subagent_max_turns);
1428        (fast_model, api_key, subagent_timeout, subagent_max_turns)
1429    }
1430
1431    /// Build `OpenAI` agent for streaming (with tools attached)
1432    fn build_openai_agent_for_streaming(
1433        &self,
1434        _prompt: &str,
1435    ) -> Result<rig::agent::Agent<provider::OpenAIModel>> {
1436        let (fast_model, api_key, subagent_timeout, subagent_max_turns) =
1437            self.streaming_agent_config();
1438        build_streaming_agent!(
1439            self,
1440            provider::openai_builder,
1441            fast_model,
1442            api_key,
1443            subagent_timeout,
1444            subagent_max_turns
1445        )
1446    }
1447
1448    /// Build Anthropic agent for streaming (with tools attached)
1449    fn build_anthropic_agent_for_streaming(
1450        &self,
1451        _prompt: &str,
1452    ) -> Result<rig::agent::Agent<provider::AnthropicModel>> {
1453        let (fast_model, api_key, subagent_timeout, subagent_max_turns) =
1454            self.streaming_agent_config();
1455        build_streaming_agent!(
1456            self,
1457            provider::anthropic_builder,
1458            fast_model,
1459            api_key,
1460            subagent_timeout,
1461            subagent_max_turns
1462        )
1463    }
1464
1465    /// Build Gemini agent for streaming (with tools attached)
1466    fn build_gemini_agent_for_streaming(
1467        &self,
1468        _prompt: &str,
1469    ) -> Result<rig::agent::Agent<provider::GeminiModel>> {
1470        let (fast_model, api_key, subagent_timeout, subagent_max_turns) =
1471            self.streaming_agent_config();
1472        build_streaming_agent!(
1473            self,
1474            provider::gemini_builder,
1475            fast_model,
1476            api_key,
1477            subagent_timeout,
1478            subagent_max_turns
1479        )
1480    }
1481
1482    /// Load capability configuration from embedded TOML, returning both prompt and output type
1483    fn load_capability_config(&self, capability: &str) -> Result<(String, String)> {
1484        let _ = self; // Keep &self for method syntax consistency
1485        if capability == "verify" {
1486            return Self::load_verify_capability_config();
1487        }
1488
1489        // Use embedded capability strings - always available regardless of working directory
1490        let content = match capability {
1491            "commit" => CAPABILITY_COMMIT,
1492            "pr" => CAPABILITY_PR,
1493            "review" => CAPABILITY_REVIEW,
1494            "changelog" => CAPABILITY_CHANGELOG,
1495            "release_notes" => CAPABILITY_RELEASE_NOTES,
1496            "chat" => CAPABILITY_CHAT,
1497            "semantic_blame" => CAPABILITY_SEMANTIC_BLAME,
1498            _ => {
1499                // Return generic prompt for unknown capabilities
1500                return Ok((
1501                    format!(
1502                        "You are helping with a {capability} task. Use the available Git tools to assist the user."
1503                    ),
1504                    "PlainText".to_string(),
1505                ));
1506            }
1507        };
1508
1509        Self::parse_capability_config(content)
1510    }
1511
1512    fn load_verify_capability_config() -> Result<(String, String)> {
1513        if let Some(config) = VERIFY_CAPABILITY_CONFIG.get() {
1514            return Ok(config.clone());
1515        }
1516
1517        let config = Self::parse_capability_config(CAPABILITY_VERIFY)?;
1518        let _ = VERIFY_CAPABILITY_CONFIG.set(config.clone());
1519        Ok(config)
1520    }
1521
1522    fn parse_capability_config(content: &str) -> Result<(String, String)> {
1523        let parsed: toml::Value = toml::from_str(content)?;
1524
1525        let task_prompt = parsed
1526            .get("task_prompt")
1527            .and_then(|v| v.as_str())
1528            .ok_or_else(|| anyhow::anyhow!("No task_prompt found in capability file"))?;
1529
1530        let output_type = parsed
1531            .get("output_type")
1532            .and_then(|v| v.as_str())
1533            .unwrap_or("PlainText")
1534            .to_string();
1535
1536        Ok((task_prompt.to_string(), output_type))
1537    }
1538
1539    /// Get the current capability being executed
1540    #[must_use]
1541    pub fn current_capability(&self) -> Option<&str> {
1542        self.current_capability.as_deref()
1543    }
1544
1545    /// Simple single-turn execution for basic queries
1546    ///
1547    /// # Errors
1548    ///
1549    /// Returns an error when the provider request fails.
1550    pub async fn chat(&self, message: &str) -> Result<String> {
1551        let agent = self.build_agent()?;
1552        let response = agent.prompt(message).await?;
1553        Ok(response)
1554    }
1555
1556    /// Set the current capability
1557    pub fn set_capability(&mut self, capability: &str) {
1558        self.current_capability = Some(capability.to_string());
1559    }
1560
1561    /// Get provider configuration
1562    #[must_use]
1563    pub fn provider_config(&self) -> &HashMap<String, String> {
1564        &self.provider_config
1565    }
1566
1567    /// Set provider configuration
1568    pub fn set_provider_config(&mut self, config: HashMap<String, String>) {
1569        self.provider_config = config;
1570    }
1571
1572    /// Set custom preamble
1573    pub fn set_preamble(&mut self, preamble: String) {
1574        self.preamble = Some(preamble);
1575    }
1576
1577    /// Set configuration
1578    pub fn set_config(&mut self, config: crate::config::Config) {
1579        self.config = Some(config);
1580    }
1581
1582    /// Set fast model for subagents
1583    pub fn set_fast_model(&mut self, fast_model: String) {
1584        self.fast_model = Some(fast_model);
1585    }
1586}
1587
1588/// Builder for creating `IrisAgent` instances with different configurations
1589pub struct IrisAgentBuilder {
1590    provider: String,
1591    model: String,
1592    preamble: Option<String>,
1593}
1594
1595impl IrisAgentBuilder {
1596    /// Create a new builder
1597    #[must_use]
1598    pub fn new() -> Self {
1599        Self {
1600            provider: "openai".to_string(),
1601            model: "gpt-5.4".to_string(),
1602            preamble: None,
1603        }
1604    }
1605
1606    /// Set the provider to use
1607    pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
1608        self.provider = provider.into();
1609        self
1610    }
1611
1612    /// Set the model to use
1613    pub fn with_model(mut self, model: impl Into<String>) -> Self {
1614        self.model = model.into();
1615        self
1616    }
1617
1618    /// Set a custom preamble
1619    pub fn with_preamble(mut self, preamble: impl Into<String>) -> Self {
1620        self.preamble = Some(preamble.into());
1621        self
1622    }
1623
1624    /// Build the `IrisAgent`
1625    ///
1626    /// # Errors
1627    ///
1628    /// Returns an error when the configured provider or model cannot build an agent.
1629    pub fn build(self) -> Result<IrisAgent> {
1630        let mut agent = IrisAgent::new(&self.provider, &self.model)?;
1631
1632        // Apply custom preamble if provided
1633        if let Some(preamble) = self.preamble {
1634            agent.set_preamble(preamble);
1635        }
1636
1637        Ok(agent)
1638    }
1639}
1640
1641impl Default for IrisAgentBuilder {
1642    fn default() -> Self {
1643        Self::new()
1644    }
1645}
1646
1647#[cfg(test)]
1648mod tests {
1649    use super::{
1650        Critique, CritiqueIssue, CritiqueSeverity, IrisAgent, extract_json_from_response,
1651        find_balanced_braces, sanitize_json_response, streaming_response_instructions,
1652    };
1653    use serde_json::Value;
1654    use std::borrow::Cow;
1655
1656    #[test]
1657    fn sanitize_json_response_is_noop_for_valid_payloads() {
1658        let raw = r#"{"title":"Test","description":"All good"}"#;
1659        let sanitized = sanitize_json_response(raw);
1660        assert!(matches!(sanitized, Cow::Borrowed(_)));
1661        serde_json::from_str::<Value>(sanitized.as_ref()).expect("valid JSON");
1662    }
1663
1664    #[test]
1665    fn sanitize_json_response_escapes_literal_newlines() {
1666        let raw = "{\"description\": \"Line1
1667Line2\"}";
1668        let sanitized = sanitize_json_response(raw);
1669        assert_eq!(sanitized.as_ref(), "{\"description\": \"Line1\\nLine2\"}");
1670        serde_json::from_str::<Value>(sanitized.as_ref()).expect("json sanitized");
1671    }
1672
1673    #[test]
1674    fn chat_streaming_instructions_avoid_markdown_suffix() {
1675        let instructions = streaming_response_instructions("chat");
1676        assert!(instructions.contains("plain text"));
1677        assert!(instructions.contains("do not repeat full content"));
1678        assert!(!instructions.contains("markdown format"));
1679    }
1680
1681    #[test]
1682    fn structured_streaming_instructions_still_use_markdown_suffix() {
1683        let instructions = streaming_response_instructions("review");
1684        assert!(instructions.contains("markdown format"));
1685        assert!(instructions.contains("well-structured"));
1686    }
1687
1688    #[test]
1689    fn find_balanced_braces_returns_first_balanced_pair() {
1690        let (start, end) = find_balanced_braces("prefix {\"a\":1} suffix").expect("balanced pair");
1691        assert_eq!(&"prefix {\"a\":1} suffix"[start..end], "{\"a\":1}");
1692    }
1693
1694    #[test]
1695    fn find_balanced_braces_returns_none_for_unbalanced() {
1696        assert_eq!(find_balanced_braces("no braces here"), None);
1697        assert_eq!(find_balanced_braces("{ unclosed"), None);
1698    }
1699
1700    #[test]
1701    fn extract_json_skips_github_actions_expression_false_positive() {
1702        // Regression for a real failure: a diff hunk that adds
1703        // `commit_message: "Update to ${{ github.ref_name }}"` to a workflow
1704        // lands in the model's response. The old scanner grabbed `{{ github.ref_name }}`
1705        // as its first balanced pair and errored out before seeing the real JSON.
1706        let response = r#"Looking at the diff, I see the new value `${{ github.ref_name }}` replacing the old bash expansion. Here's the commit:
1707
1708{"emoji": "🔧", "title": "Upgrade AUR deploy action", "message": "Bump to v4.1.2 to fix bash --command error."}
1709"#;
1710        let extracted = extract_json_from_response(response).expect("should recover real JSON");
1711        let parsed: Value = serde_json::from_str(&extracted).expect("extracted value is JSON");
1712        assert_eq!(parsed["emoji"], "🔧");
1713        assert_eq!(parsed["title"], "Upgrade AUR deploy action");
1714    }
1715
1716    #[test]
1717    fn extract_json_from_pure_json_response() {
1718        let response = r##"{"content": "# Heading\n\nBody text."}"##;
1719        let extracted = extract_json_from_response(response).expect("pure JSON passes through");
1720        assert_eq!(extracted, response);
1721    }
1722
1723    #[test]
1724    fn streamed_generated_message_text_becomes_commit_response() {
1725        let response = r#"```json
1726{"emoji":"🔧","title":"Wire streaming commit output","message":"Parse streamed JSON into the commit response type."}
1727```"#;
1728
1729        let structured =
1730            IrisAgent::text_to_structured_response("GeneratedMessage", response.to_string());
1731
1732        let super::StructuredResponse::CommitMessage(message) = structured else {
1733            panic!("expected commit message response");
1734        };
1735        assert_eq!(message.emoji.as_deref(), Some("🔧"));
1736        assert_eq!(message.title, "Wire streaming commit output");
1737        assert_eq!(
1738            message.message,
1739            "Parse streamed JSON into the commit response type."
1740        );
1741    }
1742
1743    #[test]
1744    fn invalid_streamed_generated_message_stays_plain_text() {
1745        let structured =
1746            IrisAgent::text_to_structured_response("GeneratedMessage", "not json".to_string());
1747
1748        let super::StructuredResponse::PlainText(text) = structured else {
1749            panic!("expected plain text fallback");
1750        };
1751        assert_eq!(text, "not json");
1752    }
1753
1754    #[test]
1755    fn critic_runs_for_configured_structured_artifacts() {
1756        let mut agent = IrisAgent::new("openai", "gpt-5.4").expect("agent should build");
1757        agent.set_config(crate::config::Config::default());
1758
1759        assert!(agent.should_run_critic("review", "Review"));
1760        assert!(agent.should_run_critic("commit", "GeneratedMessage"));
1761        assert!(!agent.should_run_critic("chat", "PlainText"));
1762        assert!(!agent.should_run_critic("semantic_blame", "SemanticBlame"));
1763    }
1764
1765    #[test]
1766    fn critic_can_be_disabled_by_config() {
1767        let config = crate::config::Config {
1768            critic_enabled: false,
1769            ..crate::config::Config::default()
1770        };
1771        let mut agent = IrisAgent::new("openai", "gpt-5.4").expect("agent should build");
1772        agent.set_config(config);
1773
1774        assert!(!agent.should_run_critic("review", "Review"));
1775    }
1776
1777    #[test]
1778    fn critic_revision_prompt_includes_material_issues() {
1779        let critique = Critique {
1780            requires_revision: true,
1781            issues: vec![CritiqueIssue {
1782                title: "Unsupported auth claim".to_string(),
1783                body: "The diff only updates docs.".to_string(),
1784                severity: CritiqueSeverity::High,
1785            }],
1786            revision_prompt: "Remove the auth-hardening claim.".to_string(),
1787            confidence: 91,
1788        };
1789
1790        let prompt = IrisAgent::build_revision_prompt("Original task", &critique);
1791
1792        assert!(prompt.contains("Original task"));
1793        assert!(prompt.contains("[high] Unsupported auth claim"));
1794        assert!(prompt.contains("Remove the auth-hardening claim."));
1795    }
1796
1797    #[test]
1798    fn critic_revision_prompt_falls_back_to_issues() {
1799        let critique = Critique {
1800            requires_revision: true,
1801            issues: vec![CritiqueIssue {
1802                title: "Unsupported auth claim".to_string(),
1803                body: "The diff only updates docs.".to_string(),
1804                severity: CritiqueSeverity::High,
1805            }],
1806            revision_prompt: String::new(),
1807            confidence: 91,
1808        };
1809
1810        let prompt = IrisAgent::build_revision_prompt("Original task", &critique);
1811
1812        assert!(prompt.contains("Address the material issues listed above."));
1813    }
1814
1815    #[test]
1816    fn critic_revision_prompt_omits_empty_issues_section() {
1817        let critique = Critique {
1818            requires_revision: true,
1819            issues: Vec::new(),
1820            revision_prompt: "Remove the unsupported claim.".to_string(),
1821            confidence: 91,
1822        };
1823
1824        let prompt = IrisAgent::build_revision_prompt("Original task", &critique);
1825
1826        assert!(!prompt.contains("Issues:"));
1827        assert!(prompt.contains("Remove the unsupported claim."));
1828    }
1829
1830    #[test]
1831    fn critic_artifact_serialization_strips_response_variant_wrapper() {
1832        let response = super::StructuredResponse::CommitMessage(crate::types::GeneratedMessage {
1833            emoji: None,
1834            title: "Add critic pass".to_string(),
1835            message: "Check generated artifacts before returning them.".to_string(),
1836            completion_message: None,
1837        });
1838
1839        let artifact = IrisAgent::serialize_artifact_for_critic(&response);
1840
1841        assert!(artifact.contains("\"title\": \"Add critic pass\""));
1842        assert!(!artifact.contains("CommitMessage"));
1843    }
1844
1845    #[test]
1846    fn critic_severity_normalizes_unknown_values_to_medium() {
1847        let severity: CritiqueSeverity =
1848            serde_json::from_str("\"totally-fine\"").expect("severity should deserialize");
1849
1850        assert_eq!(severity, CritiqueSeverity::Medium);
1851    }
1852
1853    #[test]
1854    fn extract_json_errors_when_no_candidate_parses() {
1855        // A single malformed candidate and no other braces: we surface the
1856        // parse error with a preview so the user sees what went wrong.
1857        let response = "prose ${{ template }} more prose";
1858        let err = extract_json_from_response(response).expect_err("should fail");
1859        let msg = err.to_string();
1860        assert!(
1861            msg.contains("Preview:"),
1862            "error should include a preview: {msg}"
1863        );
1864    }
1865
1866    #[test]
1867    fn pr_review_emoji_styling_uses_a_compact_gitmoji_guide() {
1868        let mut prompt = String::new();
1869        IrisAgent::inject_pr_review_emoji_styling(&mut prompt);
1870
1871        assert!(prompt.contains("Common gitmoji choices:"));
1872        assert!(prompt.contains("`:feat:`"));
1873        assert!(prompt.contains("`:fix:`"));
1874        assert!(!prompt.contains("`:accessibility:`"));
1875        assert!(!prompt.contains("`:analytics:`"));
1876    }
1877}