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::v2::{CodeMarker, Decision, Narrative};
11
12/// Collected output from the agent's emit tools.
13#[derive(Debug, Default)]
14pub struct CollectedOutput {
15    pub narrative: Option<Narrative>,
16    pub decisions: Vec<Decision>,
17    pub markers: Vec<CodeMarker>,
18}
19
20/// Return the tool definitions the agent has access to.
21pub fn tool_definitions() -> Vec<ToolDefinition> {
22    vec![
23        ToolDefinition {
24            name: "get_diff".to_string(),
25            description: "Get the full unified diff for this commit.".to_string(),
26            input_schema: serde_json::json!({
27                "type": "object",
28                "properties": {},
29                "required": []
30            }),
31        },
32        ToolDefinition {
33            name: "get_file_content".to_string(),
34            description: "Get the content of a file at this commit.".to_string(),
35            input_schema: serde_json::json!({
36                "type": "object",
37                "properties": {
38                    "path": {
39                        "type": "string",
40                        "description": "Path of the file to read"
41                    }
42                },
43                "required": ["path"]
44            }),
45        },
46        ToolDefinition {
47            name: "get_commit_info".to_string(),
48            description: "Get commit metadata: SHA, message, author, timestamp.".to_string(),
49            input_schema: serde_json::json!({
50                "type": "object",
51                "properties": {},
52                "required": []
53            }),
54        },
55        ToolDefinition {
56            name: "emit_narrative".to_string(),
57            description: "Emit the commit-level narrative (REQUIRED, call exactly once). \
58                Tell the story of this commit: what it does, why this approach, \
59                what was considered and rejected."
60                .to_string(),
61            input_schema: serde_json::json!({
62                "type": "object",
63                "properties": {
64                    "summary": {
65                        "type": "string",
66                        "description": "What this commit does and WHY this approach. Not a diff restatement."
67                    },
68                    "motivation": {
69                        "type": "string",
70                        "description": "What triggered this change? User request, bug, planned work?"
71                    },
72                    "rejected_alternatives": {
73                        "type": "array",
74                        "description": "Approaches that were considered and rejected",
75                        "items": {
76                            "type": "object",
77                            "properties": {
78                                "approach": { "type": "string" },
79                                "reason": { "type": "string" }
80                            },
81                            "required": ["approach", "reason"]
82                        }
83                    },
84                    "follow_up": {
85                        "type": "string",
86                        "description": "Expected follow-up work, if any. Omit if this is complete."
87                    }
88                },
89                "required": ["summary"]
90            }),
91        },
92        ToolDefinition {
93            name: "emit_decision".to_string(),
94            description: "Emit a design or architectural decision made in this commit.".to_string(),
95            input_schema: serde_json::json!({
96                "type": "object",
97                "properties": {
98                    "what": { "type": "string", "description": "What was decided" },
99                    "why": { "type": "string", "description": "Why this decision was made" },
100                    "stability": {
101                        "type": "string",
102                        "enum": ["permanent", "provisional", "experimental"],
103                        "description": "How stable is this decision?"
104                    },
105                    "revisit_when": {
106                        "type": "string",
107                        "description": "When should this decision be reconsidered?"
108                    },
109                    "scope": {
110                        "type": "array",
111                        "items": { "type": "string" },
112                        "description": "Files/modules this decision applies to"
113                    }
114                },
115                "required": ["what", "why", "stability"]
116            }),
117        },
118        ToolDefinition {
119            name: "emit_marker".to_string(),
120            description: "Emit a code marker for genuinely non-obvious behavior. \
121                Only use for contracts, hazards, dependencies, or unstable code. \
122                Do NOT emit a marker for every function."
123                .to_string(),
124            input_schema: serde_json::json!({
125                "type": "object",
126                "properties": {
127                    "file": { "type": "string", "description": "File path" },
128                    "anchor": {
129                        "type": "object",
130                        "description": "Optional AST anchor. Omit for file-level markers.",
131                        "properties": {
132                            "unit_type": { "type": "string" },
133                            "name": { "type": "string" },
134                            "signature": { "type": "string" }
135                        },
136                        "required": ["unit_type", "name"]
137                    },
138                    "lines": {
139                        "type": "object",
140                        "properties": {
141                            "start": { "type": "integer" },
142                            "end": { "type": "integer" }
143                        },
144                        "required": ["start", "end"]
145                    },
146                    "kind": {
147                        "type": "string",
148                        "enum": ["contract", "hazard", "dependency", "unstable"],
149                        "description": "Type of marker"
150                    },
151                    "description": {
152                        "type": "string",
153                        "description": "For contract/hazard/unstable: what the behavior or concern is"
154                    },
155                    "source": {
156                        "type": "string",
157                        "enum": ["author", "inferred"],
158                        "description": "For contracts: whether the author stated this or it was inferred"
159                    },
160                    "target_file": { "type": "string", "description": "For dependency: the file depended on" },
161                    "target_anchor": { "type": "string", "description": "For dependency: the anchor depended on" },
162                    "assumption": { "type": "string", "description": "For dependency: what is assumed" },
163                    "revisit_when": { "type": "string", "description": "For unstable: when to revisit" }
164                },
165                "required": ["file", "kind"]
166            }),
167        },
168    ]
169}
170
171/// Dispatch a tool call by name, returning the result string.
172pub fn dispatch_tool(
173    name: &str,
174    input: &serde_json::Value,
175    git_ops: &dyn GitOps,
176    context: &AnnotationContext,
177    collected: &mut CollectedOutput,
178) -> Result<String, AgentError> {
179    match name {
180        "get_diff" => dispatch_get_diff(context),
181        "get_file_content" => dispatch_get_file_content(input, git_ops, context),
182        "get_commit_info" => dispatch_get_commit_info(context),
183        "emit_narrative" => dispatch_emit_narrative(input, collected),
184        "emit_decision" => dispatch_emit_decision(input, collected),
185        "emit_marker" => dispatch_emit_marker(input, collected),
186        _ => Ok(format!("Unknown tool: {name}")),
187    }
188}
189
190fn dispatch_get_diff(context: &AnnotationContext) -> Result<String, AgentError> {
191    let mut out = String::new();
192    for diff in &context.diffs {
193        out.push_str(&format!(
194            "--- a/{}\n+++ b/{}\n",
195            diff.old_path.as_deref().unwrap_or(&diff.path),
196            &diff.path
197        ));
198        for hunk in &diff.hunks {
199            out.push_str(&hunk.header);
200            out.push('\n');
201            for line in &hunk.lines {
202                match line {
203                    HunkLine::Context(s) => {
204                        out.push(' ');
205                        out.push_str(s);
206                        out.push('\n');
207                    }
208                    HunkLine::Added(s) => {
209                        out.push('+');
210                        out.push_str(s);
211                        out.push('\n');
212                    }
213                    HunkLine::Removed(s) => {
214                        out.push('-');
215                        out.push_str(s);
216                        out.push('\n');
217                    }
218                }
219            }
220        }
221    }
222    Ok(out)
223}
224
225fn dispatch_get_file_content(
226    input: &serde_json::Value,
227    git_ops: &dyn GitOps,
228    context: &AnnotationContext,
229) -> Result<String, AgentError> {
230    let path = input.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
231        AgentError::InvalidAnnotation {
232            message: "get_file_content requires 'path' parameter".to_string(),
233            location: snafu::Location::default(),
234        }
235    })?;
236    let content = git_ops
237        .file_at_commit(Path::new(path), &context.commit_sha)
238        .context(GitSnafu)?;
239    Ok(content)
240}
241
242fn dispatch_get_commit_info(context: &AnnotationContext) -> Result<String, AgentError> {
243    Ok(format!(
244        "SHA: {}\nMessage: {}\nAuthor: {} <{}>\nTimestamp: {}",
245        context.commit_sha,
246        context.commit_message,
247        context.author_name,
248        context.author_email,
249        context.timestamp,
250    ))
251}
252
253fn dispatch_emit_narrative(
254    input: &serde_json::Value,
255    collected: &mut CollectedOutput,
256) -> Result<String, AgentError> {
257    let narrative: Narrative = serde_json::from_value(input.clone()).context(JsonSnafu)?;
258    collected.narrative = Some(narrative);
259    Ok("Narrative emitted.".to_string())
260}
261
262fn dispatch_emit_decision(
263    input: &serde_json::Value,
264    collected: &mut CollectedOutput,
265) -> Result<String, AgentError> {
266    let decision: Decision = serde_json::from_value(input.clone()).context(JsonSnafu)?;
267    collected.decisions.push(decision);
268    Ok(format!(
269        "Decision emitted. Total decisions: {}",
270        collected.decisions.len()
271    ))
272}
273
274fn dispatch_emit_marker(
275    input: &serde_json::Value,
276    collected: &mut CollectedOutput,
277) -> Result<String, AgentError> {
278    // The agent emits a flat JSON object with `kind` as a string discriminator
279    // and kind-specific fields at the top level. We manually construct the
280    // CodeMarker since the serde format for MarkerKind uses `tag = "type"`.
281    use crate::schema::common::{AstAnchor, LineRange};
282    use crate::schema::v2::{ContractSource, MarkerKind};
283
284    let file = input
285        .get("file")
286        .and_then(|v| v.as_str())
287        .unwrap_or("")
288        .to_string();
289    let kind_str = input
290        .get("kind")
291        .and_then(|v| v.as_str())
292        .unwrap_or("hazard");
293
294    let anchor = input.get("anchor").and_then(|v| {
295        let unit_type = v.get("unit_type")?.as_str()?.to_string();
296        let name = v.get("name")?.as_str()?.to_string();
297        let signature = v
298            .get("signature")
299            .and_then(|s| s.as_str())
300            .map(String::from);
301        Some(AstAnchor {
302            unit_type,
303            name,
304            signature,
305        })
306    });
307
308    let lines = input.get("lines").and_then(|v| {
309        let start = v.get("start")?.as_u64()? as u32;
310        let end = v.get("end")?.as_u64()? as u32;
311        Some(LineRange { start, end })
312    });
313
314    let marker_kind = match kind_str {
315        "contract" => {
316            let description = input
317                .get("description")
318                .and_then(|v| v.as_str())
319                .unwrap_or("")
320                .to_string();
321            let source = match input.get("source").and_then(|v| v.as_str()) {
322                Some("author") => ContractSource::Author,
323                _ => ContractSource::Inferred,
324            };
325            MarkerKind::Contract {
326                description,
327                source,
328            }
329        }
330        "hazard" => {
331            let description = input
332                .get("description")
333                .and_then(|v| v.as_str())
334                .unwrap_or("")
335                .to_string();
336            MarkerKind::Hazard { description }
337        }
338        "dependency" => {
339            let target_file = input
340                .get("target_file")
341                .and_then(|v| v.as_str())
342                .unwrap_or("")
343                .to_string();
344            let target_anchor = input
345                .get("target_anchor")
346                .and_then(|v| v.as_str())
347                .unwrap_or("")
348                .to_string();
349            let assumption = input
350                .get("assumption")
351                .and_then(|v| v.as_str())
352                .unwrap_or("")
353                .to_string();
354            MarkerKind::Dependency {
355                target_file,
356                target_anchor,
357                assumption,
358            }
359        }
360        "unstable" => {
361            let description = input
362                .get("description")
363                .and_then(|v| v.as_str())
364                .unwrap_or("")
365                .to_string();
366            let revisit_when = input
367                .get("revisit_when")
368                .and_then(|v| v.as_str())
369                .unwrap_or("")
370                .to_string();
371            MarkerKind::Unstable {
372                description,
373                revisit_when,
374            }
375        }
376        _ => {
377            return Err(AgentError::InvalidAnnotation {
378                message: format!("Unknown marker kind: {kind_str}"),
379                location: snafu::Location::default(),
380            });
381        }
382    };
383
384    let marker = CodeMarker {
385        file,
386        anchor,
387        lines,
388        kind: marker_kind,
389    };
390    collected.markers.push(marker);
391    Ok(format!(
392        "Marker emitted. Total markers: {}",
393        collected.markers.len()
394    ))
395}