Skip to main content

chronicle/agent/
mod.rs

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
17/// Run the annotation agent loop. Calls the LLM with tools until it finishes
18/// or hits the turn limit. Returns collected v2 output (narrative, decisions, markers)
19/// plus a summary string.
20pub 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        // Collect any text from the response as potential summary
49        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        // Add the assistant message to history
65        messages.push(Message {
66            role: Role::Assistant,
67            content: response.content.clone(),
68        });
69
70        // If stop reason is EndTurn or MaxTokens with no tool uses, we're done
71        if tool_uses.is_empty() {
72            summary = assistant_text;
73            break;
74        }
75
76        // Process tool uses
77        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        // Add tool results as a user message
99        messages.push(Message {
100            role: Role::User,
101            content: tool_results,
102        });
103
104        // Check stop conditions
105        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    // Narrative is required — if the agent didn't emit one, construct a minimal one
120    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}