Skip to main content

chronicle/agent/
tools.rs

1use std::path::Path;
2
3use snafu::ResultExt;
4
5use crate::annotate::gather::AnnotationContext;
6use crate::error::agent_error::{GitSnafu, JsonSnafu};
7use crate::error::AgentError;
8use crate::git::{GitOps, HunkLine};
9use crate::provider::ToolDefinition;
10use crate::schema::{CrossCuttingConcern, RegionAnnotation};
11
12/// Return the tool definitions the agent has access to.
13pub fn tool_definitions() -> Vec<ToolDefinition> {
14    vec![
15        ToolDefinition {
16            name: "get_diff".to_string(),
17            description: "Get the full unified diff for this commit.".to_string(),
18            input_schema: serde_json::json!({
19                "type": "object",
20                "properties": {},
21                "required": []
22            }),
23        },
24        ToolDefinition {
25            name: "get_file_content".to_string(),
26            description: "Get the content of a file at this commit.".to_string(),
27            input_schema: serde_json::json!({
28                "type": "object",
29                "properties": {
30                    "path": {
31                        "type": "string",
32                        "description": "Path of the file to read"
33                    }
34                },
35                "required": ["path"]
36            }),
37        },
38        ToolDefinition {
39            name: "get_ast_outline".to_string(),
40            description: "Get a tree-sitter AST outline of semantic units in a file.".to_string(),
41            input_schema: serde_json::json!({
42                "type": "object",
43                "properties": {
44                    "path": {
45                        "type": "string",
46                        "description": "Path of the file to analyze"
47                    }
48                },
49                "required": ["path"]
50            }),
51        },
52        ToolDefinition {
53            name: "get_commit_info".to_string(),
54            description: "Get commit metadata: SHA, message, author, timestamp.".to_string(),
55            input_schema: serde_json::json!({
56                "type": "object",
57                "properties": {},
58                "required": []
59            }),
60        },
61        ToolDefinition {
62            name: "emit_annotation".to_string(),
63            description: "Emit a region annotation for a changed semantic unit.".to_string(),
64            input_schema: serde_json::json!({
65                "type": "object",
66                "properties": {
67                    "file": { "type": "string", "description": "File path" },
68                    "ast_anchor": {
69                        "type": "object",
70                        "properties": {
71                            "unit_type": { "type": "string" },
72                            "name": { "type": "string" },
73                            "signature": { "type": "string" }
74                        },
75                        "required": ["unit_type", "name"]
76                    },
77                    "lines": {
78                        "type": "object",
79                        "properties": {
80                            "start": { "type": "integer" },
81                            "end": { "type": "integer" }
82                        },
83                        "required": ["start", "end"]
84                    },
85                    "intent": { "type": "string", "description": "What this change does and why" },
86                    "reasoning": { "type": "string" },
87                    "constraints": {
88                        "type": "array",
89                        "items": {
90                            "type": "object",
91                            "properties": {
92                                "text": { "type": "string" },
93                                "source": { "type": "string", "enum": ["author", "inferred"] }
94                            },
95                            "required": ["text", "source"]
96                        }
97                    },
98                    "semantic_dependencies": {
99                        "type": "array",
100                        "items": {
101                            "type": "object",
102                            "properties": {
103                                "file": { "type": "string" },
104                                "anchor": { "type": "string" },
105                                "nature": { "type": "string" }
106                            },
107                            "required": ["file", "anchor", "nature"]
108                        }
109                    },
110                    "tags": {
111                        "type": "array",
112                        "items": { "type": "string" }
113                    },
114                    "risk_notes": { "type": "string" }
115                },
116                "required": ["file", "ast_anchor", "lines", "intent"]
117            }),
118        },
119        ToolDefinition {
120            name: "emit_cross_cutting".to_string(),
121            description: "Emit a cross-cutting concern that spans multiple regions.".to_string(),
122            input_schema: serde_json::json!({
123                "type": "object",
124                "properties": {
125                    "description": { "type": "string" },
126                    "regions": {
127                        "type": "array",
128                        "items": {
129                            "type": "object",
130                            "properties": {
131                                "file": { "type": "string" },
132                                "anchor": { "type": "string" }
133                            },
134                            "required": ["file", "anchor"]
135                        }
136                    },
137                    "tags": {
138                        "type": "array",
139                        "items": { "type": "string" }
140                    }
141                },
142                "required": ["description", "regions"]
143            }),
144        },
145    ]
146}
147
148/// Dispatch a tool call by name, returning the result string.
149pub fn dispatch_tool(
150    name: &str,
151    input: &serde_json::Value,
152    git_ops: &dyn GitOps,
153    context: &AnnotationContext,
154    collected_regions: &mut Vec<RegionAnnotation>,
155    collected_cross_cutting: &mut Vec<CrossCuttingConcern>,
156) -> Result<String, AgentError> {
157    match name {
158        "get_diff" => dispatch_get_diff(context),
159        "get_file_content" => dispatch_get_file_content(input, git_ops, context),
160        "get_ast_outline" => dispatch_get_ast_outline(input, git_ops, context),
161        "get_commit_info" => dispatch_get_commit_info(context),
162        "emit_annotation" => dispatch_emit_annotation(input, collected_regions),
163        "emit_cross_cutting" => dispatch_emit_cross_cutting(input, collected_cross_cutting),
164        _ => Ok(format!("Unknown tool: {name}")),
165    }
166}
167
168fn dispatch_get_diff(context: &AnnotationContext) -> Result<String, AgentError> {
169    let mut out = String::new();
170    for diff in &context.diffs {
171        out.push_str(&format!(
172            "--- a/{}\n+++ b/{}\n",
173            diff.old_path.as_deref().unwrap_or(&diff.path),
174            &diff.path
175        ));
176        for hunk in &diff.hunks {
177            out.push_str(&hunk.header);
178            out.push('\n');
179            for line in &hunk.lines {
180                match line {
181                    HunkLine::Context(s) => {
182                        out.push(' ');
183                        out.push_str(s);
184                        out.push('\n');
185                    }
186                    HunkLine::Added(s) => {
187                        out.push('+');
188                        out.push_str(s);
189                        out.push('\n');
190                    }
191                    HunkLine::Removed(s) => {
192                        out.push('-');
193                        out.push_str(s);
194                        out.push('\n');
195                    }
196                }
197            }
198        }
199    }
200    Ok(out)
201}
202
203fn dispatch_get_file_content(
204    input: &serde_json::Value,
205    git_ops: &dyn GitOps,
206    context: &AnnotationContext,
207) -> Result<String, AgentError> {
208    let path = input.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
209        AgentError::InvalidAnnotation {
210            message: "get_file_content requires 'path' parameter".to_string(),
211            location: snafu::Location::default(),
212        }
213    })?;
214    let content = git_ops
215        .file_at_commit(Path::new(path), &context.commit_sha)
216        .context(GitSnafu)?;
217    Ok(content)
218}
219
220fn dispatch_get_ast_outline(
221    input: &serde_json::Value,
222    git_ops: &dyn GitOps,
223    context: &AnnotationContext,
224) -> Result<String, AgentError> {
225    let path = input.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
226        AgentError::InvalidAnnotation {
227            message: "get_ast_outline requires 'path' parameter".to_string(),
228            location: snafu::Location::default(),
229        }
230    })?;
231
232    let source = git_ops
233        .file_at_commit(Path::new(path), &context.commit_sha)
234        .context(GitSnafu)?;
235
236    let language = crate::ast::Language::from_path(path);
237    match crate::ast::extract_outline(&source, language) {
238        Ok(entries) => {
239            let mut out = String::new();
240            for entry in &entries {
241                out.push_str(&format!(
242                    "{} {} (lines {}-{})",
243                    entry.kind.as_str(),
244                    entry.name,
245                    entry.lines.start,
246                    entry.lines.end,
247                ));
248                if let Some(sig) = &entry.signature {
249                    out.push_str(&format!(" sig: {sig}"));
250                }
251                out.push('\n');
252            }
253            Ok(out)
254        }
255        Err(e) => Ok(format!("AST outline not available: {e}")),
256    }
257}
258
259fn dispatch_get_commit_info(context: &AnnotationContext) -> Result<String, AgentError> {
260    Ok(format!(
261        "SHA: {}\nMessage: {}\nAuthor: {} <{}>\nTimestamp: {}",
262        context.commit_sha,
263        context.commit_message,
264        context.author_name,
265        context.author_email,
266        context.timestamp,
267    ))
268}
269
270fn dispatch_emit_annotation(
271    input: &serde_json::Value,
272    collected_regions: &mut Vec<RegionAnnotation>,
273) -> Result<String, AgentError> {
274    let annotation: RegionAnnotation = serde_json::from_value(input.clone()).context(JsonSnafu)?;
275    collected_regions.push(annotation);
276    Ok(format!(
277        "Annotation emitted. Total annotations: {}",
278        collected_regions.len()
279    ))
280}
281
282fn dispatch_emit_cross_cutting(
283    input: &serde_json::Value,
284    collected_cross_cutting: &mut Vec<CrossCuttingConcern>,
285) -> Result<String, AgentError> {
286    let concern: CrossCuttingConcern = serde_json::from_value(input.clone()).context(JsonSnafu)?;
287    collected_cross_cutting.push(concern);
288    Ok(format!(
289        "Cross-cutting concern emitted. Total: {}",
290        collected_cross_cutting.len()
291    ))
292}