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;
15
16macro_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 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 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 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
73const 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
82const DEFAULT_PREAMBLE: &str = "\
84You are Iris, a helpful AI assistant specialized in Git operations and workflows.
85
86You have access to Git tools, code analysis tools, and powerful sub-agent capabilities for handling large analyses.
87
88**File Access Tools:**
89- **file_read** - Read file contents directly. Use `start_line` and `num_lines` for large files.
90- **project_docs** - Load a compact snapshot of README and agent instructions. Use targeted doc types for full docs when needed.
91- **code_search** - Search for patterns across files. Use sparingly; prefer file_read for known files.
92
93**Sub-Agent Tools:**
94
951. **parallel_analyze** - Run multiple analysis tasks CONCURRENTLY with independent context windows
96 - Best for: Large changesets (>500 lines or >20 files), batch commit analysis
97 - Each task runs in its own subagent, preventing context overflow
98 - Example: parallel_analyze({ \"tasks\": [\"Analyze auth/ changes for security\", \"Review db/ for performance\", \"Check api/ for breaking changes\"] })
99
1002. **analyze_subagent** - Delegate a single focused task to a sub-agent
101 - Best for: Deep dive on specific files or focused analysis
102
103**Best Practices:**
104- Use git_diff to get changes first - it includes file content
105- Use file_read to read files directly instead of multiple code_search calls
106- Use project_docs when repository conventions or product framing matter; do not front-load docs if the diff already answers the question
107- Use parallel_analyze for large changesets to avoid context overflow
108
109**Voice and Tone (applies to all output):**
110
111Write directly. Avoid the common LLM tells that make output read as AI slop:
112
113- No em dashes (—). Use commas, colons, periods, or parentheses instead. Hyphens (-) in compound words are fine.
114- No hedge phrases like \"it's worth noting\", \"it's important to remember\", \"ultimately\", \"at the end of the day\", \"in essence\".
115- No filler intros or outros: \"I'd be happy to\", \"let me explain\", \"in conclusion\", \"overall\", \"to summarize\".
116- No hype vocabulary: \"robust\", \"comprehensive\", \"seamless\", \"leverage\", \"delve into\", \"unlock\", \"elevate\", \"powerful\", \"cutting-edge\", \"game-changing\".
117- No vague intensifiers (\"very\", \"really\", \"extremely\", \"quite\") and no tricolon padding (\"fast, reliable, and scalable\" when one adjective fits).
118- No meta-commentary openers: don't start with \"This commit adds...\", \"This PR introduces...\", \"This change refactors...\". Start with the verb: \"Add...\", \"Refactor...\".
119- No stacked emoji. One project-style emoji is plenty when the repo uses gitmoji; never combos like 🚀✨🎉.
120- \"In order to\" → \"to\". Prefer plain words over Latinate or marketing alternatives.
121
122If user instructions, presets, project-config, or repository conventions specify a different tone, follow those over these defaults. These rules are the floor, not a ceiling that overrides explicit user voice.";
123
124fn streaming_response_instructions(capability: &str) -> &'static str {
125 if capability == "chat" {
126 "After using the available tools, respond in plain text.\n\
127 Keep it concise and do not repeat full content that tools already updated."
128 } else {
129 "After using the available tools, respond with your analysis in markdown format.\n\
130 Keep it clear, well-structured, and informative."
131 }
132}
133
134use crate::agents::provider::{self, CompletionProfile, DynAgent};
135use crate::agents::tools::{GitRepoInfo, ParallelAnalyze, Workspace};
136
137#[async_trait::async_trait]
139pub trait StreamingCallback: Send + Sync {
140 async fn on_chunk(
142 &self,
143 chunk: &str,
144 tokens: Option<crate::agents::status::TokenMetrics>,
145 ) -> Result<()>;
146
147 async fn on_complete(
149 &self,
150 full_response: &str,
151 final_tokens: crate::agents::status::TokenMetrics,
152 ) -> Result<()>;
153
154 async fn on_error(&self, error: &anyhow::Error) -> Result<()>;
156
157 async fn on_status_update(&self, message: &str) -> Result<()>;
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub enum StructuredResponse {
164 CommitMessage(crate::types::GeneratedMessage),
165 PullRequest(crate::types::MarkdownPullRequest),
166 Changelog(crate::types::MarkdownChangelog),
167 ReleaseNotes(crate::types::MarkdownReleaseNotes),
168 MarkdownReview(crate::types::MarkdownReview),
170 SemanticBlame(String),
172 PlainText(String),
173}
174
175impl fmt::Display for StructuredResponse {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 match self {
178 StructuredResponse::CommitMessage(msg) => {
179 write!(f, "{}", crate::types::format_commit_message(msg))
180 }
181 StructuredResponse::PullRequest(pr) => {
182 write!(f, "{}", pr.raw_content())
183 }
184 StructuredResponse::Changelog(cl) => {
185 write!(f, "{}", cl.raw_content())
186 }
187 StructuredResponse::ReleaseNotes(rn) => {
188 write!(f, "{}", rn.raw_content())
189 }
190 StructuredResponse::MarkdownReview(review) => {
191 write!(f, "{}", review.format())
192 }
193 StructuredResponse::SemanticBlame(explanation) => {
194 write!(f, "{explanation}")
195 }
196 StructuredResponse::PlainText(text) => {
197 write!(f, "{text}")
198 }
199 }
200 }
201}
202
203fn find_balanced_braces(s: &str) -> Option<(usize, usize)> {
210 let mut depth: i32 = 0;
211 let mut start: Option<usize> = None;
212 for (i, ch) in s.char_indices() {
213 match ch {
214 '{' => {
215 if depth == 0 {
216 start = Some(i);
217 }
218 depth += 1;
219 }
220 '}' if depth > 0 => {
221 depth -= 1;
222 if depth == 0 {
223 return start.map(|s_idx| (s_idx, i + 1));
224 }
225 }
226 _ => {}
227 }
228 }
229 None
230}
231
232fn extract_json_from_response(response: &str) -> Result<String> {
234 use crate::agents::debug;
235
236 debug::debug_section("JSON Extraction");
237
238 let trimmed_response = response.trim();
239
240 if trimmed_response.starts_with('{')
242 && serde_json::from_str::<serde_json::Value>(trimmed_response).is_ok()
243 {
244 debug::debug_context_management(
245 "Response is pure JSON",
246 &format!("{} characters", trimmed_response.len()),
247 );
248 return Ok(trimmed_response.to_string());
249 }
250
251 if let Some(start) = response.find("```json") {
253 let content_start = start + "```json".len();
254 let json_end = if let Some(end) = response[content_start..].find("\n```") {
257 end
259 } else {
260 response[content_start..]
262 .find("```")
263 .unwrap_or(response.len() - content_start)
264 };
265
266 let json_content = &response[content_start..content_start + json_end];
267 let trimmed = json_content.trim().to_string();
268
269 debug::debug_context_management(
270 "Found JSON in markdown code block",
271 &format!("{} characters", trimmed.len()),
272 );
273
274 if let Err(e) = debug::write_debug_artifact("iris_extracted.json", &trimmed) {
276 debug::debug_warning(&format!("Failed to write extracted JSON: {}", e));
277 }
278
279 debug::debug_json_parse_attempt(&trimmed);
280 return Ok(trimmed);
281 }
282
283 let mut last_error: Option<anyhow::Error> = None;
291 let mut cursor = 0;
292 while cursor < response.len() {
293 let Some((rel_start, rel_end)) = find_balanced_braces(&response[cursor..]) else {
294 break;
295 };
296 let start = cursor + rel_start;
297 let end = cursor + rel_end;
298 let json_content = &response[start..end];
299 debug::debug_json_parse_attempt(json_content);
300
301 let sanitized = sanitize_json_response(json_content);
302 match serde_json::from_str::<serde_json::Value>(&sanitized) {
303 Ok(_) => {
304 debug::debug_context_management(
305 "Found valid JSON object",
306 &format!("{} characters", json_content.len()),
307 );
308 return Ok(sanitized.into_owned());
309 }
310 Err(e) => {
311 debug::debug_json_parse_error(&format!(
312 "Candidate at offset {} is not valid JSON: {}",
313 start, e
314 ));
315 let preview = if json_content.len() > 200 {
316 format!("{}...", &json_content[..200])
317 } else {
318 json_content.to_string()
319 };
320 last_error = Some(anyhow::anyhow!(
321 "Found JSON-like content but it's not valid JSON: {}\nPreview: {}",
322 e,
323 preview
324 ));
325 cursor = start + 1;
328 }
329 }
330 }
331
332 if let Some(err) = last_error {
333 return Err(err);
334 }
335
336 let trimmed = response.trim();
339 if trimmed.starts_with('#') || trimmed.starts_with("##") {
340 debug::debug_context_management(
341 "Detected raw markdown response",
342 "Wrapping in JSON structure",
343 );
344 let escaped_content = serde_json::to_string(trimmed)?;
346 let wrapped = format!(r#"{{"content": {}}}"#, escaped_content);
348 debug::debug_json_parse_attempt(&wrapped);
349 return Ok(wrapped);
350 }
351
352 debug::debug_json_parse_error("No valid JSON found in response");
354 Err(anyhow::anyhow!("No valid JSON found in response"))
355}
356
357fn sanitize_json_response(raw: &str) -> Cow<'_, str> {
362 let mut needs_sanitization = false;
363 let mut in_string = false;
364 let mut escaped = false;
365
366 for ch in raw.chars() {
367 if in_string {
368 if escaped {
369 escaped = false;
370 continue;
371 }
372
373 match ch {
374 '\\' => escaped = true,
375 '"' => in_string = false,
376 '\n' | '\r' | '\t' => {
377 needs_sanitization = true;
378 break;
379 }
380 c if c.is_control() => {
381 needs_sanitization = true;
382 break;
383 }
384 _ => {}
385 }
386 } else if ch == '"' {
387 in_string = true;
388 }
389 }
390
391 if !needs_sanitization {
392 return Cow::Borrowed(raw);
393 }
394
395 let mut sanitized = String::with_capacity(raw.len());
396 in_string = false;
397 escaped = false;
398
399 for ch in raw.chars() {
400 if in_string {
401 if escaped {
402 sanitized.push(ch);
403 escaped = false;
404 continue;
405 }
406
407 match ch {
408 '\\' => {
409 sanitized.push('\\');
410 escaped = true;
411 }
412 '"' => {
413 sanitized.push('"');
414 in_string = false;
415 }
416 '\n' => sanitized.push_str("\\n"),
417 '\r' => sanitized.push_str("\\r"),
418 '\t' => sanitized.push_str("\\t"),
419 c if c.is_control() => {
420 use std::fmt::Write as _;
421 let _ = write!(&mut sanitized, "\\u{:04X}", u32::from(c));
422 }
423 _ => sanitized.push(ch),
424 }
425 } else {
426 sanitized.push(ch);
427 if ch == '"' {
428 in_string = true;
429 escaped = false;
430 }
431 }
432 }
433
434 Cow::Owned(sanitized)
435}
436
437fn parse_with_recovery<T>(json_str: &str) -> Result<T>
444where
445 T: JsonSchema + DeserializeOwned,
446{
447 use crate::agents::debug as agent_debug;
448 use crate::agents::output_validator::validate_and_parse;
449
450 let validation_result = validate_and_parse::<T>(json_str)?;
451
452 if validation_result.recovered {
454 agent_debug::debug_context_management(
455 "JSON recovery applied",
456 &format!("{} issues fixed", validation_result.warnings.len()),
457 );
458 for warning in &validation_result.warnings {
459 agent_debug::debug_warning(warning);
460 }
461 }
462
463 validation_result
464 .value
465 .ok_or_else(|| anyhow::anyhow!("Failed to parse JSON even after recovery"))
466}
467
468pub struct IrisAgent {
474 provider: String,
475 model: String,
476 fast_model: Option<String>,
478 current_capability: Option<String>,
480 provider_config: HashMap<String, String>,
482 preamble: Option<String>,
484 config: Option<crate::config::Config>,
486 content_update_sender: Option<crate::agents::tools::ContentUpdateSender>,
488 workspace: Workspace,
490}
491
492impl IrisAgent {
493 pub fn new(provider: &str, model: &str) -> Result<Self> {
499 Ok(Self {
500 provider: provider.to_string(),
501 model: model.to_string(),
502 fast_model: None,
503 current_capability: None,
504 provider_config: HashMap::new(),
505 preamble: None,
506 config: None,
507 content_update_sender: None,
508 workspace: Workspace::new(),
509 })
510 }
511
512 pub fn set_content_update_sender(&mut self, sender: crate::agents::tools::ContentUpdateSender) {
517 self.content_update_sender = Some(sender);
518 }
519
520 fn effective_fast_model(&self) -> &str {
522 self.fast_model.as_deref().unwrap_or(&self.model)
523 }
524
525 fn get_api_key(&self) -> Option<&str> {
527 provider::current_provider_config(self.config.as_ref(), &self.provider)
528 .and_then(crate::providers::ProviderConfig::api_key_if_set)
529 }
530
531 fn current_provider(&self) -> Result<crate::providers::Provider> {
532 provider::provider_from_name(&self.provider)
533 }
534
535 fn current_provider_additional_params(&self) -> Option<&HashMap<String, String>> {
536 provider::current_provider_config(self.config.as_ref(), &self.provider)
537 .map(|provider_config| &provider_config.additional_params)
538 }
539
540 fn build_agent(&self) -> Result<DynAgent> {
546 use crate::agents::debug_tool::DebugTool;
547
548 let preamble = self.preamble.as_deref().unwrap_or(DEFAULT_PREAMBLE);
549 let fast_model = self.effective_fast_model();
550 let api_key = self.get_api_key();
551 let subagent_timeout = self
552 .config
553 .as_ref()
554 .map_or(120, |c| c.subagent_timeout_secs);
555
556 macro_rules! build_subagent {
558 ($builder:expr) => {{
559 let builder = $builder
560 .name("analyze_subagent")
561 .description("Delegate focused analysis tasks to a sub-agent with its own context window. Use for analyzing specific files, commits, or code sections independently. The sub-agent has access to Git tools (diff, log, status) and file analysis tools.")
562 .preamble("You are a specialized analysis sub-agent for Iris. Your job is to complete focused analysis tasks and return concise, actionable summaries.
563
564Guidelines:
565- Use the available tools to gather information
566- Focus only on what's asked - don't expand scope
567- Return a clear, structured summary of findings
568- Highlight important issues, patterns, or insights
569- Keep your response focused and concise")
570 ;
571 let builder = self.apply_completion_params(
572 builder,
573 fast_model,
574 4096,
575 CompletionProfile::Subagent,
576 )?;
577 crate::attach_core_tools!(builder).build()
578 }};
579 }
580
581 macro_rules! attach_main_tools {
583 ($builder:expr) => {{
584 crate::attach_core_tools!($builder)
585 .tool(DebugTool::new(GitRepoInfo))
586 .tool(DebugTool::new(self.workspace.clone()))
587 .tool(DebugTool::new(ParallelAnalyze::with_timeout(
588 &self.provider,
589 fast_model,
590 subagent_timeout,
591 api_key,
592 self.current_provider_additional_params().cloned(),
593 )?))
594 }};
595 }
596
597 macro_rules! maybe_attach_update_tools {
599 ($builder:expr) => {{
600 if let Some(sender) = &self.content_update_sender {
601 use crate::agents::tools::{UpdateCommitTool, UpdatePRTool, UpdateReviewTool};
602 $builder
603 .tool(DebugTool::new(UpdateCommitTool::new(sender.clone())))
604 .tool(DebugTool::new(UpdatePRTool::new(sender.clone())))
605 .tool(DebugTool::new(UpdateReviewTool::new(sender.clone())))
606 .build()
607 } else {
608 $builder.build()
609 }
610 }};
611 }
612
613 match self.provider.as_str() {
614 "openai" => {
615 let sub_agent = build_subagent!(provider::openai_builder(fast_model, api_key)?);
617
618 let builder = provider::openai_builder(&self.model, api_key)?.preamble(preamble);
620 let builder = self.apply_completion_params(
621 builder,
622 &self.model,
623 16384,
624 CompletionProfile::MainAgent,
625 )?;
626 let builder = attach_main_tools!(builder).tool(sub_agent);
627 let agent = maybe_attach_update_tools!(builder);
628 Ok(DynAgent::OpenAI(agent))
629 }
630 "anthropic" => {
631 let sub_agent = build_subagent!(provider::anthropic_builder(fast_model, api_key)?);
633
634 let builder = provider::anthropic_builder(&self.model, api_key)?.preamble(preamble);
636 let builder = self.apply_completion_params(
637 builder,
638 &self.model,
639 16384,
640 CompletionProfile::MainAgent,
641 )?;
642 let builder = attach_main_tools!(builder).tool(sub_agent);
643 let agent = maybe_attach_update_tools!(builder);
644 Ok(DynAgent::Anthropic(agent))
645 }
646 "google" | "gemini" => {
647 let sub_agent = build_subagent!(provider::gemini_builder(fast_model, api_key)?);
649
650 let builder = provider::gemini_builder(&self.model, api_key)?.preamble(preamble);
652 let builder = self.apply_completion_params(
653 builder,
654 &self.model,
655 16384,
656 CompletionProfile::MainAgent,
657 )?;
658 let builder = attach_main_tools!(builder).tool(sub_agent);
659 let agent = maybe_attach_update_tools!(builder);
660 Ok(DynAgent::Gemini(agent))
661 }
662 _ => Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
663 }
664 }
665
666 fn apply_completion_params<M>(
667 &self,
668 builder: AgentBuilder<M>,
669 model: &str,
670 max_tokens: u64,
671 profile: CompletionProfile,
672 ) -> Result<AgentBuilder<M>>
673 where
674 M: CompletionModel,
675 {
676 let provider = self.current_provider()?;
677 Ok(provider::apply_completion_params(
678 builder,
679 provider,
680 model,
681 max_tokens,
682 self.current_provider_additional_params(),
683 profile,
684 ))
685 }
686
687 async fn execute_with_agent<T>(&self, system_prompt: &str, user_prompt: &str) -> Result<T>
690 where
691 T: JsonSchema + for<'a> serde::Deserialize<'a> + serde::Serialize + Send + Sync + 'static,
692 {
693 use crate::agents::debug;
694 use crate::agents::status::IrisPhase;
695 use crate::messages::get_capability_message;
696 use schemars::schema_for;
697
698 let capability = self.current_capability().unwrap_or("commit");
699
700 debug::debug_phase_change(&format!("AGENT EXECUTION: {}", std::any::type_name::<T>()));
701
702 let msg = get_capability_message(capability);
704 crate::iris_status_dynamic!(IrisPhase::Planning, msg.text, 2, 4);
705
706 let agent = self.build_agent()?;
708 debug::debug_context_management(
709 "Agent built with tools",
710 &format!(
711 "Provider: {}, Model: {} (fast: {})",
712 self.provider,
713 self.model,
714 self.effective_fast_model()
715 ),
716 );
717
718 let schema = schema_for!(T);
720 let schema_json = serde_json::to_string_pretty(&schema)?;
721 debug::debug_context_management(
722 "JSON schema created",
723 &format!("Type: {}", std::any::type_name::<T>()),
724 );
725
726 let full_prompt = format!(
728 "{system_prompt}\n\n{user_prompt}\n\n\
729 === CRITICAL: RESPONSE FORMAT ===\n\
730 After using the available tools to gather necessary information, you MUST respond with ONLY a valid JSON object.\n\n\
731 REQUIRED JSON SCHEMA:\n\
732 {schema_json}\n\n\
733 CRITICAL INSTRUCTIONS:\n\
734 - Return ONLY the raw JSON object - nothing else\n\
735 - NO explanations before the JSON\n\
736 - NO explanations after the JSON\n\
737 - NO markdown code blocks (just raw JSON)\n\
738 - NO preamble text like 'Here is the JSON:' or 'Let me generate:'\n\
739 - Start your response with {{ and end with }}\n\
740 - The JSON must be complete and valid\n\n\
741 Your entire response should be ONLY the JSON object."
742 );
743
744 debug::debug_llm_request(&full_prompt, Some(16384));
745
746 let gen_msg = get_capability_message(capability);
748 crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
749
750 let timer = debug::DebugTimer::start("Agent prompt execution");
755
756 debug::debug_context_management(
757 "LLM request",
758 "Sending prompt to agent with multi_turn(50)",
759 );
760 let prompt_response: PromptResponse = agent.prompt_extended(&full_prompt, 50).await?;
761
762 timer.finish();
763
764 let usage = &prompt_response.usage;
766 debug::debug_context_management(
767 "Token usage",
768 &format!(
769 "input: {} | output: {} | total: {}",
770 usage.input_tokens, usage.output_tokens, usage.total_tokens
771 ),
772 );
773
774 let response = &prompt_response.output;
775 #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
776 let total_tokens_usize = usage.total_tokens as usize;
777 debug::debug_llm_response(
778 response,
779 std::time::Duration::from_secs(0),
780 Some(total_tokens_usize),
781 );
782
783 crate::iris_status_dynamic!(
785 IrisPhase::Synthesis,
786 "✨ Iris is synthesizing results...",
787 4,
788 4
789 );
790
791 let json_str = extract_json_from_response(response)?;
793 let sanitized_json = sanitize_json_response(&json_str);
794 let sanitized_ref = sanitized_json.as_ref();
795
796 if matches!(sanitized_json, Cow::Borrowed(_)) {
797 debug::debug_json_parse_attempt(sanitized_ref);
798 } else {
799 debug::debug_context_management(
800 "Sanitized JSON response",
801 &format!("{} → {} characters", json_str.len(), sanitized_ref.len()),
802 );
803 debug::debug_json_parse_attempt(sanitized_ref);
804 }
805
806 let result: T = parse_with_recovery(sanitized_ref)?;
808
809 debug::debug_json_parse_success(std::any::type_name::<T>());
810
811 crate::iris_status_completed!();
813
814 Ok(result)
815 }
816
817 fn inject_style_instructions(&self, system_prompt: &mut String, capability: &str) {
823 let Some(config) = &self.config else {
824 return;
825 };
826
827 let preset_name = config.get_effective_preset_name();
828 let is_conventional = preset_name == "conventional";
829 let is_default_mode = preset_name == "default" || preset_name.is_empty();
830 let use_style_detection =
831 capability == "commit" && is_default_mode && config.gitmoji_override.is_none();
832 let commit_emoji = config.use_gitmoji && !is_conventional && !use_style_detection;
833 let output_emoji = config.gitmoji_override.unwrap_or(config.use_gitmoji);
834
835 Self::inject_instruction_preset(system_prompt, preset_name, is_default_mode);
836
837 if capability == "commit" {
838 Self::inject_commit_styling(system_prompt, commit_emoji, is_conventional);
839 }
840
841 Self::inject_markdown_output_styling(system_prompt, capability, output_emoji);
842 }
843
844 fn inject_instruction_preset(
845 system_prompt: &mut String,
846 preset_name: &str,
847 is_default_mode: bool,
848 ) {
849 if preset_name.is_empty() || is_default_mode {
850 return;
851 }
852
853 let library = crate::instruction_presets::get_instruction_preset_library();
854 if let Some(preset) = library.get_preset(preset_name) {
855 tracing::info!("📋 Injecting '{}' preset style instructions", preset_name);
856 system_prompt.push_str("\n\n=== STYLE INSTRUCTIONS ===\n");
857 system_prompt.push_str(&preset.instructions);
858 system_prompt.push('\n');
859 } else {
860 tracing::warn!("⚠️ Preset '{}' not found in library", preset_name);
861 }
862 }
863
864 fn inject_commit_styling(
865 system_prompt: &mut String,
866 commit_emoji: bool,
867 is_conventional: bool,
868 ) {
869 if commit_emoji {
870 system_prompt.push_str("\n\n=== GITMOJI INSTRUCTIONS ===\n");
871 system_prompt.push_str("Set the 'emoji' field to a single relevant gitmoji. ");
872 system_prompt.push_str(
873 "DO NOT include the emoji in the 'message' or 'title' text - only set the 'emoji' field. ",
874 );
875 system_prompt.push_str("Choose the closest match from this compact guide:\n\n");
876 system_prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
877 system_prompt.push_str("\n\nThe emoji should match the primary type of change.");
878 } else if is_conventional {
879 system_prompt.push_str("\n\n=== CONVENTIONAL COMMITS FORMAT ===\n");
880 system_prompt.push_str("IMPORTANT: This uses Conventional Commits format. ");
881 system_prompt.push_str("DO NOT include any emojis in the commit message or PR title. ");
882 system_prompt.push_str("The 'emoji' field should be null.");
883 }
884 }
885
886 fn inject_markdown_output_styling(
887 system_prompt: &mut String,
888 capability: &str,
889 output_emoji: bool,
890 ) {
891 match (capability, output_emoji) {
892 ("pr" | "review", true) => Self::inject_pr_review_emoji_styling(system_prompt),
893 ("release_notes", true) => Self::inject_release_notes_emoji_styling(system_prompt),
894 ("changelog", true) => Self::inject_changelog_emoji_styling(system_prompt),
895 ("pr" | "review" | "release_notes" | "changelog", false) => {
896 Self::inject_no_emoji_styling(system_prompt);
897 }
898 _ => {}
899 }
900 }
901
902 fn inject_pr_review_emoji_styling(prompt: &mut String) {
903 prompt.push_str("\n\n=== EMOJI STYLING ===\n");
904 prompt.push_str("Use emojis to make the output visually scannable and engaging:\n");
905 prompt.push_str("- H1 title: ONE gitmoji at the start (✨, 🐛, ♻️, etc.)\n");
906 prompt.push_str("- Section headers: Add relevant emojis (🎯 What's New, ⚙️ How It Works, 📋 Commits, ⚠️ Breaking Changes)\n");
907 prompt.push_str("- Commit list entries: Include gitmoji where appropriate\n");
908 prompt.push_str("- Body text: Keep clean - no scattered emojis within prose\n\n");
909 prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
910 }
911
912 fn inject_release_notes_emoji_styling(prompt: &mut String) {
913 prompt.push_str("\n\n=== EMOJI STYLING ===\n");
914 prompt.push_str("Use at most one emoji per highlight/section title. No emojis in bullet descriptions, upgrade notes, or metrics. ");
915 prompt.push_str("Pick from the approved gitmoji list (e.g., 🌟 Highlights, 🤖 Agents, 🔧 Tooling, 🐛 Fixes, ⚡ Performance). ");
916 prompt.push_str("Never sprinkle emojis within sentences or JSON keys.\n\n");
917 prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
918 }
919
920 fn inject_changelog_emoji_styling(prompt: &mut String) {
921 prompt.push_str("\n\n=== EMOJI STYLING ===\n");
922 prompt.push_str("Section keys must remain plain text (Added/Changed/Deprecated/Removed/Fixed/Security). ");
923 prompt.push_str(
924 "You may include one emoji within a change description to reinforce meaning. ",
925 );
926 prompt.push_str(
927 "Never add emojis to JSON keys, section names, metrics, or upgrade notes.\n\n",
928 );
929 prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
930 }
931
932 fn inject_no_emoji_styling(prompt: &mut String) {
933 prompt.push_str("\n\n=== NO EMOJI STYLING ===\n");
934 prompt.push_str(
935 "DO NOT include any emojis anywhere in the output. Keep all content plain text.",
936 );
937 }
938
939 pub async fn execute_task(
947 &mut self,
948 capability: &str,
949 user_prompt: &str,
950 ) -> Result<StructuredResponse> {
951 use crate::agents::status::IrisPhase;
952 use crate::messages::get_capability_message;
953
954 let waiting_msg = get_capability_message(capability);
956 crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
957
958 let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
960
961 self.inject_style_instructions(&mut system_prompt, capability);
963
964 self.current_capability = Some(capability.to_string());
966
967 crate::iris_status_dynamic!(
969 IrisPhase::Analysis,
970 "🔍 Iris is analyzing your changes...",
971 2,
972 4
973 );
974
975 match output_type.as_str() {
978 "GeneratedMessage" => {
979 let response = self
980 .execute_with_agent::<crate::types::GeneratedMessage>(
981 &system_prompt,
982 user_prompt,
983 )
984 .await?;
985 Ok(StructuredResponse::CommitMessage(response))
986 }
987 "MarkdownPullRequest" => {
988 let response = self
989 .execute_with_agent::<crate::types::MarkdownPullRequest>(
990 &system_prompt,
991 user_prompt,
992 )
993 .await?;
994 Ok(StructuredResponse::PullRequest(response))
995 }
996 "MarkdownChangelog" => {
997 let response = self
998 .execute_with_agent::<crate::types::MarkdownChangelog>(
999 &system_prompt,
1000 user_prompt,
1001 )
1002 .await?;
1003 Ok(StructuredResponse::Changelog(response))
1004 }
1005 "MarkdownReleaseNotes" => {
1006 let response = self
1007 .execute_with_agent::<crate::types::MarkdownReleaseNotes>(
1008 &system_prompt,
1009 user_prompt,
1010 )
1011 .await?;
1012 Ok(StructuredResponse::ReleaseNotes(response))
1013 }
1014 "MarkdownReview" => {
1015 let response = self
1016 .execute_with_agent::<crate::types::MarkdownReview>(&system_prompt, user_prompt)
1017 .await?;
1018 Ok(StructuredResponse::MarkdownReview(response))
1019 }
1020 "SemanticBlame" => {
1021 let agent = self.build_agent()?;
1023 let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
1024 let response = agent.prompt_multi_turn(&full_prompt, 10).await?;
1025 Ok(StructuredResponse::SemanticBlame(response))
1026 }
1027 _ => {
1028 let agent = self.build_agent()?;
1030 let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
1031 let response = agent.prompt_multi_turn(&full_prompt, 50).await?;
1033 Ok(StructuredResponse::PlainText(response))
1034 }
1035 }
1036 }
1037
1038 pub async fn execute_task_streaming<F>(
1049 &mut self,
1050 capability: &str,
1051 user_prompt: &str,
1052 mut on_chunk: F,
1053 ) -> Result<StructuredResponse>
1054 where
1055 F: FnMut(&str, &str) + Send,
1056 {
1057 use crate::agents::status::IrisPhase;
1058 use crate::messages::get_capability_message;
1059 use futures::StreamExt;
1060 use rig::agent::MultiTurnStreamItem;
1061 use rig::streaming::{StreamedAssistantContent, StreamingPrompt};
1062
1063 let waiting_msg = get_capability_message(capability);
1065 crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
1066
1067 let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
1069
1070 self.inject_style_instructions(&mut system_prompt, capability);
1072
1073 self.current_capability = Some(capability.to_string());
1075
1076 crate::iris_status_dynamic!(
1078 IrisPhase::Analysis,
1079 "🔍 Iris is analyzing your changes...",
1080 2,
1081 4
1082 );
1083
1084 let full_prompt = format!(
1086 "{}\n\n{}\n\n{}",
1087 system_prompt,
1088 user_prompt,
1089 streaming_response_instructions(capability)
1090 );
1091
1092 let gen_msg = get_capability_message(capability);
1094 crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
1095
1096 macro_rules! consume_stream {
1098 ($stream:expr) => {{
1099 let mut aggregated_text = String::new();
1100 let mut stream = $stream;
1101 while let Some(item) = stream.next().await {
1102 match item {
1103 Ok(MultiTurnStreamItem::StreamAssistantItem(
1104 StreamedAssistantContent::Text(text),
1105 )) => {
1106 aggregated_text.push_str(&text.text);
1107 on_chunk(&text.text, &aggregated_text);
1108 }
1109 Ok(MultiTurnStreamItem::StreamAssistantItem(
1110 StreamedAssistantContent::ToolCall { tool_call, .. },
1111 )) => {
1112 let tool_name = &tool_call.function.name;
1113 let reason = format!("Calling {}", tool_name);
1114 crate::iris_status_dynamic!(
1115 IrisPhase::ToolExecution {
1116 tool_name: tool_name.clone(),
1117 reason: reason.clone()
1118 },
1119 format!("🔧 {}", reason),
1120 3,
1121 4
1122 );
1123 }
1124 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
1125 Err(e) => return Err(anyhow::anyhow!("Streaming error: {}", e)),
1126 _ => {}
1127 }
1128 }
1129 aggregated_text
1130 }};
1131 }
1132
1133 let aggregated_text = match self.provider.as_str() {
1135 "openai" => {
1136 let agent = self.build_openai_agent_for_streaming(&full_prompt)?;
1137 let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1138 consume_stream!(stream)
1139 }
1140 "anthropic" => {
1141 let agent = self.build_anthropic_agent_for_streaming(&full_prompt)?;
1142 let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1143 consume_stream!(stream)
1144 }
1145 "google" | "gemini" => {
1146 let agent = self.build_gemini_agent_for_streaming(&full_prompt)?;
1147 let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1148 consume_stream!(stream)
1149 }
1150 _ => return Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
1151 };
1152
1153 crate::iris_status_dynamic!(
1155 IrisPhase::Synthesis,
1156 "✨ Iris is synthesizing results...",
1157 4,
1158 4
1159 );
1160
1161 let response = Self::text_to_structured_response(&output_type, aggregated_text);
1162 crate::iris_status_completed!();
1163 Ok(response)
1164 }
1165
1166 fn text_to_structured_response(output_type: &str, text: String) -> StructuredResponse {
1168 match output_type {
1169 "MarkdownReview" => {
1170 StructuredResponse::MarkdownReview(crate::types::MarkdownReview { content: text })
1171 }
1172 "MarkdownPullRequest" => {
1173 StructuredResponse::PullRequest(crate::types::MarkdownPullRequest { content: text })
1174 }
1175 "MarkdownChangelog" => {
1176 StructuredResponse::Changelog(crate::types::MarkdownChangelog { content: text })
1177 }
1178 "MarkdownReleaseNotes" => {
1179 StructuredResponse::ReleaseNotes(crate::types::MarkdownReleaseNotes {
1180 content: text,
1181 })
1182 }
1183 "SemanticBlame" => StructuredResponse::SemanticBlame(text),
1184 _ => StructuredResponse::PlainText(text),
1185 }
1186 }
1187
1188 fn streaming_agent_config(&self) -> (&str, Option<&str>, u64) {
1190 let fast_model = self.effective_fast_model();
1191 let api_key = self.get_api_key();
1192 let subagent_timeout = self
1193 .config
1194 .as_ref()
1195 .map_or(120, |c| c.subagent_timeout_secs);
1196 (fast_model, api_key, subagent_timeout)
1197 }
1198
1199 fn build_openai_agent_for_streaming(
1201 &self,
1202 _prompt: &str,
1203 ) -> Result<rig::agent::Agent<provider::OpenAIModel>> {
1204 let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1205 build_streaming_agent!(
1206 self,
1207 provider::openai_builder,
1208 fast_model,
1209 api_key,
1210 subagent_timeout
1211 )
1212 }
1213
1214 fn build_anthropic_agent_for_streaming(
1216 &self,
1217 _prompt: &str,
1218 ) -> Result<rig::agent::Agent<provider::AnthropicModel>> {
1219 let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1220 build_streaming_agent!(
1221 self,
1222 provider::anthropic_builder,
1223 fast_model,
1224 api_key,
1225 subagent_timeout
1226 )
1227 }
1228
1229 fn build_gemini_agent_for_streaming(
1231 &self,
1232 _prompt: &str,
1233 ) -> Result<rig::agent::Agent<provider::GeminiModel>> {
1234 let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1235 build_streaming_agent!(
1236 self,
1237 provider::gemini_builder,
1238 fast_model,
1239 api_key,
1240 subagent_timeout
1241 )
1242 }
1243
1244 fn load_capability_config(&self, capability: &str) -> Result<(String, String)> {
1246 let _ = self; let content = match capability {
1249 "commit" => CAPABILITY_COMMIT,
1250 "pr" => CAPABILITY_PR,
1251 "review" => CAPABILITY_REVIEW,
1252 "changelog" => CAPABILITY_CHANGELOG,
1253 "release_notes" => CAPABILITY_RELEASE_NOTES,
1254 "chat" => CAPABILITY_CHAT,
1255 "semantic_blame" => CAPABILITY_SEMANTIC_BLAME,
1256 _ => {
1257 return Ok((
1259 format!(
1260 "You are helping with a {capability} task. Use the available Git tools to assist the user."
1261 ),
1262 "PlainText".to_string(),
1263 ));
1264 }
1265 };
1266
1267 let parsed: toml::Value = toml::from_str(content)?;
1269
1270 let task_prompt = parsed
1271 .get("task_prompt")
1272 .and_then(|v| v.as_str())
1273 .ok_or_else(|| anyhow::anyhow!("No task_prompt found in capability file"))?;
1274
1275 let output_type = parsed
1276 .get("output_type")
1277 .and_then(|v| v.as_str())
1278 .unwrap_or("PlainText")
1279 .to_string();
1280
1281 Ok((task_prompt.to_string(), output_type))
1282 }
1283
1284 #[must_use]
1286 pub fn current_capability(&self) -> Option<&str> {
1287 self.current_capability.as_deref()
1288 }
1289
1290 pub async fn chat(&self, message: &str) -> Result<String> {
1296 let agent = self.build_agent()?;
1297 let response = agent.prompt(message).await?;
1298 Ok(response)
1299 }
1300
1301 pub fn set_capability(&mut self, capability: &str) {
1303 self.current_capability = Some(capability.to_string());
1304 }
1305
1306 #[must_use]
1308 pub fn provider_config(&self) -> &HashMap<String, String> {
1309 &self.provider_config
1310 }
1311
1312 pub fn set_provider_config(&mut self, config: HashMap<String, String>) {
1314 self.provider_config = config;
1315 }
1316
1317 pub fn set_preamble(&mut self, preamble: String) {
1319 self.preamble = Some(preamble);
1320 }
1321
1322 pub fn set_config(&mut self, config: crate::config::Config) {
1324 self.config = Some(config);
1325 }
1326
1327 pub fn set_fast_model(&mut self, fast_model: String) {
1329 self.fast_model = Some(fast_model);
1330 }
1331}
1332
1333pub struct IrisAgentBuilder {
1335 provider: String,
1336 model: String,
1337 preamble: Option<String>,
1338}
1339
1340impl IrisAgentBuilder {
1341 #[must_use]
1343 pub fn new() -> Self {
1344 Self {
1345 provider: "openai".to_string(),
1346 model: "gpt-5.4".to_string(),
1347 preamble: None,
1348 }
1349 }
1350
1351 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
1353 self.provider = provider.into();
1354 self
1355 }
1356
1357 pub fn with_model(mut self, model: impl Into<String>) -> Self {
1359 self.model = model.into();
1360 self
1361 }
1362
1363 pub fn with_preamble(mut self, preamble: impl Into<String>) -> Self {
1365 self.preamble = Some(preamble.into());
1366 self
1367 }
1368
1369 pub fn build(self) -> Result<IrisAgent> {
1375 let mut agent = IrisAgent::new(&self.provider, &self.model)?;
1376
1377 if let Some(preamble) = self.preamble {
1379 agent.set_preamble(preamble);
1380 }
1381
1382 Ok(agent)
1383 }
1384}
1385
1386impl Default for IrisAgentBuilder {
1387 fn default() -> Self {
1388 Self::new()
1389 }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::{
1395 IrisAgent, extract_json_from_response, find_balanced_braces, sanitize_json_response,
1396 streaming_response_instructions,
1397 };
1398 use serde_json::Value;
1399 use std::borrow::Cow;
1400
1401 #[test]
1402 fn sanitize_json_response_is_noop_for_valid_payloads() {
1403 let raw = r#"{"title":"Test","description":"All good"}"#;
1404 let sanitized = sanitize_json_response(raw);
1405 assert!(matches!(sanitized, Cow::Borrowed(_)));
1406 serde_json::from_str::<Value>(sanitized.as_ref()).expect("valid JSON");
1407 }
1408
1409 #[test]
1410 fn sanitize_json_response_escapes_literal_newlines() {
1411 let raw = "{\"description\": \"Line1
1412Line2\"}";
1413 let sanitized = sanitize_json_response(raw);
1414 assert_eq!(sanitized.as_ref(), "{\"description\": \"Line1\\nLine2\"}");
1415 serde_json::from_str::<Value>(sanitized.as_ref()).expect("json sanitized");
1416 }
1417
1418 #[test]
1419 fn chat_streaming_instructions_avoid_markdown_suffix() {
1420 let instructions = streaming_response_instructions("chat");
1421 assert!(instructions.contains("plain text"));
1422 assert!(instructions.contains("do not repeat full content"));
1423 assert!(!instructions.contains("markdown format"));
1424 }
1425
1426 #[test]
1427 fn structured_streaming_instructions_still_use_markdown_suffix() {
1428 let instructions = streaming_response_instructions("review");
1429 assert!(instructions.contains("markdown format"));
1430 assert!(instructions.contains("well-structured"));
1431 }
1432
1433 #[test]
1434 fn find_balanced_braces_returns_first_balanced_pair() {
1435 let (start, end) = find_balanced_braces("prefix {\"a\":1} suffix").expect("balanced pair");
1436 assert_eq!(&"prefix {\"a\":1} suffix"[start..end], "{\"a\":1}");
1437 }
1438
1439 #[test]
1440 fn find_balanced_braces_returns_none_for_unbalanced() {
1441 assert_eq!(find_balanced_braces("no braces here"), None);
1442 assert_eq!(find_balanced_braces("{ unclosed"), None);
1443 }
1444
1445 #[test]
1446 fn extract_json_skips_github_actions_expression_false_positive() {
1447 let response = r#"Looking at the diff, I see the new value `${{ github.ref_name }}` replacing the old bash expansion. Here's the commit:
1452
1453{"emoji": "🔧", "title": "Upgrade AUR deploy action", "message": "Bump to v4.1.2 to fix bash --command error."}
1454"#;
1455 let extracted = extract_json_from_response(response).expect("should recover real JSON");
1456 let parsed: Value = serde_json::from_str(&extracted).expect("extracted value is JSON");
1457 assert_eq!(parsed["emoji"], "🔧");
1458 assert_eq!(parsed["title"], "Upgrade AUR deploy action");
1459 }
1460
1461 #[test]
1462 fn extract_json_from_pure_json_response() {
1463 let response = r##"{"content": "# Heading\n\nBody text."}"##;
1464 let extracted = extract_json_from_response(response).expect("pure JSON passes through");
1465 assert_eq!(extracted, response);
1466 }
1467
1468 #[test]
1469 fn extract_json_errors_when_no_candidate_parses() {
1470 let response = "prose ${{ template }} more prose";
1473 let err = extract_json_from_response(response).expect_err("should fail");
1474 let msg = err.to_string();
1475 assert!(
1476 msg.contains("Preview:"),
1477 "error should include a preview: {msg}"
1478 );
1479 }
1480
1481 #[test]
1482 fn pr_review_emoji_styling_uses_a_compact_gitmoji_guide() {
1483 let mut prompt = String::new();
1484 IrisAgent::inject_pr_review_emoji_styling(&mut prompt);
1485
1486 assert!(prompt.contains("Common gitmoji choices:"));
1487 assert!(prompt.contains("`:feat:`"));
1488 assert!(prompt.contains("`:fix:`"));
1489 assert!(!prompt.contains("`:accessibility:`"));
1490 assert!(!prompt.contains("`:analytics:`"));
1491 }
1492}