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 extract_json_from_response(response: &str) -> Result<String> {
190 use crate::agents::debug;
191
192 debug::debug_section("JSON Extraction");
193
194 let trimmed_response = response.trim();
195
196 if trimmed_response.starts_with('{')
198 && serde_json::from_str::<serde_json::Value>(trimmed_response).is_ok()
199 {
200 debug::debug_context_management(
201 "Response is pure JSON",
202 &format!("{} characters", trimmed_response.len()),
203 );
204 return Ok(trimmed_response.to_string());
205 }
206
207 if let Some(start) = response.find("```json") {
209 let content_start = start + "```json".len();
210 let json_end = if let Some(end) = response[content_start..].find("\n```") {
213 end
215 } else {
216 response[content_start..]
218 .find("```")
219 .unwrap_or(response.len() - content_start)
220 };
221
222 let json_content = &response[content_start..content_start + json_end];
223 let trimmed = json_content.trim().to_string();
224
225 debug::debug_context_management(
226 "Found JSON in markdown code block",
227 &format!("{} characters", trimmed.len()),
228 );
229
230 if let Err(e) = debug::write_debug_artifact("iris_extracted.json", &trimmed) {
232 debug::debug_warning(&format!("Failed to write extracted JSON: {}", e));
233 }
234
235 debug::debug_json_parse_attempt(&trimmed);
236 return Ok(trimmed);
237 }
238
239 let mut brace_count = 0;
241 let mut json_start = None;
242 let mut json_end = None;
243
244 for (i, ch) in response.char_indices() {
245 match ch {
246 '{' => {
247 if brace_count == 0 {
248 json_start = Some(i);
249 }
250 brace_count += 1;
251 }
252 '}' => {
253 brace_count -= 1;
254 if brace_count == 0 && json_start.is_some() {
255 json_end = Some(i + 1);
256 break;
257 }
258 }
259 _ => {}
260 }
261 }
262
263 if let (Some(start), Some(end)) = (json_start, json_end) {
264 let json_content = &response[start..end];
265 debug::debug_json_parse_attempt(json_content);
266
267 let sanitized = sanitize_json_response(json_content);
269
270 let _: serde_json::Value = serde_json::from_str(&sanitized).map_err(|e| {
272 debug::debug_json_parse_error(&format!(
273 "Found JSON-like content but it's not valid JSON: {}",
274 e
275 ));
276 let preview = if json_content.len() > 200 {
278 format!("{}...", &json_content[..200])
279 } else {
280 json_content.to_string()
281 };
282 anyhow::anyhow!(
283 "Found JSON-like content but it's not valid JSON: {}\nPreview: {}",
284 e,
285 preview
286 )
287 })?;
288
289 debug::debug_context_management(
290 "Found valid JSON object",
291 &format!("{} characters", json_content.len()),
292 );
293 return Ok(sanitized.into_owned());
294 }
295
296 let trimmed = response.trim();
299 if trimmed.starts_with('#') || trimmed.starts_with("##") {
300 debug::debug_context_management(
301 "Detected raw markdown response",
302 "Wrapping in JSON structure",
303 );
304 let escaped_content = serde_json::to_string(trimmed)?;
306 let wrapped = format!(r#"{{"content": {}}}"#, escaped_content);
308 debug::debug_json_parse_attempt(&wrapped);
309 return Ok(wrapped);
310 }
311
312 debug::debug_json_parse_error("No valid JSON found in response");
314 Err(anyhow::anyhow!("No valid JSON found in response"))
315}
316
317fn sanitize_json_response(raw: &str) -> Cow<'_, str> {
322 let mut needs_sanitization = false;
323 let mut in_string = false;
324 let mut escaped = false;
325
326 for ch in raw.chars() {
327 if in_string {
328 if escaped {
329 escaped = false;
330 continue;
331 }
332
333 match ch {
334 '\\' => escaped = true,
335 '"' => in_string = false,
336 '\n' | '\r' | '\t' => {
337 needs_sanitization = true;
338 break;
339 }
340 c if c.is_control() => {
341 needs_sanitization = true;
342 break;
343 }
344 _ => {}
345 }
346 } else if ch == '"' {
347 in_string = true;
348 }
349 }
350
351 if !needs_sanitization {
352 return Cow::Borrowed(raw);
353 }
354
355 let mut sanitized = String::with_capacity(raw.len());
356 in_string = false;
357 escaped = false;
358
359 for ch in raw.chars() {
360 if in_string {
361 if escaped {
362 sanitized.push(ch);
363 escaped = false;
364 continue;
365 }
366
367 match ch {
368 '\\' => {
369 sanitized.push('\\');
370 escaped = true;
371 }
372 '"' => {
373 sanitized.push('"');
374 in_string = false;
375 }
376 '\n' => sanitized.push_str("\\n"),
377 '\r' => sanitized.push_str("\\r"),
378 '\t' => sanitized.push_str("\\t"),
379 c if c.is_control() => {
380 use std::fmt::Write as _;
381 let _ = write!(&mut sanitized, "\\u{:04X}", u32::from(c));
382 }
383 _ => sanitized.push(ch),
384 }
385 } else {
386 sanitized.push(ch);
387 if ch == '"' {
388 in_string = true;
389 escaped = false;
390 }
391 }
392 }
393
394 Cow::Owned(sanitized)
395}
396
397fn parse_with_recovery<T>(json_str: &str) -> Result<T>
404where
405 T: JsonSchema + DeserializeOwned,
406{
407 use crate::agents::debug as agent_debug;
408 use crate::agents::output_validator::validate_and_parse;
409
410 let validation_result = validate_and_parse::<T>(json_str)?;
411
412 if validation_result.recovered {
414 agent_debug::debug_context_management(
415 "JSON recovery applied",
416 &format!("{} issues fixed", validation_result.warnings.len()),
417 );
418 for warning in &validation_result.warnings {
419 agent_debug::debug_warning(warning);
420 }
421 }
422
423 validation_result
424 .value
425 .ok_or_else(|| anyhow::anyhow!("Failed to parse JSON even after recovery"))
426}
427
428pub struct IrisAgent {
434 provider: String,
435 model: String,
436 fast_model: Option<String>,
438 current_capability: Option<String>,
440 provider_config: HashMap<String, String>,
442 preamble: Option<String>,
444 config: Option<crate::config::Config>,
446 content_update_sender: Option<crate::agents::tools::ContentUpdateSender>,
448 workspace: Workspace,
450}
451
452impl IrisAgent {
453 pub fn new(provider: &str, model: &str) -> Result<Self> {
459 Ok(Self {
460 provider: provider.to_string(),
461 model: model.to_string(),
462 fast_model: None,
463 current_capability: None,
464 provider_config: HashMap::new(),
465 preamble: None,
466 config: None,
467 content_update_sender: None,
468 workspace: Workspace::new(),
469 })
470 }
471
472 pub fn set_content_update_sender(&mut self, sender: crate::agents::tools::ContentUpdateSender) {
477 self.content_update_sender = Some(sender);
478 }
479
480 fn effective_fast_model(&self) -> &str {
482 self.fast_model.as_deref().unwrap_or(&self.model)
483 }
484
485 fn get_api_key(&self) -> Option<&str> {
487 provider::current_provider_config(self.config.as_ref(), &self.provider)
488 .and_then(crate::providers::ProviderConfig::api_key_if_set)
489 }
490
491 fn current_provider(&self) -> Result<crate::providers::Provider> {
492 provider::provider_from_name(&self.provider)
493 }
494
495 fn current_provider_additional_params(&self) -> Option<&HashMap<String, String>> {
496 provider::current_provider_config(self.config.as_ref(), &self.provider)
497 .map(|provider_config| &provider_config.additional_params)
498 }
499
500 fn build_agent(&self) -> Result<DynAgent> {
506 use crate::agents::debug_tool::DebugTool;
507
508 let preamble = self.preamble.as_deref().unwrap_or(DEFAULT_PREAMBLE);
509 let fast_model = self.effective_fast_model();
510 let api_key = self.get_api_key();
511 let subagent_timeout = self
512 .config
513 .as_ref()
514 .map_or(120, |c| c.subagent_timeout_secs);
515
516 macro_rules! build_subagent {
518 ($builder:expr) => {{
519 let builder = $builder
520 .name("analyze_subagent")
521 .description("Delegate focused analysis tasks to a sub-agent with its own context window. Use for analyzing specific files, commits, or code sections independently. The sub-agent has access to Git tools (diff, log, status) and file analysis tools.")
522 .preamble("You are a specialized analysis sub-agent for Iris. Your job is to complete focused analysis tasks and return concise, actionable summaries.
523
524Guidelines:
525- Use the available tools to gather information
526- Focus only on what's asked - don't expand scope
527- Return a clear, structured summary of findings
528- Highlight important issues, patterns, or insights
529- Keep your response focused and concise")
530 ;
531 let builder = self.apply_completion_params(
532 builder,
533 fast_model,
534 4096,
535 CompletionProfile::Subagent,
536 )?;
537 crate::attach_core_tools!(builder).build()
538 }};
539 }
540
541 macro_rules! attach_main_tools {
543 ($builder:expr) => {{
544 crate::attach_core_tools!($builder)
545 .tool(DebugTool::new(GitRepoInfo))
546 .tool(DebugTool::new(self.workspace.clone()))
547 .tool(DebugTool::new(ParallelAnalyze::with_timeout(
548 &self.provider,
549 fast_model,
550 subagent_timeout,
551 api_key,
552 self.current_provider_additional_params().cloned(),
553 )?))
554 }};
555 }
556
557 macro_rules! maybe_attach_update_tools {
559 ($builder:expr) => {{
560 if let Some(sender) = &self.content_update_sender {
561 use crate::agents::tools::{UpdateCommitTool, UpdatePRTool, UpdateReviewTool};
562 $builder
563 .tool(DebugTool::new(UpdateCommitTool::new(sender.clone())))
564 .tool(DebugTool::new(UpdatePRTool::new(sender.clone())))
565 .tool(DebugTool::new(UpdateReviewTool::new(sender.clone())))
566 .build()
567 } else {
568 $builder.build()
569 }
570 }};
571 }
572
573 match self.provider.as_str() {
574 "openai" => {
575 let sub_agent = build_subagent!(provider::openai_builder(fast_model, api_key)?);
577
578 let builder = provider::openai_builder(&self.model, api_key)?.preamble(preamble);
580 let builder = self.apply_completion_params(
581 builder,
582 &self.model,
583 16384,
584 CompletionProfile::MainAgent,
585 )?;
586 let builder = attach_main_tools!(builder).tool(sub_agent);
587 let agent = maybe_attach_update_tools!(builder);
588 Ok(DynAgent::OpenAI(agent))
589 }
590 "anthropic" => {
591 let sub_agent = build_subagent!(provider::anthropic_builder(fast_model, api_key)?);
593
594 let builder = provider::anthropic_builder(&self.model, api_key)?.preamble(preamble);
596 let builder = self.apply_completion_params(
597 builder,
598 &self.model,
599 16384,
600 CompletionProfile::MainAgent,
601 )?;
602 let builder = attach_main_tools!(builder).tool(sub_agent);
603 let agent = maybe_attach_update_tools!(builder);
604 Ok(DynAgent::Anthropic(agent))
605 }
606 "google" | "gemini" => {
607 let sub_agent = build_subagent!(provider::gemini_builder(fast_model, api_key)?);
609
610 let builder = provider::gemini_builder(&self.model, api_key)?.preamble(preamble);
612 let builder = self.apply_completion_params(
613 builder,
614 &self.model,
615 16384,
616 CompletionProfile::MainAgent,
617 )?;
618 let builder = attach_main_tools!(builder).tool(sub_agent);
619 let agent = maybe_attach_update_tools!(builder);
620 Ok(DynAgent::Gemini(agent))
621 }
622 _ => Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
623 }
624 }
625
626 fn apply_completion_params<M>(
627 &self,
628 builder: AgentBuilder<M>,
629 model: &str,
630 max_tokens: u64,
631 profile: CompletionProfile,
632 ) -> Result<AgentBuilder<M>>
633 where
634 M: CompletionModel,
635 {
636 let provider = self.current_provider()?;
637 Ok(provider::apply_completion_params(
638 builder,
639 provider,
640 model,
641 max_tokens,
642 self.current_provider_additional_params(),
643 profile,
644 ))
645 }
646
647 async fn execute_with_agent<T>(&self, system_prompt: &str, user_prompt: &str) -> Result<T>
650 where
651 T: JsonSchema + for<'a> serde::Deserialize<'a> + serde::Serialize + Send + Sync + 'static,
652 {
653 use crate::agents::debug;
654 use crate::agents::status::IrisPhase;
655 use crate::messages::get_capability_message;
656 use schemars::schema_for;
657
658 let capability = self.current_capability().unwrap_or("commit");
659
660 debug::debug_phase_change(&format!("AGENT EXECUTION: {}", std::any::type_name::<T>()));
661
662 let msg = get_capability_message(capability);
664 crate::iris_status_dynamic!(IrisPhase::Planning, msg.text, 2, 4);
665
666 let agent = self.build_agent()?;
668 debug::debug_context_management(
669 "Agent built with tools",
670 &format!(
671 "Provider: {}, Model: {} (fast: {})",
672 self.provider,
673 self.model,
674 self.effective_fast_model()
675 ),
676 );
677
678 let schema = schema_for!(T);
680 let schema_json = serde_json::to_string_pretty(&schema)?;
681 debug::debug_context_management(
682 "JSON schema created",
683 &format!("Type: {}", std::any::type_name::<T>()),
684 );
685
686 let full_prompt = format!(
688 "{system_prompt}\n\n{user_prompt}\n\n\
689 === CRITICAL: RESPONSE FORMAT ===\n\
690 After using the available tools to gather necessary information, you MUST respond with ONLY a valid JSON object.\n\n\
691 REQUIRED JSON SCHEMA:\n\
692 {schema_json}\n\n\
693 CRITICAL INSTRUCTIONS:\n\
694 - Return ONLY the raw JSON object - nothing else\n\
695 - NO explanations before the JSON\n\
696 - NO explanations after the JSON\n\
697 - NO markdown code blocks (just raw JSON)\n\
698 - NO preamble text like 'Here is the JSON:' or 'Let me generate:'\n\
699 - Start your response with {{ and end with }}\n\
700 - The JSON must be complete and valid\n\n\
701 Your entire response should be ONLY the JSON object."
702 );
703
704 debug::debug_llm_request(&full_prompt, Some(16384));
705
706 let gen_msg = get_capability_message(capability);
708 crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
709
710 let timer = debug::DebugTimer::start("Agent prompt execution");
715
716 debug::debug_context_management(
717 "LLM request",
718 "Sending prompt to agent with multi_turn(50)",
719 );
720 let prompt_response: PromptResponse = agent.prompt_extended(&full_prompt, 50).await?;
721
722 timer.finish();
723
724 let usage = &prompt_response.usage;
726 debug::debug_context_management(
727 "Token usage",
728 &format!(
729 "input: {} | output: {} | total: {}",
730 usage.input_tokens, usage.output_tokens, usage.total_tokens
731 ),
732 );
733
734 let response = &prompt_response.output;
735 #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
736 let total_tokens_usize = usage.total_tokens as usize;
737 debug::debug_llm_response(
738 response,
739 std::time::Duration::from_secs(0),
740 Some(total_tokens_usize),
741 );
742
743 crate::iris_status_dynamic!(
745 IrisPhase::Synthesis,
746 "β¨ Iris is synthesizing results...",
747 4,
748 4
749 );
750
751 let json_str = extract_json_from_response(response)?;
753 let sanitized_json = sanitize_json_response(&json_str);
754 let sanitized_ref = sanitized_json.as_ref();
755
756 if matches!(sanitized_json, Cow::Borrowed(_)) {
757 debug::debug_json_parse_attempt(sanitized_ref);
758 } else {
759 debug::debug_context_management(
760 "Sanitized JSON response",
761 &format!("{} β {} characters", json_str.len(), sanitized_ref.len()),
762 );
763 debug::debug_json_parse_attempt(sanitized_ref);
764 }
765
766 let result: T = parse_with_recovery(sanitized_ref)?;
768
769 debug::debug_json_parse_success(std::any::type_name::<T>());
770
771 crate::iris_status_completed!();
773
774 Ok(result)
775 }
776
777 fn inject_style_instructions(&self, system_prompt: &mut String, capability: &str) {
783 let Some(config) = &self.config else {
784 return;
785 };
786
787 let preset_name = config.get_effective_preset_name();
788 let is_conventional = preset_name == "conventional";
789 let is_default_mode = preset_name == "default" || preset_name.is_empty();
790
791 let use_style_detection =
793 capability == "commit" && is_default_mode && config.gitmoji_override.is_none();
794
795 let commit_emoji = config.use_gitmoji && !is_conventional && !use_style_detection;
797
798 let output_emoji = config.gitmoji_override.unwrap_or(config.use_gitmoji);
801
802 if !preset_name.is_empty() && !is_default_mode {
804 let library = crate::instruction_presets::get_instruction_preset_library();
805 if let Some(preset) = library.get_preset(preset_name) {
806 tracing::info!("π Injecting '{}' preset style instructions", preset_name);
807 system_prompt.push_str("\n\n=== STYLE INSTRUCTIONS ===\n");
808 system_prompt.push_str(&preset.instructions);
809 system_prompt.push('\n');
810 } else {
811 tracing::warn!("β οΈ Preset '{}' not found in library", preset_name);
812 }
813 }
814
815 if capability == "commit" {
819 if commit_emoji {
820 system_prompt.push_str("\n\n=== GITMOJI INSTRUCTIONS ===\n");
821 system_prompt.push_str("Set the 'emoji' field to a single relevant gitmoji. ");
822 system_prompt.push_str(
823 "DO NOT include the emoji in the 'message' or 'title' text - only set the 'emoji' field. ",
824 );
825 system_prompt.push_str("Choose the closest match from this compact guide:\n\n");
826 system_prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
827 system_prompt.push_str("\n\nThe emoji should match the primary type of change.");
828 } else if is_conventional {
829 system_prompt.push_str("\n\n=== CONVENTIONAL COMMITS FORMAT ===\n");
830 system_prompt.push_str("IMPORTANT: This uses Conventional Commits format. ");
831 system_prompt
832 .push_str("DO NOT include any emojis in the commit message or PR title. ");
833 system_prompt.push_str("The 'emoji' field should be null.");
834 }
835 }
836
837 if capability == "pr" || capability == "review" {
839 if output_emoji {
840 Self::inject_pr_review_emoji_styling(system_prompt);
841 } else {
842 Self::inject_no_emoji_styling(system_prompt);
843 }
844 }
845
846 if capability == "release_notes" && output_emoji {
847 Self::inject_release_notes_emoji_styling(system_prompt);
848 } else if capability == "release_notes" {
849 Self::inject_no_emoji_styling(system_prompt);
850 }
851
852 if capability == "changelog" && output_emoji {
853 Self::inject_changelog_emoji_styling(system_prompt);
854 } else if capability == "changelog" {
855 Self::inject_no_emoji_styling(system_prompt);
856 }
857 }
858
859 fn inject_pr_review_emoji_styling(prompt: &mut String) {
860 prompt.push_str("\n\n=== EMOJI STYLING ===\n");
861 prompt.push_str("Use emojis to make the output visually scannable and engaging:\n");
862 prompt.push_str("- H1 title: ONE gitmoji at the start (β¨, π, β»οΈ, etc.)\n");
863 prompt.push_str("- Section headers: Add relevant emojis (π― What's New, βοΈ How It Works, π Commits, β οΈ Breaking Changes)\n");
864 prompt.push_str("- Commit list entries: Include gitmoji where appropriate\n");
865 prompt.push_str("- Body text: Keep clean - no scattered emojis within prose\n\n");
866 prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
867 }
868
869 fn inject_release_notes_emoji_styling(prompt: &mut String) {
870 prompt.push_str("\n\n=== EMOJI STYLING ===\n");
871 prompt.push_str("Use at most one emoji per highlight/section title. No emojis in bullet descriptions, upgrade notes, or metrics. ");
872 prompt.push_str("Pick from the approved gitmoji list (e.g., π Highlights, π€ Agents, π§ Tooling, π Fixes, β‘ Performance). ");
873 prompt.push_str("Never sprinkle emojis within sentences or JSON keys.\n\n");
874 prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
875 }
876
877 fn inject_changelog_emoji_styling(prompt: &mut String) {
878 prompt.push_str("\n\n=== EMOJI STYLING ===\n");
879 prompt.push_str("Section keys must remain plain text (Added/Changed/Deprecated/Removed/Fixed/Security). ");
880 prompt.push_str(
881 "You may include one emoji within a change description to reinforce meaning. ",
882 );
883 prompt.push_str(
884 "Never add emojis to JSON keys, section names, metrics, or upgrade notes.\n\n",
885 );
886 prompt.push_str(&crate::gitmoji::get_gitmoji_prompt_guide());
887 }
888
889 fn inject_no_emoji_styling(prompt: &mut String) {
890 prompt.push_str("\n\n=== NO EMOJI STYLING ===\n");
891 prompt.push_str(
892 "DO NOT include any emojis anywhere in the output. Keep all content plain text.",
893 );
894 }
895
896 pub async fn execute_task(
904 &mut self,
905 capability: &str,
906 user_prompt: &str,
907 ) -> Result<StructuredResponse> {
908 use crate::agents::status::IrisPhase;
909 use crate::messages::get_capability_message;
910
911 let waiting_msg = get_capability_message(capability);
913 crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
914
915 let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
917
918 self.inject_style_instructions(&mut system_prompt, capability);
920
921 self.current_capability = Some(capability.to_string());
923
924 crate::iris_status_dynamic!(
926 IrisPhase::Analysis,
927 "π Iris is analyzing your changes...",
928 2,
929 4
930 );
931
932 match output_type.as_str() {
935 "GeneratedMessage" => {
936 let response = self
937 .execute_with_agent::<crate::types::GeneratedMessage>(
938 &system_prompt,
939 user_prompt,
940 )
941 .await?;
942 Ok(StructuredResponse::CommitMessage(response))
943 }
944 "MarkdownPullRequest" => {
945 let response = self
946 .execute_with_agent::<crate::types::MarkdownPullRequest>(
947 &system_prompt,
948 user_prompt,
949 )
950 .await?;
951 Ok(StructuredResponse::PullRequest(response))
952 }
953 "MarkdownChangelog" => {
954 let response = self
955 .execute_with_agent::<crate::types::MarkdownChangelog>(
956 &system_prompt,
957 user_prompt,
958 )
959 .await?;
960 Ok(StructuredResponse::Changelog(response))
961 }
962 "MarkdownReleaseNotes" => {
963 let response = self
964 .execute_with_agent::<crate::types::MarkdownReleaseNotes>(
965 &system_prompt,
966 user_prompt,
967 )
968 .await?;
969 Ok(StructuredResponse::ReleaseNotes(response))
970 }
971 "MarkdownReview" => {
972 let response = self
973 .execute_with_agent::<crate::types::MarkdownReview>(&system_prompt, user_prompt)
974 .await?;
975 Ok(StructuredResponse::MarkdownReview(response))
976 }
977 "SemanticBlame" => {
978 let agent = self.build_agent()?;
980 let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
981 let response = agent.prompt_multi_turn(&full_prompt, 10).await?;
982 Ok(StructuredResponse::SemanticBlame(response))
983 }
984 _ => {
985 let agent = self.build_agent()?;
987 let full_prompt = format!("{system_prompt}\n\n{user_prompt}");
988 let response = agent.prompt_multi_turn(&full_prompt, 50).await?;
990 Ok(StructuredResponse::PlainText(response))
991 }
992 }
993 }
994
995 pub async fn execute_task_streaming<F>(
1006 &mut self,
1007 capability: &str,
1008 user_prompt: &str,
1009 mut on_chunk: F,
1010 ) -> Result<StructuredResponse>
1011 where
1012 F: FnMut(&str, &str) + Send,
1013 {
1014 use crate::agents::status::IrisPhase;
1015 use crate::messages::get_capability_message;
1016 use futures::StreamExt;
1017 use rig::agent::MultiTurnStreamItem;
1018 use rig::streaming::{StreamedAssistantContent, StreamingPrompt};
1019
1020 let waiting_msg = get_capability_message(capability);
1022 crate::iris_status_dynamic!(IrisPhase::Initializing, waiting_msg.text, 1, 4);
1023
1024 let (mut system_prompt, output_type) = self.load_capability_config(capability)?;
1026
1027 self.inject_style_instructions(&mut system_prompt, capability);
1029
1030 self.current_capability = Some(capability.to_string());
1032
1033 crate::iris_status_dynamic!(
1035 IrisPhase::Analysis,
1036 "π Iris is analyzing your changes...",
1037 2,
1038 4
1039 );
1040
1041 let full_prompt = format!(
1043 "{}\n\n{}\n\n{}",
1044 system_prompt,
1045 user_prompt,
1046 streaming_response_instructions(capability)
1047 );
1048
1049 let gen_msg = get_capability_message(capability);
1051 crate::iris_status_dynamic!(IrisPhase::Generation, gen_msg.text, 3, 4);
1052
1053 macro_rules! consume_stream {
1055 ($stream:expr) => {{
1056 let mut aggregated_text = String::new();
1057 let mut stream = $stream;
1058 while let Some(item) = stream.next().await {
1059 match item {
1060 Ok(MultiTurnStreamItem::StreamAssistantItem(
1061 StreamedAssistantContent::Text(text),
1062 )) => {
1063 aggregated_text.push_str(&text.text);
1064 on_chunk(&text.text, &aggregated_text);
1065 }
1066 Ok(MultiTurnStreamItem::StreamAssistantItem(
1067 StreamedAssistantContent::ToolCall { tool_call, .. },
1068 )) => {
1069 let tool_name = &tool_call.function.name;
1070 let reason = format!("Calling {}", tool_name);
1071 crate::iris_status_dynamic!(
1072 IrisPhase::ToolExecution {
1073 tool_name: tool_name.clone(),
1074 reason: reason.clone()
1075 },
1076 format!("π§ {}", reason),
1077 3,
1078 4
1079 );
1080 }
1081 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
1082 Err(e) => return Err(anyhow::anyhow!("Streaming error: {}", e)),
1083 _ => {}
1084 }
1085 }
1086 aggregated_text
1087 }};
1088 }
1089
1090 let aggregated_text = match self.provider.as_str() {
1092 "openai" => {
1093 let agent = self.build_openai_agent_for_streaming(&full_prompt)?;
1094 let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1095 consume_stream!(stream)
1096 }
1097 "anthropic" => {
1098 let agent = self.build_anthropic_agent_for_streaming(&full_prompt)?;
1099 let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1100 consume_stream!(stream)
1101 }
1102 "google" | "gemini" => {
1103 let agent = self.build_gemini_agent_for_streaming(&full_prompt)?;
1104 let stream = agent.stream_prompt(&full_prompt).multi_turn(50).await;
1105 consume_stream!(stream)
1106 }
1107 _ => return Err(anyhow::anyhow!("Unsupported provider: {}", self.provider)),
1108 };
1109
1110 crate::iris_status_dynamic!(
1112 IrisPhase::Synthesis,
1113 "β¨ Iris is synthesizing results...",
1114 4,
1115 4
1116 );
1117
1118 let response = Self::text_to_structured_response(&output_type, aggregated_text);
1119 crate::iris_status_completed!();
1120 Ok(response)
1121 }
1122
1123 fn text_to_structured_response(output_type: &str, text: String) -> StructuredResponse {
1125 match output_type {
1126 "MarkdownReview" => {
1127 StructuredResponse::MarkdownReview(crate::types::MarkdownReview { content: text })
1128 }
1129 "MarkdownPullRequest" => {
1130 StructuredResponse::PullRequest(crate::types::MarkdownPullRequest { content: text })
1131 }
1132 "MarkdownChangelog" => {
1133 StructuredResponse::Changelog(crate::types::MarkdownChangelog { content: text })
1134 }
1135 "MarkdownReleaseNotes" => {
1136 StructuredResponse::ReleaseNotes(crate::types::MarkdownReleaseNotes {
1137 content: text,
1138 })
1139 }
1140 "SemanticBlame" => StructuredResponse::SemanticBlame(text),
1141 _ => StructuredResponse::PlainText(text),
1142 }
1143 }
1144
1145 fn streaming_agent_config(&self) -> (&str, Option<&str>, u64) {
1147 let fast_model = self.effective_fast_model();
1148 let api_key = self.get_api_key();
1149 let subagent_timeout = self
1150 .config
1151 .as_ref()
1152 .map_or(120, |c| c.subagent_timeout_secs);
1153 (fast_model, api_key, subagent_timeout)
1154 }
1155
1156 fn build_openai_agent_for_streaming(
1158 &self,
1159 _prompt: &str,
1160 ) -> Result<rig::agent::Agent<provider::OpenAIModel>> {
1161 let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1162 build_streaming_agent!(
1163 self,
1164 provider::openai_builder,
1165 fast_model,
1166 api_key,
1167 subagent_timeout
1168 )
1169 }
1170
1171 fn build_anthropic_agent_for_streaming(
1173 &self,
1174 _prompt: &str,
1175 ) -> Result<rig::agent::Agent<provider::AnthropicModel>> {
1176 let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1177 build_streaming_agent!(
1178 self,
1179 provider::anthropic_builder,
1180 fast_model,
1181 api_key,
1182 subagent_timeout
1183 )
1184 }
1185
1186 fn build_gemini_agent_for_streaming(
1188 &self,
1189 _prompt: &str,
1190 ) -> Result<rig::agent::Agent<provider::GeminiModel>> {
1191 let (fast_model, api_key, subagent_timeout) = self.streaming_agent_config();
1192 build_streaming_agent!(
1193 self,
1194 provider::gemini_builder,
1195 fast_model,
1196 api_key,
1197 subagent_timeout
1198 )
1199 }
1200
1201 fn load_capability_config(&self, capability: &str) -> Result<(String, String)> {
1203 let _ = self; let content = match capability {
1206 "commit" => CAPABILITY_COMMIT,
1207 "pr" => CAPABILITY_PR,
1208 "review" => CAPABILITY_REVIEW,
1209 "changelog" => CAPABILITY_CHANGELOG,
1210 "release_notes" => CAPABILITY_RELEASE_NOTES,
1211 "chat" => CAPABILITY_CHAT,
1212 "semantic_blame" => CAPABILITY_SEMANTIC_BLAME,
1213 _ => {
1214 return Ok((
1216 format!(
1217 "You are helping with a {capability} task. Use the available Git tools to assist the user."
1218 ),
1219 "PlainText".to_string(),
1220 ));
1221 }
1222 };
1223
1224 let parsed: toml::Value = toml::from_str(content)?;
1226
1227 let task_prompt = parsed
1228 .get("task_prompt")
1229 .and_then(|v| v.as_str())
1230 .ok_or_else(|| anyhow::anyhow!("No task_prompt found in capability file"))?;
1231
1232 let output_type = parsed
1233 .get("output_type")
1234 .and_then(|v| v.as_str())
1235 .unwrap_or("PlainText")
1236 .to_string();
1237
1238 Ok((task_prompt.to_string(), output_type))
1239 }
1240
1241 #[must_use]
1243 pub fn current_capability(&self) -> Option<&str> {
1244 self.current_capability.as_deref()
1245 }
1246
1247 pub async fn chat(&self, message: &str) -> Result<String> {
1253 let agent = self.build_agent()?;
1254 let response = agent.prompt(message).await?;
1255 Ok(response)
1256 }
1257
1258 pub fn set_capability(&mut self, capability: &str) {
1260 self.current_capability = Some(capability.to_string());
1261 }
1262
1263 #[must_use]
1265 pub fn provider_config(&self) -> &HashMap<String, String> {
1266 &self.provider_config
1267 }
1268
1269 pub fn set_provider_config(&mut self, config: HashMap<String, String>) {
1271 self.provider_config = config;
1272 }
1273
1274 pub fn set_preamble(&mut self, preamble: String) {
1276 self.preamble = Some(preamble);
1277 }
1278
1279 pub fn set_config(&mut self, config: crate::config::Config) {
1281 self.config = Some(config);
1282 }
1283
1284 pub fn set_fast_model(&mut self, fast_model: String) {
1286 self.fast_model = Some(fast_model);
1287 }
1288}
1289
1290pub struct IrisAgentBuilder {
1292 provider: String,
1293 model: String,
1294 preamble: Option<String>,
1295}
1296
1297impl IrisAgentBuilder {
1298 #[must_use]
1300 pub fn new() -> Self {
1301 Self {
1302 provider: "openai".to_string(),
1303 model: "gpt-5.4".to_string(),
1304 preamble: None,
1305 }
1306 }
1307
1308 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
1310 self.provider = provider.into();
1311 self
1312 }
1313
1314 pub fn with_model(mut self, model: impl Into<String>) -> Self {
1316 self.model = model.into();
1317 self
1318 }
1319
1320 pub fn with_preamble(mut self, preamble: impl Into<String>) -> Self {
1322 self.preamble = Some(preamble.into());
1323 self
1324 }
1325
1326 pub fn build(self) -> Result<IrisAgent> {
1332 let mut agent = IrisAgent::new(&self.provider, &self.model)?;
1333
1334 if let Some(preamble) = self.preamble {
1336 agent.set_preamble(preamble);
1337 }
1338
1339 Ok(agent)
1340 }
1341}
1342
1343impl Default for IrisAgentBuilder {
1344 fn default() -> Self {
1345 Self::new()
1346 }
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::{IrisAgent, sanitize_json_response, streaming_response_instructions};
1352 use serde_json::Value;
1353 use std::borrow::Cow;
1354
1355 #[test]
1356 fn sanitize_json_response_is_noop_for_valid_payloads() {
1357 let raw = r#"{"title":"Test","description":"All good"}"#;
1358 let sanitized = sanitize_json_response(raw);
1359 assert!(matches!(sanitized, Cow::Borrowed(_)));
1360 serde_json::from_str::<Value>(sanitized.as_ref()).expect("valid JSON");
1361 }
1362
1363 #[test]
1364 fn sanitize_json_response_escapes_literal_newlines() {
1365 let raw = "{\"description\": \"Line1
1366Line2\"}";
1367 let sanitized = sanitize_json_response(raw);
1368 assert_eq!(sanitized.as_ref(), "{\"description\": \"Line1\\nLine2\"}");
1369 serde_json::from_str::<Value>(sanitized.as_ref()).expect("json sanitized");
1370 }
1371
1372 #[test]
1373 fn chat_streaming_instructions_avoid_markdown_suffix() {
1374 let instructions = streaming_response_instructions("chat");
1375 assert!(instructions.contains("plain text"));
1376 assert!(instructions.contains("do not repeat full content"));
1377 assert!(!instructions.contains("markdown format"));
1378 }
1379
1380 #[test]
1381 fn structured_streaming_instructions_still_use_markdown_suffix() {
1382 let instructions = streaming_response_instructions("review");
1383 assert!(instructions.contains("markdown format"));
1384 assert!(instructions.contains("well-structured"));
1385 }
1386
1387 #[test]
1388 fn pr_review_emoji_styling_uses_a_compact_gitmoji_guide() {
1389 let mut prompt = String::new();
1390 IrisAgent::inject_pr_review_emoji_styling(&mut prompt);
1391
1392 assert!(prompt.contains("Common gitmoji choices:"));
1393 assert!(prompt.contains("`:feat:`"));
1394 assert!(prompt.contains("`:fix:`"));
1395 assert!(!prompt.contains("`:accessibility:`"));
1396 assert!(!prompt.contains("`:analytics:`"));
1397 }
1398}