1use 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
17macro_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 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 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 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
75const 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
86const 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#[async_trait::async_trait]
151pub trait StreamingCallback: Send + Sync {
152 async fn on_chunk(
154 &self,
155 chunk: &str,
156 tokens: Option<crate::agents::status::TokenMetrics>,
157 ) -> Result<()>;
158
159 async fn on_complete(
161 &self,
162 full_response: &str,
163 final_tokens: crate::agents::status::TokenMetrics,
164 ) -> Result<()>;
165
166 async fn on_error(&self, error: &anyhow::Error) -> Result<()>;
168
169 async fn on_status_update(&self, message: &str) -> Result<()>;
171}
172
173#[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 Review(crate::types::Review),
182 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
275fn 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
304fn 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 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 if let Some(start) = response.find("```json") {
325 let content_start = start + "```json".len();
326 let json_end = if let Some(end) = response[content_start..].find("\n```") {
329 end
331 } else {
332 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 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 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 cursor = start + 1;
400 }
401 }
402 }
403
404 if let Some(err) = last_error {
405 return Err(err);
406 }
407
408 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 let escaped_content = serde_json::to_string(trimmed)?;
418 let wrapped = format!(r#"{{"content": {}}}"#, escaped_content);
420 debug::debug_json_parse_attempt(&wrapped);
421 return Ok(wrapped);
422 }
423
424 debug::debug_json_parse_error("No valid JSON found in response");
426 Err(anyhow::anyhow!("No valid JSON found in response"))
427}
428
429fn 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
509fn 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 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
540pub struct IrisAgent {
546 provider: String,
547 model: String,
548 fast_model: Option<String>,
550 current_capability: Option<String>,
552 provider_config: HashMap<String, String>,
554 preamble: Option<String>,
556 config: Option<crate::config::Config>,
558 content_update_sender: Option<crate::agents::tools::ContentUpdateSender>,
560 workspace: Workspace,
562}
563
564impl IrisAgent {
565 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 pub fn set_content_update_sender(&mut self, sender: crate::agents::tools::ContentUpdateSender) {
589 self.content_update_sender = Some(sender);
590 }
591
592 fn effective_fast_model(&self) -> &str {
594 self.fast_model.as_deref().unwrap_or(&self.model)
595 }
596
597 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 #[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_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_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_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 let sub_agent = build_subagent!(provider::openai_builder(fast_model, api_key)?);
692
693 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 let sub_agent = build_subagent!(provider::anthropic_builder(fast_model, api_key)?);
708
709 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 let sub_agent = build_subagent!(provider::gemini_builder(fast_model, api_key)?);
724
725 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 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 let msg = get_capability_message(capability);
779 crate::iris_status_dynamic!(IrisPhase::Planning, msg.text, 2, 4);
780
781 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 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 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 let gen_msg = get_capability_message(capability);
823 crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
824
825 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 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 crate::iris_status_dynamic!(
864 IrisPhase::Synthesis,
865 "✨ Iris is synthesizing results...",
866 4,
867 4
868 );
869
870 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 let result: T = parse_with_recovery(sanitized_ref)?;
887
888 debug::debug_json_parse_success(std::any::type_name::<T>());
889
890 crate::iris_status_completed!();
892
893 Ok(result)
894 }
895
896 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 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 let waiting_msg = get_capability_message(capability);
1035 crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
1036
1037 let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
1039
1040 self.inject_style_instructions(&mut system_prompt, capability);
1042
1043 self.current_capability = Some(capability.to_string());
1045
1046 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 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 let waiting_msg = get_capability_message(capability);
1284 crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
1285
1286 let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
1288
1289 self.inject_style_instructions(&mut system_prompt, capability);
1291
1292 self.current_capability = Some(capability.to_string());
1294
1295 crate::iris_status_dynamic!(
1297 IrisPhase::Analysis,
1298 "🔍 Iris is analyzing your changes...",
1299 2,
1300 4
1301 );
1302
1303 let full_prompt = format!(
1305 "{}\n\n{}\n\n{}",
1306 system_prompt,
1307 user_prompt,
1308 streaming_response_instructions(capability)
1309 );
1310
1311 let gen_msg = get_capability_message(capability);
1313 crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
1314
1315 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 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 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 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 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 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 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 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 fn load_capability_config(&self, capability: &str) -> Result<(String, String)> {
1484 let _ = self; if capability == "verify" {
1486 return Self::load_verify_capability_config();
1487 }
1488
1489 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 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 #[must_use]
1541 pub fn current_capability(&self) -> Option<&str> {
1542 self.current_capability.as_deref()
1543 }
1544
1545 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 pub fn set_capability(&mut self, capability: &str) {
1558 self.current_capability = Some(capability.to_string());
1559 }
1560
1561 #[must_use]
1563 pub fn provider_config(&self) -> &HashMap<String, String> {
1564 &self.provider_config
1565 }
1566
1567 pub fn set_provider_config(&mut self, config: HashMap<String, String>) {
1569 self.provider_config = config;
1570 }
1571
1572 pub fn set_preamble(&mut self, preamble: String) {
1574 self.preamble = Some(preamble);
1575 }
1576
1577 pub fn set_config(&mut self, config: crate::config::Config) {
1579 self.config = Some(config);
1580 }
1581
1582 pub fn set_fast_model(&mut self, fast_model: String) {
1584 self.fast_model = Some(fast_model);
1585 }
1586}
1587
1588pub struct IrisAgentBuilder {
1590 provider: String,
1591 model: String,
1592 preamble: Option<String>,
1593}
1594
1595impl IrisAgentBuilder {
1596 #[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 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
1608 self.provider = provider.into();
1609 self
1610 }
1611
1612 pub fn with_model(mut self, model: impl Into<String>) -> Self {
1614 self.model = model.into();
1615 self
1616 }
1617
1618 pub fn with_preamble(mut self, preamble: impl Into<String>) -> Self {
1620 self.preamble = Some(preamble.into());
1621 self
1622 }
1623
1624 pub fn build(self) -> Result<IrisAgent> {
1630 let mut agent = IrisAgent::new(&self.provider, &self.model)?;
1631
1632 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 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 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}