Skip to main content

git_iris/agents/
iris.rs

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