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
109fn streaming_response_instructions(capability: &str) -> &'static str {
110 if capability == "chat" {
111 "After using the available tools, respond in plain text.\n\
112 Keep it concise and do not repeat full content that tools already updated."
113 } else {
114 "After using the available tools, respond with your analysis in markdown format.\n\
115 Keep it clear, well-structured, and informative."
116 }
117}
118
119use crate::agents::provider::{self, CompletionProfile, DynAgent};
120use crate::agents::tools::{GitRepoInfo, ParallelAnalyze, Workspace};
121
122#[async_trait::async_trait]
124pub trait StreamingCallback: Send + Sync {
125 async fn on_chunk(
127 &self,
128 chunk: &str,
129 tokens: Option<crate::agents::status::TokenMetrics>,
130 ) -> Result<()>;
131
132 async fn on_complete(
134 &self,
135 full_response: &str,
136 final_tokens: crate::agents::status::TokenMetrics,
137 ) -> Result<()>;
138
139 async fn on_error(&self, error: &anyhow::Error) -> Result<()>;
141
142 async fn on_status_update(&self, message: &str) -> Result<()>;
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub enum StructuredResponse {
149 CommitMessage(crate::types::GeneratedMessage),
150 PullRequest(crate::types::MarkdownPullRequest),
151 Changelog(crate::types::MarkdownChangelog),
152 ReleaseNotes(crate::types::MarkdownReleaseNotes),
153 MarkdownReview(crate::types::MarkdownReview),
155 SemanticBlame(String),
157 PlainText(String),
158}
159
160impl fmt::Display for StructuredResponse {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 match self {
163 StructuredResponse::CommitMessage(msg) => {
164 write!(f, "{}", crate::types::format_commit_message(msg))
165 }
166 StructuredResponse::PullRequest(pr) => {
167 write!(f, "{}", pr.raw_content())
168 }
169 StructuredResponse::Changelog(cl) => {
170 write!(f, "{}", cl.raw_content())
171 }
172 StructuredResponse::ReleaseNotes(rn) => {
173 write!(f, "{}", rn.raw_content())
174 }
175 StructuredResponse::MarkdownReview(review) => {
176 write!(f, "{}", review.format())
177 }
178 StructuredResponse::SemanticBlame(explanation) => {
179 write!(f, "{explanation}")
180 }
181 StructuredResponse::PlainText(text) => {
182 write!(f, "{text}")
183 }
184 }
185 }
186}
187
188fn find_balanced_braces(s: &str) -> Option<(usize, usize)> {
195 let mut depth: i32 = 0;
196 let mut start: Option<usize> = None;
197 for (i, ch) in s.char_indices() {
198 match ch {
199 '{' => {
200 if depth == 0 {
201 start = Some(i);
202 }
203 depth += 1;
204 }
205 '}' if depth > 0 => {
206 depth -= 1;
207 if depth == 0 {
208 return start.map(|s_idx| (s_idx, i + 1));
209 }
210 }
211 _ => {}
212 }
213 }
214 None
215}
216
217fn extract_json_from_response(response: &str) -> Result<String> {
219 use crate::agents::debug;
220
221 debug::debug_section("JSON Extraction");
222
223 let trimmed_response = response.trim();
224
225 if trimmed_response.starts_with('{')
227 && serde_json::from_str::<serde_json::Value>(trimmed_response).is_ok()
228 {
229 debug::debug_context_management(
230 "Response is pure JSON",
231 &format!("{} characters", trimmed_response.len()),
232 );
233 return Ok(trimmed_response.to_string());
234 }
235
236 if let Some(start) = response.find("```json") {
238 let content_start = start + "```json".len();
239 let json_end = if let Some(end) = response[content_start..].find("\n```") {
242 end
244 } else {
245 response[content_start..]
247 .find("```")
248 .unwrap_or(response.len() - content_start)
249 };
250
251 let json_content = &response[content_start..content_start + json_end];
252 let trimmed = json_content.trim().to_string();
253
254 debug::debug_context_management(
255 "Found JSON in markdown code block",
256 &format!("{} characters", trimmed.len()),
257 );
258
259 if let Err(e) = debug::write_debug_artifact("iris_extracted.json", &trimmed) {
261 debug::debug_warning(&format!("Failed to write extracted JSON: {}", e));
262 }
263
264 debug::debug_json_parse_attempt(&trimmed);
265 return Ok(trimmed);
266 }
267
268 let mut last_error: Option<anyhow::Error> = None;
276 let mut cursor = 0;
277 while cursor < response.len() {
278 let Some((rel_start, rel_end)) = find_balanced_braces(&response[cursor..]) else {
279 break;
280 };
281 let start = cursor + rel_start;
282 let end = cursor + rel_end;
283 let json_content = &response[start..end];
284 debug::debug_json_parse_attempt(json_content);
285
286 let sanitized = sanitize_json_response(json_content);
287 match serde_json::from_str::<serde_json::Value>(&sanitized) {
288 Ok(_) => {
289 debug::debug_context_management(
290 "Found valid JSON object",
291 &format!("{} characters", json_content.len()),
292 );
293 return Ok(sanitized.into_owned());
294 }
295 Err(e) => {
296 debug::debug_json_parse_error(&format!(
297 "Candidate at offset {} is not valid JSON: {}",
298 start, e
299 ));
300 let preview = if json_content.len() > 200 {
301 format!("{}...", &json_content[..200])
302 } else {
303 json_content.to_string()
304 };
305 last_error = Some(anyhow::anyhow!(
306 "Found JSON-like content but it's not valid JSON: {}\nPreview: {}",
307 e,
308 preview
309 ));
310 cursor = start + 1;
313 }
314 }
315 }
316
317 if let Some(err) = last_error {
318 return Err(err);
319 }
320
321 let trimmed = response.trim();
324 if trimmed.starts_with('#') || trimmed.starts_with("##") {
325 debug::debug_context_management(
326 "Detected raw markdown response",
327 "Wrapping in JSON structure",
328 );
329 let escaped_content = serde_json::to_string(trimmed)?;
331 let wrapped = format!(r#"{{"content": {}}}"#, escaped_content);
333 debug::debug_json_parse_attempt(&wrapped);
334 return Ok(wrapped);
335 }
336
337 debug::debug_json_parse_error("No valid JSON found in response");
339 Err(anyhow::anyhow!("No valid JSON found in response"))
340}
341
342fn sanitize_json_response(raw: &str) -> Cow<'_, str> {
347 let mut needs_sanitization = false;
348 let mut in_string = false;
349 let mut escaped = false;
350
351 for ch in raw.chars() {
352 if in_string {
353 if escaped {
354 escaped = false;
355 continue;
356 }
357
358 match ch {
359 '\\' => escaped = true,
360 '"' => in_string = false,
361 '\n' | '\r' | '\t' => {
362 needs_sanitization = true;
363 break;
364 }
365 c if c.is_control() => {
366 needs_sanitization = true;
367 break;
368 }
369 _ => {}
370 }
371 } else if ch == '"' {
372 in_string = true;
373 }
374 }
375
376 if !needs_sanitization {
377 return Cow::Borrowed(raw);
378 }
379
380 let mut sanitized = String::with_capacity(raw.len());
381 in_string = false;
382 escaped = false;
383
384 for ch in raw.chars() {
385 if in_string {
386 if escaped {
387 sanitized.push(ch);
388 escaped = false;
389 continue;
390 }
391
392 match ch {
393 '\\' => {
394 sanitized.push('\\');
395 escaped = true;
396 }
397 '"' => {
398 sanitized.push('"');
399 in_string = false;
400 }
401 '\n' => sanitized.push_str("\\n"),
402 '\r' => sanitized.push_str("\\r"),
403 '\t' => sanitized.push_str("\\t"),
404 c if c.is_control() => {
405 use std::fmt::Write as _;
406 let _ = write!(&mut sanitized, "\\u{:04X}", u32::from(c));
407 }
408 _ => sanitized.push(ch),
409 }
410 } else {
411 sanitized.push(ch);
412 if ch == '"' {
413 in_string = true;
414 escaped = false;
415 }
416 }
417 }
418
419 Cow::Owned(sanitized)
420}
421
422fn parse_with_recovery<T>(json_str: &str) -> Result<T>
429where
430 T: JsonSchema + DeserializeOwned,
431{
432 use crate::agents::debug as agent_debug;
433 use crate::agents::output_validator::validate_and_parse;
434
435 let validation_result = validate_and_parse::<T>(json_str)?;
436
437 if validation_result.recovered {
439 agent_debug::debug_context_management(
440 "JSON recovery applied",
441 &format!("{} issues fixed", validation_result.warnings.len()),
442 );
443 for warning in &validation_result.warnings {
444 agent_debug::debug_warning(warning);
445 }
446 }
447
448 validation_result
449 .value
450 .ok_or_else(|| anyhow::anyhow!("Failed to parse JSON even after recovery"))
451}
452
453pub struct IrisAgent {
459 provider: String,
460 model: String,
461 fast_model: Option<String>,
463 current_capability: Option<String>,
465 provider_config: HashMap<String, String>,
467 preamble: Option<String>,
469 config: Option<crate::config::Config>,
471 content_update_sender: Option<crate::agents::tools::ContentUpdateSender>,
473 workspace: Workspace,
475}
476
477impl IrisAgent {
478 pub fn new(provider: &str, model: &str) -> Result<Self> {
484 Ok(Self {
485 provider: provider.to_string(),
486 model: model.to_string(),
487 fast_model: None,
488 current_capability: None,
489 provider_config: HashMap::new(),
490 preamble: None,
491 config: None,
492 content_update_sender: None,
493 workspace: Workspace::new(),
494 })
495 }
496
497 pub fn set_content_update_sender(&mut self, sender: crate::agents::tools::ContentUpdateSender) {
502 self.content_update_sender = Some(sender);
503 }
504
505 fn effective_fast_model(&self) -> &str {
507 self.fast_model.as_deref().unwrap_or(&self.model)
508 }
509
510 fn get_api_key(&self) -> Option<&str> {
512 provider::current_provider_config(self.config.as_ref(), &self.provider)
513 .and_then(crate::providers::ProviderConfig::api_key_if_set)
514 }
515
516 fn current_provider(&self) -> Result<crate::providers::Provider> {
517 provider::provider_from_name(&self.provider)
518 }
519
520 fn current_provider_additional_params(&self) -> Option<&HashMap<String, String>> {
521 provider::current_provider_config(self.config.as_ref(), &self.provider)
522 .map(|provider_config| &provider_config.additional_params)
523 }
524
525 fn build_agent(&self) -> Result<DynAgent> {
531 use crate::agents::debug_tool::DebugTool;
532
533 let preamble = self.preamble.as_deref().unwrap_or(DEFAULT_PREAMBLE);
534 let fast_model = self.effective_fast_model();
535 let api_key = self.get_api_key();
536 let subagent_timeout = self
537 .config
538 .as_ref()
539 .map_or(120, |c| c.subagent_timeout_secs);
540
541 macro_rules! build_subagent {
543 ($builder:expr) => {{
544 let builder = $builder
545 .name("analyze_subagent")
546 .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.")
547 .preamble("You are a specialized analysis sub-agent for Iris. Your job is to complete focused analysis tasks and return concise, actionable summaries.
548
549Guidelines:
550- Use the available tools to gather information
551- Focus only on what's asked - don't expand scope
552- Return a clear, structured summary of findings
553- Highlight important issues, patterns, or insights
554- Keep your response focused and concise")
555 ;
556 let builder = self.apply_completion_params(
557 builder,
558 fast_model,
559 4096,
560 CompletionProfile::Subagent,
561 )?;
562 crate::attach_core_tools!(builder).build()
563 }};
564 }
565
566 macro_rules! attach_main_tools {
568 ($builder:expr) => {{
569 crate::attach_core_tools!($builder)
570 .tool(DebugTool::new(GitRepoInfo))
571 .tool(DebugTool::new(self.workspace.clone()))
572 .tool(DebugTool::new(ParallelAnalyze::with_timeout(
573 &self.provider,
574 fast_model,
575 subagent_timeout,
576 api_key,
577 self.current_provider_additional_params().cloned(),
578 )?))
579 }};
580 }
581
582 macro_rules! maybe_attach_update_tools {
584 ($builder:expr) => {{
585 if let Some(sender) = &self.content_update_sender {
586 use crate::agents::tools::{UpdateCommitTool, UpdatePRTool, UpdateReviewTool};
587 $builder
588 .tool(DebugTool::new(UpdateCommitTool::new(sender.clone())))
589 .tool(DebugTool::new(UpdatePRTool::new(sender.clone())))
590 .tool(DebugTool::new(UpdateReviewTool::new(sender.clone())))
591 .build()
592 } else {
593 $builder.build()
594 }
595 }};
596 }
597
598 match self.provider.as_str() {
599 "openai" => {
600 let sub_agent = build_subagent!(provider::openai_builder(fast_model, api_key)?);
602
603 let builder = provider::openai_builder(&self.model, api_key)?.preamble(preamble);
605 let builder = self.apply_completion_params(
606 builder,
607 &self.model,
608 16384,
609 CompletionProfile::MainAgent,
610 )?;
611 let builder = attach_main_tools!(builder).tool(sub_agent);
612 let agent = maybe_attach_update_tools!(builder);
613 Ok(DynAgent::OpenAI(agent))
614 }
615 "anthropic" => {
616 let sub_agent = build_subagent!(provider::anthropic_builder(fast_model, api_key)?);
618
619 let builder = provider::anthropic_builder(&self.model, api_key)?.preamble(preamble);
621 let builder = self.apply_completion_params(
622 builder,
623 &self.model,
624 16384,
625 CompletionProfile::MainAgent,
626 )?;
627 let builder = attach_main_tools!(builder).tool(sub_agent);
628 let agent = maybe_attach_update_tools!(builder);
629 Ok(DynAgent::Anthropic(agent))
630 }
631 "google" | "gemini" => {
632 let sub_agent = build_subagent!(provider::gemini_builder(fast_model, api_key)?);
634
635 let builder = provider::gemini_builder(&self.model, api_key)?.preamble(preamble);
637 let builder = self.apply_completion_params(
638 builder,
639 &self.model,
640 16384,
641 CompletionProfile::MainAgent,
642 )?;
643 let builder = attach_main_tools!(builder).tool(sub_agent);
644 let agent = maybe_attach_update_tools!(builder);
645 Ok(DynAgent::Gemini(agent))
646 }
647 _ => Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
648 }
649 }
650
651 fn apply_completion_params<M>(
652 &self,
653 builder: AgentBuilder<M>,
654 model: &str,
655 max_tokens: u64,
656 profile: CompletionProfile,
657 ) -> Result<AgentBuilder<M>>
658 where
659 M: CompletionModel,
660 {
661 let provider = self.current_provider()?;
662 Ok(provider::apply_completion_params(
663 builder,
664 provider,
665 model,
666 max_tokens,
667 self.current_provider_additional_params(),
668 profile,
669 ))
670 }
671
672 async fn execute_with_agent<T>(&self, system_prompt: &str, user_prompt: &str) -> Result<T>
675 where
676 T: JsonSchema + for<'a> serde::Deserialize<'a> + serde::Serialize + Send + Sync + 'static,
677 {
678 use crate::agents::debug;
679 use crate::agents::status::IrisPhase;
680 use crate::messages::get_capability_message;
681 use schemars::schema_for;
682
683 let capability = self.current_capability().unwrap_or("commit");
684
685 debug::debug_phase_change(&format!("AGENT EXECUTION: {}", std::any::type_name::<T>()));
686
687 let msg = get_capability_message(capability);
689 crate::iris_status_dynamic!(IrisPhase::Planning, msg.text, 2, 4);
690
691 let agent = self.build_agent()?;
693 debug::debug_context_management(
694 "Agent built with tools",
695 &format!(
696 "Provider: {}, Model: {} (fast: {})",
697 self.provider,
698 self.model,
699 self.effective_fast_model()
700 ),
701 );
702
703 let schema = schema_for!(T);
705 let schema_json = serde_json::to_string_pretty(&schema)?;
706 debug::debug_context_management(
707 "JSON schema created",
708 &format!("Type: {}", std::any::type_name::<T>()),
709 );
710
711 let full_prompt = format!(
713 "{system_prompt}\n\n{user_prompt}\n\n\
714 === CRITICAL: RESPONSE FORMAT ===\n\
715 After using the available tools to gather necessary information, you MUST respond with ONLY a valid JSON object.\n\n\
716 REQUIRED JSON SCHEMA:\n\
717 {schema_json}\n\n\
718 CRITICAL INSTRUCTIONS:\n\
719 - Return ONLY the raw JSON object - nothing else\n\
720 - NO explanations before the JSON\n\
721 - NO explanations after the JSON\n\
722 - NO markdown code blocks (just raw JSON)\n\
723 - NO preamble text like 'Here is the JSON:' or 'Let me generate:'\n\
724 - Start your response with {{ and end with }}\n\
725 - The JSON must be complete and valid\n\n\
726 Your entire response should be ONLY the JSON object."
727 );
728
729 debug::debug_llm_request(&full_prompt, Some(16384));
730
731 let gen_msg = get_capability_message(capability);
733 crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
734
735 let timer = debug::DebugTimer::start("Agent prompt execution");
740
741 debug::debug_context_management(
742 "LLM request",
743 "Sending prompt to agent with multi_turn(50)",
744 );
745 let prompt_response: PromptResponse = agent.prompt_extended(&full_prompt, 50).await?;
746
747 timer.finish();
748
749 let usage = &prompt_response.usage;
751 debug::debug_context_management(
752 "Token usage",
753 &format!(
754 "input: {} | output: {} | total: {}",
755 usage.input_tokens, usage.output_tokens, usage.total_tokens
756 ),
757 );
758
759 let response = &prompt_response.output;
760 #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
761 let total_tokens_usize = usage.total_tokens as usize;
762 debug::debug_llm_response(
763 response,
764 std::time::Duration::from_secs(0),
765 Some(total_tokens_usize),
766 );
767
768 crate::iris_status_dynamic!(
770 IrisPhase::Synthesis,
771 "✨ Iris is synthesizing results...",
772 4,
773 4
774 );
775
776 let json_str = extract_json_from_response(response)?;
778 let sanitized_json = sanitize_json_response(&json_str);
779 let sanitized_ref = sanitized_json.as_ref();
780
781 if matches!(sanitized_json, Cow::Borrowed(_)) {
782 debug::debug_json_parse_attempt(sanitized_ref);
783 } else {
784 debug::debug_context_management(
785 "Sanitized JSON response",
786 &format!("{} → {} characters", json_str.len(), sanitized_ref.len()),
787 );
788 debug::debug_json_parse_attempt(sanitized_ref);
789 }
790
791 let result: T = parse_with_recovery(sanitized_ref)?;
793
794 debug::debug_json_parse_success(std::any::type_name::<T>());
795
796 crate::iris_status_completed!();
798
799 Ok(result)
800 }
801
802 fn inject_style_instructions(&self, system_prompt: &mut String, capability: &str) {
808 let Some(config) = &self.config else {
809 return;
810 };
811
812 let preset_name = config.get_effective_preset_name();
813 let is_conventional = preset_name == "conventional";
814 let is_default_mode = preset_name == "default" || preset_name.is_empty();
815
816 let use_style_detection =
818 capability == "commit" && is_default_mode && config.gitmoji_override.is_none();
819
820 let commit_emoji = config.use_gitmoji && !is_conventional && !use_style_detection;
822
823 let output_emoji = config.gitmoji_override.unwrap_or(config.use_gitmoji);
826
827 if !preset_name.is_empty() && !is_default_mode {
829 let library = crate::instruction_presets::get_instruction_preset_library();
830 if let Some(preset) = library.get_preset(preset_name) {
831 tracing::info!("📋 Injecting '{}' preset style instructions", preset_name);
832 system_prompt.push_str("\n\n=== STYLE INSTRUCTIONS ===\n");
833 system_prompt.push_str(&preset.instructions);
834 system_prompt.push('\n');
835 } else {
836 tracing::warn!("⚠️ Preset '{}' not found in library", preset_name);
837 }
838 }
839
840 if capability == "commit" {
844 if commit_emoji {
845 system_prompt.push_str("\n\n=== GITMOJI INSTRUCTIONS ===\n");
846 system_prompt.push_str("Set the 'emoji' field to a single relevant gitmoji. ");
847 system_prompt.push_str(
848 "DO NOT include the emoji in the 'message' or 'title' text - only set the 'emoji' field. ",
849 );
850 system_prompt.push_str("Choose the closest match from this compact guide:\n\n");
851 system_prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
852 system_prompt.push_str("\n\nThe emoji should match the primary type of change.");
853 } else if is_conventional {
854 system_prompt.push_str("\n\n=== CONVENTIONAL COMMITS FORMAT ===\n");
855 system_prompt.push_str("IMPORTANT: This uses Conventional Commits format. ");
856 system_prompt
857 .push_str("DO NOT include any emojis in the commit message or PR title. ");
858 system_prompt.push_str("The 'emoji' field should be null.");
859 }
860 }
861
862 if capability == "pr" || capability == "review" {
864 if output_emoji {
865 Self::inject_pr_review_emoji_styling(system_prompt);
866 } else {
867 Self::inject_no_emoji_styling(system_prompt);
868 }
869 }
870
871 if capability == "release_notes" && output_emoji {
872 Self::inject_release_notes_emoji_styling(system_prompt);
873 } else if capability == "release_notes" {
874 Self::inject_no_emoji_styling(system_prompt);
875 }
876
877 if capability == "changelog" && output_emoji {
878 Self::inject_changelog_emoji_styling(system_prompt);
879 } else if capability == "changelog" {
880 Self::inject_no_emoji_styling(system_prompt);
881 }
882 }
883
884 fn inject_pr_review_emoji_styling(prompt: &mut String) {
885 prompt.push_str("\n\n=== EMOJI STYLING ===\n");
886 prompt.push_str("Use emojis to make the output visually scannable and engaging:\n");
887 prompt.push_str("- H1 title: ONE gitmoji at the start (✨, 🐛, ♻️, etc.)\n");
888 prompt.push_str("- Section headers: Add relevant emojis (🎯 What's New, ⚙️ How It Works, 📋 Commits, ⚠️ Breaking Changes)\n");
889 prompt.push_str("- Commit list entries: Include gitmoji where appropriate\n");
890 prompt.push_str("- Body text: Keep clean - no scattered emojis within prose\n\n");
891 prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
892 }
893
894 fn inject_release_notes_emoji_styling(prompt: &mut String) {
895 prompt.push_str("\n\n=== EMOJI STYLING ===\n");
896 prompt.push_str("Use at most one emoji per highlight/section title. No emojis in bullet descriptions, upgrade notes, or metrics. ");
897 prompt.push_str("Pick from the approved gitmoji list (e.g., 🌟 Highlights, 🤖 Agents, 🔧 Tooling, 🐛 Fixes, ⚡ Performance). ");
898 prompt.push_str("Never sprinkle emojis within sentences or JSON keys.\n\n");
899 prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
900 }
901
902 fn inject_changelog_emoji_styling(prompt: &mut String) {
903 prompt.push_str("\n\n=== EMOJI STYLING ===\n");
904 prompt.push_str("Section keys must remain plain text (Added/Changed/Deprecated/Removed/Fixed/Security). ");
905 prompt.push_str(
906 "You may include one emoji within a change description to reinforce meaning. ",
907 );
908 prompt.push_str(
909 "Never add emojis to JSON keys, section names, metrics, or upgrade notes.\n\n",
910 );
911 prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
912 }
913
914 fn inject_no_emoji_styling(prompt: &mut String) {
915 prompt.push_str("\n\n=== NO EMOJI STYLING ===\n");
916 prompt.push_str(
917 "DO NOT include any emojis anywhere in the output. Keep all content plain text.",
918 );
919 }
920
921 pub async fn execute_task(
929 &mut self,
930 capability: &str,
931 user_prompt: &str,
932 ) -> Result<StructuredResponse> {
933 use crate::agents::status::IrisPhase;
934 use crate::messages::get_capability_message;
935
936 let waiting_msg = get_capability_message(capability);
938 crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
939
940 let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
942
943 self.inject_style_instructions(&mut system_prompt, capability);
945
946 self.current_capability = Some(capability.to_string());
948
949 crate::iris_status_dynamic!(
951 IrisPhase::Analysis,
952 "🔍 Iris is analyzing your changes...",
953 2,
954 4
955 );
956
957 match output_type.as_str() {
960 "GeneratedMessage" => {
961 let response = self
962 .execute_with_agent::<crate::types::GeneratedMessage>(
963 &system_prompt,
964 user_prompt,
965 )
966 .await?;
967 Ok(StructuredResponse::CommitMessage(response))
968 }
969 "MarkdownPullRequest" => {
970 let response = self
971 .execute_with_agent::<crate::types::MarkdownPullRequest>(
972 &system_prompt,
973 user_prompt,
974 )
975 .await?;
976 Ok(StructuredResponse::PullRequest(response))
977 }
978 "MarkdownChangelog" => {
979 let response = self
980 .execute_with_agent::<crate::types::MarkdownChangelog>(
981 &system_prompt,
982 user_prompt,
983 )
984 .await?;
985 Ok(StructuredResponse::Changelog(response))
986 }
987 "MarkdownReleaseNotes" => {
988 let response = self
989 .execute_with_agent::<crate::types::MarkdownReleaseNotes>(
990 &system_prompt,
991 user_prompt,
992 )
993 .await?;
994 Ok(StructuredResponse::ReleaseNotes(response))
995 }
996 "MarkdownReview" => {
997 let response = self
998 .execute_with_agent::<crate::types::MarkdownReview>(&system_prompt, user_prompt)
999 .await?;
1000 Ok(StructuredResponse::MarkdownReview(response))
1001 }
1002 "SemanticBlame" => {
1003 let agent = self.build_agent()?;
1005 let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
1006 let response = agent.prompt_multi_turn(&full_prompt, 10).await?;
1007 Ok(StructuredResponse::SemanticBlame(response))
1008 }
1009 _ => {
1010 let agent = self.build_agent()?;
1012 let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
1013 let response = agent.prompt_multi_turn(&full_prompt, 50).await?;
1015 Ok(StructuredResponse::PlainText(response))
1016 }
1017 }
1018 }
1019
1020 pub async fn execute_task_streaming<F>(
1031 &mut self,
1032 capability: &str,
1033 user_prompt: &str,
1034 mut on_chunk: F,
1035 ) -> Result<StructuredResponse>
1036 where
1037 F: FnMut(&str, &str) + Send,
1038 {
1039 use crate::agents::status::IrisPhase;
1040 use crate::messages::get_capability_message;
1041 use futures::StreamExt;
1042 use rig::agent::MultiTurnStreamItem;
1043 use rig::streaming::{StreamedAssistantContent, StreamingPrompt};
1044
1045 let waiting_msg = get_capability_message(capability);
1047 crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
1048
1049 let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
1051
1052 self.inject_style_instructions(&mut system_prompt, capability);
1054
1055 self.current_capability = Some(capability.to_string());
1057
1058 crate::iris_status_dynamic!(
1060 IrisPhase::Analysis,
1061 "🔍 Iris is analyzing your changes...",
1062 2,
1063 4
1064 );
1065
1066 let full_prompt = format!(
1068 "{}\n\n{}\n\n{}",
1069 system_prompt,
1070 user_prompt,
1071 streaming_response_instructions(capability)
1072 );
1073
1074 let gen_msg = get_capability_message(capability);
1076 crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
1077
1078 macro_rules! consume_stream {
1080 ($stream:expr) => {{
1081 let mut aggregated_text = String::new();
1082 let mut stream = $stream;
1083 while let Some(item) = stream.next().await {
1084 match item {
1085 Ok(MultiTurnStreamItem::StreamAssistantItem(
1086 StreamedAssistantContent::Text(text),
1087 )) => {
1088 aggregated_text.push_str(&text.text);
1089 on_chunk(&text.text, &aggregated_text);
1090 }
1091 Ok(MultiTurnStreamItem::StreamAssistantItem(
1092 StreamedAssistantContent::ToolCall { tool_call, .. },
1093 )) => {
1094 let tool_name = &tool_call.function.name;
1095 let reason = format!("Calling {}", tool_name);
1096 crate::iris_status_dynamic!(
1097 IrisPhase::ToolExecution {
1098 tool_name: tool_name.clone(),
1099 reason: reason.clone()
1100 },
1101 format!("🔧 {}", reason),
1102 3,
1103 4
1104 );
1105 }
1106 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
1107 Err(e) => return Err(anyhow::anyhow!("Streaming error: {}", e)),
1108 _ => {}
1109 }
1110 }
1111 aggregated_text
1112 }};
1113 }
1114
1115 let aggregated_text = match self.provider.as_str() {
1117 "openai" => {
1118 let agent = self.build_openai_agent_for_streaming(&full_prompt)?;
1119 let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1120 consume_stream!(stream)
1121 }
1122 "anthropic" => {
1123 let agent = self.build_anthropic_agent_for_streaming(&full_prompt)?;
1124 let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1125 consume_stream!(stream)
1126 }
1127 "google" | "gemini" => {
1128 let agent = self.build_gemini_agent_for_streaming(&full_prompt)?;
1129 let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1130 consume_stream!(stream)
1131 }
1132 _ => return Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
1133 };
1134
1135 crate::iris_status_dynamic!(
1137 IrisPhase::Synthesis,
1138 "✨ Iris is synthesizing results...",
1139 4,
1140 4
1141 );
1142
1143 let response = Self::text_to_structured_response(&output_type, aggregated_text);
1144 crate::iris_status_completed!();
1145 Ok(response)
1146 }
1147
1148 fn text_to_structured_response(output_type: &str, text: String) -> StructuredResponse {
1150 match output_type {
1151 "MarkdownReview" => {
1152 StructuredResponse::MarkdownReview(crate::types::MarkdownReview { content: text })
1153 }
1154 "MarkdownPullRequest" => {
1155 StructuredResponse::PullRequest(crate::types::MarkdownPullRequest { content: text })
1156 }
1157 "MarkdownChangelog" => {
1158 StructuredResponse::Changelog(crate::types::MarkdownChangelog { content: text })
1159 }
1160 "MarkdownReleaseNotes" => {
1161 StructuredResponse::ReleaseNotes(crate::types::MarkdownReleaseNotes {
1162 content: text,
1163 })
1164 }
1165 "SemanticBlame" => StructuredResponse::SemanticBlame(text),
1166 _ => StructuredResponse::PlainText(text),
1167 }
1168 }
1169
1170 fn streaming_agent_config(&self) -> (&str, Option<&str>, u64) {
1172 let fast_model = self.effective_fast_model();
1173 let api_key = self.get_api_key();
1174 let subagent_timeout = self
1175 .config
1176 .as_ref()
1177 .map_or(120, |c| c.subagent_timeout_secs);
1178 (fast_model, api_key, subagent_timeout)
1179 }
1180
1181 fn build_openai_agent_for_streaming(
1183 &self,
1184 _prompt: &str,
1185 ) -> Result<rig::agent::Agent<provider::OpenAIModel>> {
1186 let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1187 build_streaming_agent!(
1188 self,
1189 provider::openai_builder,
1190 fast_model,
1191 api_key,
1192 subagent_timeout
1193 )
1194 }
1195
1196 fn build_anthropic_agent_for_streaming(
1198 &self,
1199 _prompt: &str,
1200 ) -> Result<rig::agent::Agent<provider::AnthropicModel>> {
1201 let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1202 build_streaming_agent!(
1203 self,
1204 provider::anthropic_builder,
1205 fast_model,
1206 api_key,
1207 subagent_timeout
1208 )
1209 }
1210
1211 fn build_gemini_agent_for_streaming(
1213 &self,
1214 _prompt: &str,
1215 ) -> Result<rig::agent::Agent<provider::GeminiModel>> {
1216 let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1217 build_streaming_agent!(
1218 self,
1219 provider::gemini_builder,
1220 fast_model,
1221 api_key,
1222 subagent_timeout
1223 )
1224 }
1225
1226 fn load_capability_config(&self, capability: &str) -> Result<(String, String)> {
1228 let _ = self; let content = match capability {
1231 "commit" => CAPABILITY_COMMIT,
1232 "pr" => CAPABILITY_PR,
1233 "review" => CAPABILITY_REVIEW,
1234 "changelog" => CAPABILITY_CHANGELOG,
1235 "release_notes" => CAPABILITY_RELEASE_NOTES,
1236 "chat" => CAPABILITY_CHAT,
1237 "semantic_blame" => CAPABILITY_SEMANTIC_BLAME,
1238 _ => {
1239 return Ok((
1241 format!(
1242 "You are helping with a {capability} task. Use the available Git tools to assist the user."
1243 ),
1244 "PlainText".to_string(),
1245 ));
1246 }
1247 };
1248
1249 let parsed: toml::Value = toml::from_str(content)?;
1251
1252 let task_prompt = parsed
1253 .get("task_prompt")
1254 .and_then(|v| v.as_str())
1255 .ok_or_else(|| anyhow::anyhow!("No task_prompt found in capability file"))?;
1256
1257 let output_type = parsed
1258 .get("output_type")
1259 .and_then(|v| v.as_str())
1260 .unwrap_or("PlainText")
1261 .to_string();
1262
1263 Ok((task_prompt.to_string(), output_type))
1264 }
1265
1266 #[must_use]
1268 pub fn current_capability(&self) -> Option<&str> {
1269 self.current_capability.as_deref()
1270 }
1271
1272 pub async fn chat(&self, message: &str) -> Result<String> {
1278 let agent = self.build_agent()?;
1279 let response = agent.prompt(message).await?;
1280 Ok(response)
1281 }
1282
1283 pub fn set_capability(&mut self, capability: &str) {
1285 self.current_capability = Some(capability.to_string());
1286 }
1287
1288 #[must_use]
1290 pub fn provider_config(&self) -> &HashMap<String, String> {
1291 &self.provider_config
1292 }
1293
1294 pub fn set_provider_config(&mut self, config: HashMap<String, String>) {
1296 self.provider_config = config;
1297 }
1298
1299 pub fn set_preamble(&mut self, preamble: String) {
1301 self.preamble = Some(preamble);
1302 }
1303
1304 pub fn set_config(&mut self, config: crate::config::Config) {
1306 self.config = Some(config);
1307 }
1308
1309 pub fn set_fast_model(&mut self, fast_model: String) {
1311 self.fast_model = Some(fast_model);
1312 }
1313}
1314
1315pub struct IrisAgentBuilder {
1317 provider: String,
1318 model: String,
1319 preamble: Option<String>,
1320}
1321
1322impl IrisAgentBuilder {
1323 #[must_use]
1325 pub fn new() -> Self {
1326 Self {
1327 provider: "openai".to_string(),
1328 model: "gpt-5.4".to_string(),
1329 preamble: None,
1330 }
1331 }
1332
1333 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
1335 self.provider = provider.into();
1336 self
1337 }
1338
1339 pub fn with_model(mut self, model: impl Into<String>) -> Self {
1341 self.model = model.into();
1342 self
1343 }
1344
1345 pub fn with_preamble(mut self, preamble: impl Into<String>) -> Self {
1347 self.preamble = Some(preamble.into());
1348 self
1349 }
1350
1351 pub fn build(self) -> Result<IrisAgent> {
1357 let mut agent = IrisAgent::new(&self.provider, &self.model)?;
1358
1359 if let Some(preamble) = self.preamble {
1361 agent.set_preamble(preamble);
1362 }
1363
1364 Ok(agent)
1365 }
1366}
1367
1368impl Default for IrisAgentBuilder {
1369 fn default() -> Self {
1370 Self::new()
1371 }
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376 use super::{
1377 IrisAgent, extract_json_from_response, find_balanced_braces, sanitize_json_response,
1378 streaming_response_instructions,
1379 };
1380 use serde_json::Value;
1381 use std::borrow::Cow;
1382
1383 #[test]
1384 fn sanitize_json_response_is_noop_for_valid_payloads() {
1385 let raw = r#"{"title":"Test","description":"All good"}"#;
1386 let sanitized = sanitize_json_response(raw);
1387 assert!(matches!(sanitized, Cow::Borrowed(_)));
1388 serde_json::from_str::<Value>(sanitized.as_ref()).expect("valid JSON");
1389 }
1390
1391 #[test]
1392 fn sanitize_json_response_escapes_literal_newlines() {
1393 let raw = "{\"description\": \"Line1
1394Line2\"}";
1395 let sanitized = sanitize_json_response(raw);
1396 assert_eq!(sanitized.as_ref(), "{\"description\": \"Line1\\nLine2\"}");
1397 serde_json::from_str::<Value>(sanitized.as_ref()).expect("json sanitized");
1398 }
1399
1400 #[test]
1401 fn chat_streaming_instructions_avoid_markdown_suffix() {
1402 let instructions = streaming_response_instructions("chat");
1403 assert!(instructions.contains("plain text"));
1404 assert!(instructions.contains("do not repeat full content"));
1405 assert!(!instructions.contains("markdown format"));
1406 }
1407
1408 #[test]
1409 fn structured_streaming_instructions_still_use_markdown_suffix() {
1410 let instructions = streaming_response_instructions("review");
1411 assert!(instructions.contains("markdown format"));
1412 assert!(instructions.contains("well-structured"));
1413 }
1414
1415 #[test]
1416 fn find_balanced_braces_returns_first_balanced_pair() {
1417 let (start, end) = find_balanced_braces("prefix {\"a\":1} suffix").expect("balanced pair");
1418 assert_eq!(&"prefix {\"a\":1} suffix"[start..end], "{\"a\":1}");
1419 }
1420
1421 #[test]
1422 fn find_balanced_braces_returns_none_for_unbalanced() {
1423 assert_eq!(find_balanced_braces("no braces here"), None);
1424 assert_eq!(find_balanced_braces("{ unclosed"), None);
1425 }
1426
1427 #[test]
1428 fn extract_json_skips_github_actions_expression_false_positive() {
1429 let response = r#"Looking at the diff, I see the new value `${{ github.ref_name }}` replacing the old bash expansion. Here's the commit:
1434
1435{"emoji": "🔧", "title": "Upgrade AUR deploy action", "message": "Bump to v4.1.2 to fix bash --command error."}
1436"#;
1437 let extracted = extract_json_from_response(response).expect("should recover real JSON");
1438 let parsed: Value = serde_json::from_str(&extracted).expect("extracted value is JSON");
1439 assert_eq!(parsed["emoji"], "🔧");
1440 assert_eq!(parsed["title"], "Upgrade AUR deploy action");
1441 }
1442
1443 #[test]
1444 fn extract_json_from_pure_json_response() {
1445 let response = r##"{"content": "# Heading\n\nBody text."}"##;
1446 let extracted = extract_json_from_response(response).expect("pure JSON passes through");
1447 assert_eq!(extracted, response);
1448 }
1449
1450 #[test]
1451 fn extract_json_errors_when_no_candidate_parses() {
1452 let response = "prose ${{ template }} more prose";
1455 let err = extract_json_from_response(response).expect_err("should fail");
1456 let msg = err.to_string();
1457 assert!(
1458 msg.contains("Preview:"),
1459 "error should include a preview: {msg}"
1460 );
1461 }
1462
1463 #[test]
1464 fn pr_review_emoji_styling_uses_a_compact_gitmoji_guide() {
1465 let mut prompt = String::new();
1466 IrisAgent::inject_pr_review_emoji_styling(&mut prompt);
1467
1468 assert!(prompt.contains("Common gitmoji choices:"));
1469 assert!(prompt.contains("`:feat:`"));
1470 assert!(prompt.contains("`:fix:`"));
1471 assert!(!prompt.contains("`:accessibility:`"));
1472 assert!(!prompt.contains("`:analytics:`"));
1473 }
1474}