Skip to main content

llmy_codegraph/
tools.rs

1//! Agent-facing query tools over a built [`CodeGraph`]: overview, per-module
2//! and per-callable source reading, call-edge lookups and state
3//! cross-references.
4
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use itertools::Itertools;
9use llmy_agent::tool::ToolBox;
10use llmy_types::error::LLMYError;
11use schemars::JsonSchema;
12use serde::Deserialize;
13
14use crate::model::{AccessKind, Callable, CodeGraph, Module};
15
16const MAX_SOURCE_MATCHES: usize = 3;
17
18/// Shared context for every codegraph tool.
19#[derive(Debug, Clone)]
20pub struct CodegraphContext {
21    graph: Arc<CodeGraph>,
22    root: PathBuf,
23}
24
25impl CodegraphContext {
26    pub fn new(graph: CodeGraph, root: PathBuf) -> Self {
27        Self {
28            graph: Arc::new(graph),
29            root,
30        }
31    }
32
33    pub fn graph(&self) -> &CodeGraph {
34        &self.graph
35    }
36
37    /// A short project map for the system prompt: counts plus the module
38    /// list with per-module callable/state tallies.
39    pub fn render_overview(&self) -> String {
40        if self.graph.is_empty() {
41            return "The code graph is empty (no supported source files were found).".to_string();
42        }
43        let mut lines = vec![format!("Indexed: {}.", self.graph.counts())];
44        for module in self.graph.modules.values() {
45            let callables = self.graph.callables_of_module(module.id);
46            let states = self.graph.states_of_module(module.id);
47            lines.push(format!(
48                "- {} {} ({}, {}): {} callables, {} state items",
49                module.kind.render(),
50                module.name,
51                module.language.render(),
52                module.file.display(),
53                callables.len(),
54                states.len()
55            ));
56        }
57        lines.join("\n")
58    }
59
60    pub fn tool_box(&self) -> ToolBox {
61        let mut tools = ToolBox::new();
62        tools.add_tool(CodegraphOverviewTool::new(self.clone()));
63        tools.add_tool(ListCallablesTool::new(self.clone()));
64        tools.add_tool(LookupCallableTool::new(self.clone()));
65        tools.add_tool(LookupStateTool::new(self.clone()));
66        tools.add_tool(ReadCallableSourceTool::new(self.clone()));
67        tools.add_tool(ReadModuleSourceTool::new(self.clone()));
68        tools
69    }
70
71    fn modules_named(&self, name: &str) -> Vec<&Module> {
72        self.graph.modules_by_name(name)
73    }
74
75    fn render_callable_line(&self, callable: &Callable) -> String {
76        let module = self
77            .graph
78            .modules
79            .get(&callable.module_id)
80            .map(|m| m.name.as_str())
81            .unwrap_or("?");
82        format!(
83            "{}.{} [{}] ({}:{}..{})",
84            module,
85            callable.name,
86            callable.kind.render(),
87            callable.file.display(),
88            callable.span.start_line,
89            callable.span.end_line
90        )
91    }
92
93    fn render_callable_details(&self, callable: &Callable) -> String {
94        let mut sections = vec![
95            self.render_callable_line(callable),
96            format!("signature: {}", callable.signature),
97        ];
98
99        let outgoing = self.graph.outgoing_calls(callable.id);
100        if outgoing.is_empty() {
101            sections.push("outgoing calls: none".to_string());
102        } else {
103            let rendered = outgoing
104                .iter()
105                .map(|edge| {
106                    format!(
107                        "  - line {}: {} -> {}",
108                        edge.line,
109                        edge.callee_text,
110                        self.graph.render_callee(&edge.callee)
111                    )
112                })
113                .join("\n");
114            sections.push(format!("outgoing calls:\n{rendered}"));
115        }
116
117        let incoming = self.graph.incoming_calls(callable.id);
118        if incoming.is_empty() {
119            sections.push("incoming calls: none (not called from indexed code)".to_string());
120        } else {
121            let rendered = incoming
122                .iter()
123                .map(|edge| {
124                    format!(
125                        "  - {} at line {}",
126                        self.graph.render_callable_ref(edge.caller_id),
127                        edge.line
128                    )
129                })
130                .join("\n");
131            sections.push(format!("incoming calls:\n{rendered}"));
132        }
133
134        let accesses = self.graph.state_accesses_of(callable.id);
135        if accesses.is_empty() {
136            sections.push("state accesses: none detected".to_string());
137        } else {
138            let rendered = accesses
139                .iter()
140                .map(|edge| {
141                    let state = self
142                        .graph
143                        .states
144                        .get(&edge.state_id)
145                        .map(|s| s.name.as_str())
146                        .unwrap_or("?");
147                    format!(
148                        "  - {} {} (line {})",
149                        edge.access.render(),
150                        state,
151                        edge.line
152                    )
153                })
154                .join("\n");
155            sections.push(format!("state accesses:\n{rendered}"));
156        }
157
158        sections.join("\n")
159    }
160}
161
162/// Arguments accepted by [`CodegraphOverviewTool`].
163#[derive(Deserialize, JsonSchema)]
164pub struct CodegraphOverviewArgs {}
165
166/// Renders the module map.
167#[derive(Debug, Clone)]
168#[llmy_agent::tool(
169    arguments = CodegraphOverviewArgs,
170    invoke = overview,
171    name = "codegraph_overview",
172    description = "Show the code graph overview: indexed counts and every module/contract with its file, callable count and state item count.",
173)]
174pub struct CodegraphOverviewTool {
175    context: CodegraphContext,
176}
177
178impl CodegraphOverviewTool {
179    pub fn new(context: CodegraphContext) -> Self {
180        Self { context }
181    }
182
183    async fn overview(&self, _args: CodegraphOverviewArgs) -> Result<String, LLMYError> {
184        Ok(self.context.render_overview())
185    }
186}
187
188/// Arguments accepted by [`ListCallablesTool`].
189#[derive(Deserialize, JsonSchema)]
190pub struct ListCallablesArgs {
191    /// Module / contract name to list.
192    pub module: String,
193}
194
195/// Lists a module's callables and state items.
196#[derive(Debug, Clone)]
197#[llmy_agent::tool(
198    arguments = ListCallablesArgs,
199    invoke = list,
200    name = "list_callables",
201    description = "List every callable (with kind and signature) and state item of one module/contract.",
202)]
203pub struct ListCallablesTool {
204    context: CodegraphContext,
205}
206
207impl ListCallablesTool {
208    pub fn new(context: CodegraphContext) -> Self {
209        Self { context }
210    }
211
212    async fn list(&self, args: ListCallablesArgs) -> Result<String, LLMYError> {
213        let modules = self.context.modules_named(&args.module);
214        if modules.is_empty() {
215            return Ok(format!(
216                "No module named {:?} in the code graph; use codegraph_overview for the module list.",
217                args.module
218            ));
219        }
220        let mut sections = vec![];
221        for module in modules {
222            let mut lines = vec![format!(
223                "{} {} ({}, {})",
224                module.kind.render(),
225                module.name,
226                module.language.render(),
227                module.file.display()
228            )];
229            let states = self.context.graph.states_of_module(module.id);
230            if !states.is_empty() {
231                lines.push("state items:".to_string());
232                for state in states {
233                    lines.push(format!(
234                        "  - {} [{}]: {} (line {})",
235                        state.name,
236                        state.kind.render(),
237                        state.type_text,
238                        state.span.start_line
239                    ));
240                }
241            }
242            lines.push("callables:".to_string());
243            for callable in self.context.graph.callables_of_module(module.id) {
244                lines.push(format!(
245                    "  - [{}] {} (lines {}..{})",
246                    callable.kind.render(),
247                    callable.signature,
248                    callable.span.start_line,
249                    callable.span.end_line
250                ));
251            }
252            sections.push(lines.join("\n"));
253        }
254        Ok(sections.join("\n\n"))
255    }
256}
257
258/// Arguments accepted by [`LookupCallableTool`].
259#[derive(Deserialize, JsonSchema)]
260pub struct LookupCallableArgs {
261    /// Function / callable name to look up.
262    pub name: String,
263    /// Optional module/contract name to disambiguate.
264    #[serde(default)]
265    pub module: Option<String>,
266}
267
268/// Call edges and state accesses of a callable.
269#[derive(Debug, Clone)]
270#[llmy_agent::tool(
271    arguments = LookupCallableArgs,
272    invoke = lookup,
273    name = "lookup_callable",
274    description = "Look up a function/callable by name (optionally scoped to a module): its signature, outgoing calls, incoming callers, and the state items it reads/writes. Edges marked ambiguous/external are syntactic guesses — confirm in source.",
275)]
276pub struct LookupCallableTool {
277    context: CodegraphContext,
278}
279
280impl LookupCallableTool {
281    pub fn new(context: CodegraphContext) -> Self {
282        Self { context }
283    }
284
285    async fn lookup(&self, args: LookupCallableArgs) -> Result<String, LLMYError> {
286        let matches = self
287            .context
288            .graph
289            .find_callables(args.module.as_deref(), &args.name);
290        if matches.is_empty() {
291            return Ok(format!(
292                "No callable named {:?}{} in the code graph.",
293                args.name,
294                args.module
295                    .as_deref()
296                    .map(|m| format!(" in module {m:?}"))
297                    .unwrap_or_default()
298            ));
299        }
300        Ok(matches
301            .iter()
302            .map(|callable| self.context.render_callable_details(callable))
303            .join("\n\n---\n\n"))
304    }
305}
306
307/// Arguments accepted by [`LookupStateTool`].
308#[derive(Deserialize, JsonSchema)]
309pub struct LookupStateArgs {
310    /// State item name (state variable, account struct, resource/object).
311    pub name: String,
312    /// Optional module/contract name to disambiguate.
313    #[serde(default)]
314    pub module: Option<String>,
315}
316
317/// Readers and writers of a state item.
318#[derive(Debug, Clone)]
319#[llmy_agent::tool(
320    arguments = LookupStateArgs,
321    invoke = lookup,
322    name = "lookup_state",
323    description = "Look up a state item (Solidity state variable, Anchor account, CosmWasm Item/Map, Move resource/object) by name: where it is declared and every callable that reads or writes it.",
324)]
325pub struct LookupStateTool {
326    context: CodegraphContext,
327}
328
329impl LookupStateTool {
330    pub fn new(context: CodegraphContext) -> Self {
331        Self { context }
332    }
333
334    async fn lookup(&self, args: LookupStateArgs) -> Result<String, LLMYError> {
335        let matches = self
336            .context
337            .graph
338            .find_states(args.module.as_deref(), &args.name);
339        if matches.is_empty() {
340            return Ok(format!(
341                "No state item named {:?} in the code graph.",
342                args.name
343            ));
344        }
345        let mut sections = vec![];
346        for state in matches {
347            let module = self
348                .context
349                .graph
350                .modules
351                .get(&state.module_id)
352                .map(|m| m.name.as_str())
353                .unwrap_or("?");
354            let mut lines = vec![format!(
355                "{} [{}] declared in {} ({}:{})\ntype: {}",
356                state.name,
357                state.kind.render(),
358                module,
359                state.file.display(),
360                state.span.start_line,
361                state.type_text
362            )];
363            let accessors = self.context.graph.accessors_of_state(state.id);
364            let mut writers = vec![];
365            let mut readers = vec![];
366            for edge in accessors {
367                let rendered = format!(
368                    "  - {} (line {})",
369                    self.context.graph.render_callable_ref(edge.callable_id),
370                    edge.line
371                );
372                match edge.access {
373                    AccessKind::Write => writers.push(rendered),
374                    AccessKind::Read => readers.push(rendered),
375                }
376            }
377            lines.push(if writers.is_empty() {
378                "writers: none detected".to_string()
379            } else {
380                format!("writers:\n{}", writers.join("\n"))
381            });
382            lines.push(if readers.is_empty() {
383                "readers: none detected".to_string()
384            } else {
385                format!("readers:\n{}", readers.join("\n"))
386            });
387            sections.push(lines.join("\n"));
388        }
389        Ok(sections.join("\n\n---\n\n"))
390    }
391}
392
393/// Arguments accepted by [`ReadCallableSourceTool`].
394#[derive(Deserialize, JsonSchema)]
395pub struct ReadCallableSourceArgs {
396    /// Function / callable name.
397    pub name: String,
398    /// Optional module/contract name to disambiguate.
399    #[serde(default)]
400    pub module: Option<String>,
401}
402
403/// Reads a callable's source by name.
404#[derive(Debug, Clone)]
405#[llmy_agent::tool(
406    arguments = ReadCallableSourceArgs,
407    invoke = read,
408    name = "read_callable_source",
409    description = "Read the full source of a function/callable by name (optionally scoped to a module/contract).",
410)]
411pub struct ReadCallableSourceTool {
412    context: CodegraphContext,
413}
414
415impl ReadCallableSourceTool {
416    pub fn new(context: CodegraphContext) -> Self {
417        Self { context }
418    }
419
420    async fn read(&self, args: ReadCallableSourceArgs) -> Result<String, LLMYError> {
421        let matches = self
422            .context
423            .graph
424            .find_callables(args.module.as_deref(), &args.name);
425        if matches.is_empty() {
426            return Ok(format!(
427                "No callable named {:?} in the code graph.",
428                args.name
429            ));
430        }
431        let mut sections = vec![];
432        for callable in matches.iter().take(MAX_SOURCE_MATCHES) {
433            let source = self
434                .context
435                .graph
436                .read_span(&self.context.root, &callable.file, callable.span)
437                .await?;
438            sections.push(format!(
439                "{}\n```\n{}\n```",
440                self.context.render_callable_line(callable),
441                source
442            ));
443        }
444        if matches.len() > MAX_SOURCE_MATCHES {
445            sections.push(format!(
446                "[{} more matches omitted; scope with `module`]",
447                matches.len() - MAX_SOURCE_MATCHES
448            ));
449        }
450        Ok(sections.join("\n\n"))
451    }
452}
453
454/// Arguments accepted by [`ReadModuleSourceTool`].
455#[derive(Deserialize, JsonSchema)]
456pub struct ReadModuleSourceArgs {
457    /// Module / contract name.
458    pub module: String,
459}
460
461/// Reads a whole module/contract source by name.
462#[derive(Debug, Clone)]
463#[llmy_agent::tool(
464    arguments = ReadModuleSourceArgs,
465    invoke = read,
466    name = "read_module_source",
467    description = "Read the full source of a module/contract by name.",
468)]
469pub struct ReadModuleSourceTool {
470    context: CodegraphContext,
471}
472
473impl ReadModuleSourceTool {
474    pub fn new(context: CodegraphContext) -> Self {
475        Self { context }
476    }
477
478    async fn read(&self, args: ReadModuleSourceArgs) -> Result<String, LLMYError> {
479        let matches = self.context.modules_named(&args.module);
480        if matches.is_empty() {
481            return Ok(format!(
482                "No module named {:?} in the code graph; use codegraph_overview for the module list.",
483                args.module
484            ));
485        }
486        let mut sections = vec![];
487        for module in matches.iter().take(MAX_SOURCE_MATCHES) {
488            let source = self
489                .context
490                .graph
491                .read_span(&self.context.root, &module.file, module.span)
492                .await?;
493            sections.push(format!(
494                "{} {} ({}:{}..{})\n```\n{}\n```",
495                module.kind.render(),
496                module.name,
497                module.file.display(),
498                module.span.start_line,
499                module.span.end_line,
500                source
501            ));
502        }
503        Ok(sections.join("\n\n"))
504    }
505}