1use 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#[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 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#[derive(Deserialize, JsonSchema)]
164pub struct CodegraphOverviewArgs {}
165
166#[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#[derive(Deserialize, JsonSchema)]
190pub struct ListCallablesArgs {
191 pub module: String,
193}
194
195#[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#[derive(Deserialize, JsonSchema)]
260pub struct LookupCallableArgs {
261 pub name: String,
263 #[serde(default)]
265 pub module: Option<String>,
266}
267
268#[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#[derive(Deserialize, JsonSchema)]
309pub struct LookupStateArgs {
310 pub name: String,
312 #[serde(default)]
314 pub module: Option<String>,
315}
316
317#[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#[derive(Deserialize, JsonSchema)]
395pub struct ReadCallableSourceArgs {
396 pub name: String,
398 #[serde(default)]
400 pub module: Option<String>,
401}
402
403#[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#[derive(Deserialize, JsonSchema)]
456pub struct ReadModuleSourceArgs {
457 pub module: String,
459}
460
461#[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}