1pub mod prompt;
2pub mod tools;
3
4use snafu::ResultExt;
5
6use crate::annotate::gather::AnnotationContext;
7use crate::error::agent_error::{MaxTurnsExceededSnafu, NoAnnotationsSnafu, ProviderSnafu};
8use crate::error::AgentError;
9use crate::git::GitOps;
10use crate::provider::{CompletionRequest, ContentBlock, LlmProvider, Message, Role, StopReason};
11use crate::schema::v2::Narrative;
12
13pub use tools::CollectedOutput;
14
15const MAX_TURNS: u32 = 20;
16
17pub fn run_agent_loop(
21 provider: &dyn LlmProvider,
22 git_ops: &dyn GitOps,
23 context: &AnnotationContext,
24) -> Result<(CollectedOutput, String), AgentError> {
25 let system_prompt = prompt::build_system_prompt(context);
26 let tool_defs = tools::tool_definitions();
27
28 let mut messages = vec![Message {
29 role: Role::User,
30 content: vec![ContentBlock::Text {
31 text: "Please annotate this commit.".to_string(),
32 }],
33 }];
34
35 let mut collected = CollectedOutput::default();
36 let mut summary = String::new();
37
38 for turn in 0..MAX_TURNS {
39 let request = CompletionRequest {
40 system: system_prompt.clone(),
41 messages: messages.clone(),
42 tools: tool_defs.clone(),
43 max_tokens: 4096,
44 };
45
46 let response = provider.complete(&request).context(ProviderSnafu)?;
47
48 let mut assistant_text = String::new();
50 let mut tool_uses: Vec<(String, String, serde_json::Value)> = Vec::new();
51
52 for block in &response.content {
53 match block {
54 ContentBlock::Text { text } => {
55 assistant_text.push_str(text);
56 }
57 ContentBlock::ToolUse { id, name, input } => {
58 tool_uses.push((id.clone(), name.clone(), input.clone()));
59 }
60 _ => {}
61 }
62 }
63
64 messages.push(Message {
66 role: Role::Assistant,
67 content: response.content.clone(),
68 });
69
70 if tool_uses.is_empty() {
72 summary = assistant_text;
73 break;
74 }
75
76 let mut tool_results: Vec<ContentBlock> = Vec::new();
78 for (id, name, input) in &tool_uses {
79 let result = tools::dispatch_tool(name, input, git_ops, context, &mut collected);
80 match result {
81 Ok(content) => {
82 tool_results.push(ContentBlock::ToolResult {
83 tool_use_id: id.clone(),
84 content,
85 is_error: None,
86 });
87 }
88 Err(e) => {
89 tool_results.push(ContentBlock::ToolResult {
90 tool_use_id: id.clone(),
91 content: format!("Error: {e}"),
92 is_error: Some(true),
93 });
94 }
95 }
96 }
97
98 messages.push(Message {
100 role: Role::User,
101 content: tool_results,
102 });
103
104 if response.stop_reason == StopReason::EndTurn {
106 summary = assistant_text;
107 break;
108 }
109 if response.stop_reason == StopReason::MaxTokens {
110 summary = assistant_text;
111 break;
112 }
113
114 if turn + 1 >= MAX_TURNS {
115 return MaxTurnsExceededSnafu { turns: MAX_TURNS }.fail();
116 }
117 }
118
119 if collected.narrative.is_none() {
121 if summary.is_empty() {
122 return NoAnnotationsSnafu.fail();
123 }
124 collected.narrative = Some(Narrative {
125 summary: summary.clone(),
126 motivation: None,
127 rejected_alternatives: Vec::new(),
128 follow_up: None,
129 files_changed: Vec::new(),
130 });
131 }
132
133 if summary.is_empty() {
134 summary = "Annotation complete.".to_string();
135 }
136
137 Ok((collected, summary))
138}