|
|
@@ -135,12 +135,17 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
|
|
|
}
|
|
|
if (fileCount < 5000) {
|
|
|
return {
|
|
|
- maxOutputChars: 13000,
|
|
|
- defaultMaxFiles: 6,
|
|
|
- maxCharsPerFile: 2500,
|
|
|
- gapThreshold: 10,
|
|
|
- maxSymbolsInFileHeader: 8,
|
|
|
- maxEdgesPerRelationshipKind: 8,
|
|
|
+ // Sized so ONE explore can cover a flow that centers on a god-file (e.g.
|
|
|
+ // excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
|
|
|
+ // a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
|
|
|
+ // smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
|
|
|
+ // cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
|
|
|
+ maxOutputChars: 28000,
|
|
|
+ defaultMaxFiles: 10,
|
|
|
+ maxCharsPerFile: 6500,
|
|
|
+ gapThreshold: 12,
|
|
|
+ maxSymbolsInFileHeader: 10,
|
|
|
+ maxEdgesPerRelationshipKind: 10,
|
|
|
includeRelationships: true,
|
|
|
includeAdditionalFiles: true,
|
|
|
includeCompletenessSignal: true,
|
|
|
@@ -413,7 +418,7 @@ export const tools: ToolDefinition[] = [
|
|
|
},
|
|
|
{
|
|
|
name: 'codegraph_node',
|
|
|
- description: 'Get detailed info about ONE symbol (location, signature, docstring). Pass includeCode=true for source: a function/method returns its body; a class/interface/struct/enum returns a compact member OUTLINE (fields + method signatures + line numbers), not every method body — Read or codegraph_node a specific member for its body. Keep includeCode=false to minimize context. For SEVERAL related symbols, make ONE codegraph_explore (or codegraph_context) call instead of many node calls — repeated node calls each re-read the whole context and cost far more.',
|
|
|
+ description: 'Get ONE symbol\'s details (location, signature, docstring) PLUS its TRAIL — what it calls and what calls it, each with file:line. Pass includeCode=true for source (functions return their body; containers return a member outline). Use this to WALK the call graph hop-by-hop — node a symbol, then node one of its trail entries — the structural, no-Read way to follow "what calls/triggers/handles X" across files. For a broad first overview of many symbols at once use codegraph_explore; use node to drill along a specific path from there. (If a trail is empty on a non-leaf, that hop is likely dynamic dispatch — read just that line.) Source returned with includeCode is the verbatim live file content — identical to Read.',
|
|
|
inputSchema: {
|
|
|
type: 'object',
|
|
|
properties: {
|
|
|
@@ -433,7 +438,7 @@ export const tools: ToolDefinition[] = [
|
|
|
},
|
|
|
{
|
|
|
name: 'codegraph_explore',
|
|
|
- description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts".',
|
|
|
+ description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts". The code it returns is the VERBATIM live file source (byte-for-byte identical to Read), line-numbered — not a summary; treat files it shows as already Read, no need to re-open them.',
|
|
|
inputSchema: {
|
|
|
type: 'object',
|
|
|
properties: {
|
|
|
@@ -494,6 +499,25 @@ export const tools: ToolDefinition[] = [
|
|
|
},
|
|
|
},
|
|
|
},
|
|
|
+ {
|
|
|
+ name: 'codegraph_trace',
|
|
|
+ description: 'Trace the CALL PATH between two symbols — "how does <from> reach/become <to>?" Returns the chain of functions from one to the other (each hop with file:line + the call-site line) in ONE call. This is something grep/Read structurally cannot do: there is no text pattern for "the path from A to B". Ideal for flow questions — how an update triggers a render, how a request reaches a handler, how a QuerySet becomes SQL. If no static path exists the chain likely breaks at dynamic dispatch (callbacks/descriptors/metaclasses); the tool says where and points you to codegraph_node to bridge it.',
|
|
|
+ inputSchema: {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ from: {
|
|
|
+ type: 'string',
|
|
|
+ description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
|
|
|
+ },
|
|
|
+ to: {
|
|
|
+ type: 'string',
|
|
|
+ description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
|
|
|
+ },
|
|
|
+ projectPath: projectPathProperty,
|
|
|
+ },
|
|
|
+ required: ['from', 'to'],
|
|
|
+ },
|
|
|
+ },
|
|
|
];
|
|
|
|
|
|
/**
|
|
|
@@ -734,6 +758,8 @@ export class ToolHandler {
|
|
|
return await this.handleStatus(args);
|
|
|
case 'codegraph_files':
|
|
|
return await this.handleFiles(args);
|
|
|
+ case 'codegraph_trace':
|
|
|
+ return await this.handleTrace(args);
|
|
|
default:
|
|
|
return this.errorResult(`Unknown tool: ${toolName}`);
|
|
|
}
|
|
|
@@ -947,6 +973,163 @@ export class ToolHandler {
|
|
|
return this.textResult(this.truncateOutput(formatted));
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Handle codegraph_trace — shortest CALL PATH between two symbols.
|
|
|
+ *
|
|
|
+ * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
|
|
|
+ * each hop annotated with file:line and the call-site line. This is the
|
|
|
+ * capability grep/Read structurally cannot provide. When no static path
|
|
|
+ * exists, the chain has almost certainly broken at dynamic dispatch
|
|
|
+ * (callbacks, descriptors, metaclasses) — we say so and surface the start
|
|
|
+ * symbol's outgoing calls so the agent bridges the one missing hop with
|
|
|
+ * codegraph_node rather than blindly reading.
|
|
|
+ */
|
|
|
+ private async handleTrace(args: Record<string, unknown>): Promise<ToolResult> {
|
|
|
+ const from = this.validateString(args.from, 'from');
|
|
|
+ if (typeof from !== 'string') return from;
|
|
|
+ const to = this.validateString(args.to, 'to');
|
|
|
+ if (typeof to !== 'string') return to;
|
|
|
+
|
|
|
+ const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
|
|
+ const fromMatches = this.findAllSymbols(cg, from);
|
|
|
+ if (fromMatches.nodes.length === 0) return this.textResult(`Symbol "${from}" not found in the codebase`);
|
|
|
+ const toMatches = this.findAllSymbols(cg, to);
|
|
|
+ if (toMatches.nodes.length === 0) return this.textResult(`Symbol "${to}" not found in the codebase`);
|
|
|
+
|
|
|
+ // Trace along call edges only — a true call path. Names can map to several
|
|
|
+ // nodes, so try a few from×to candidate pairs until a usable path turns up.
|
|
|
+ //
|
|
|
+ // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
|
|
|
+ // is almost always a spurious wander through unrelated code (django's
|
|
|
+ // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
|
|
|
+ // the real execution flow — and a confident-but-wrong 15-hop trace is worse
|
|
|
+ // than none. Over-cap paths are rejected and reported as "no direct path"
|
|
|
+ // (which, on real code, means the flow breaks at dynamic dispatch).
|
|
|
+ const edgeKinds: Edge['kind'][] = ['calls'];
|
|
|
+ const MAX_HOPS = 7;
|
|
|
+ const fromTry = fromMatches.nodes.slice(0, 3);
|
|
|
+ const toTry = toMatches.nodes.slice(0, 3);
|
|
|
+ let path: Array<{ node: Node; edge: Edge | null }> | null = null;
|
|
|
+ let overCap: Array<{ node: Node; edge: Edge | null }> | null = null;
|
|
|
+ for (const f of fromTry) {
|
|
|
+ for (const t of toTry) {
|
|
|
+ const p = cg.findPath(f.id, t.id, edgeKinds);
|
|
|
+ if (!p || p.length <= 1) continue;
|
|
|
+ if (p.length <= MAX_HOPS) { path = p; break; }
|
|
|
+ if (!overCap || p.length < overCap.length) overCap = p;
|
|
|
+ }
|
|
|
+ if (path) break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!path) {
|
|
|
+ // No static path — almost always a dynamic-dispatch break. Surface the
|
|
|
+ // start symbol's outgoing calls so the agent can bridge the gap.
|
|
|
+ const start = fromTry[0]!;
|
|
|
+ const callees = cg.getCallees(start.id).slice(0, 10)
|
|
|
+ .map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`);
|
|
|
+ const lines = [
|
|
|
+ `No direct call path from "${from}" to "${to}".`,
|
|
|
+ '',
|
|
|
+ (overCap
|
|
|
+ ? `(Only a ${overCap.length}-hop indirect chain connects them — almost certainly a BFS wander through unrelated code, not the real flow.) `
|
|
|
+ : '') +
|
|
|
+ 'The direct chain most likely breaks at **dynamic dispatch** (a callback, descriptor, ' +
|
|
|
+ 'metaclass, or attribute-as-callable) that static parsing cannot resolve into an edge. ' +
|
|
|
+ `Inspect \`${start.name}\` (${start.filePath}:${start.startLine}) with codegraph_node ` +
|
|
|
+ '(includeCode=true) — its body usually shows the dynamic call to follow next.',
|
|
|
+ ];
|
|
|
+ if (callees.length > 0) {
|
|
|
+ lines.push('', `**${start.name} statically calls:** ${callees.join(', ')}`);
|
|
|
+ }
|
|
|
+ return this.textResult(lines.join('\n') + fromMatches.note + toMatches.note);
|
|
|
+ }
|
|
|
+
|
|
|
+ const lines: string[] = [`## Trace: ${from} → ${to}`, '', `${path.length} hops:`, ''];
|
|
|
+ // Inline the evidence each hop needs so the agent doesn't Read/Grep to get it:
|
|
|
+ // the call-site source line for static calls, and — for dynamic-dispatch hops
|
|
|
+ // bridged by callback synthesis — where the callback was registered. (This is
|
|
|
+ // exactly what agents grepped for under a Read-0 constraint.)
|
|
|
+ const fileCache = new Map<string, string[]>();
|
|
|
+ for (let i = 0; i < path.length; i++) {
|
|
|
+ const step = path[i]!;
|
|
|
+ if (step.edge) {
|
|
|
+ const synth = this.synthEdgeNote(step.edge);
|
|
|
+ if (synth) {
|
|
|
+ lines.push(` ↓ ${synth.label}`);
|
|
|
+ if (synth.registeredAt) {
|
|
|
+ const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
|
|
|
+ lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // The call happens in the PREVIOUS hop's file at edge.line.
|
|
|
+ const prev = path[i - 1];
|
|
|
+ const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
|
|
|
+ const callSrc = this.sourceLineAt(cg, ref, fileCache);
|
|
|
+ lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
|
|
|
+ }
|
|
|
+ lines.push('', '> Each hop shows its call-site source line (and, for dynamic-dispatch hops, where the callback was registered) — no Read needed. codegraph_node a hop only for its full body.');
|
|
|
+ return this.textResult(this.truncateOutput(lines.join('\n')));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Describe a synthesized (dynamic-dispatch) edge for human output: how the
|
|
|
+ * callback was wired up — the bridge static parsing can't see. Returns null
|
|
|
+ * for ordinary static edges. Used by trace + the node trail so a synthesized
|
|
|
+ * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
|
|
|
+ */
|
|
|
+ private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
|
|
|
+ if (!edge || edge.provenance !== 'heuristic') return null;
|
|
|
+ const m = edge.metadata as Record<string, unknown> | undefined;
|
|
|
+ const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
|
|
|
+ const at = registeredAt ? ` @${registeredAt}` : '';
|
|
|
+ if (m?.synthesizedBy === 'callback') {
|
|
|
+ const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
|
|
|
+ const field = m.field ? ` on .${String(m.field)}` : '';
|
|
|
+ return {
|
|
|
+ label: `callback — registered via ${via}${field} (dynamic dispatch)`,
|
|
|
+ compact: `dynamic: callback via ${via}${at}`,
|
|
|
+ registeredAt,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ if (m?.synthesizedBy === 'event-emitter') {
|
|
|
+ const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
|
|
|
+ return {
|
|
|
+ label: `event ${ev} — emit → handler (dynamic dispatch)`,
|
|
|
+ compact: `dynamic: event ${ev}${at}`,
|
|
|
+ registeredAt,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Read one trimmed source line at "relpath:line" (relative to the project
|
|
|
+ * root). `cache` holds split file contents so a multi-hop trace reads each
|
|
|
+ * file at most once. Returns null if the file/line can't be resolved.
|
|
|
+ */
|
|
|
+ private sourceLineAt(cg: CodeGraph, ref: string | undefined, cache: Map<string, string[]>): string | null {
|
|
|
+ if (!ref) return null;
|
|
|
+ const i = ref.lastIndexOf(':');
|
|
|
+ if (i < 0) return null;
|
|
|
+ const filePath = ref.slice(0, i);
|
|
|
+ const line = parseInt(ref.slice(i + 1), 10);
|
|
|
+ if (!Number.isFinite(line) || line < 1) return null;
|
|
|
+ let fileLines = cache.get(filePath);
|
|
|
+ if (!fileLines) {
|
|
|
+ const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
|
|
|
+ if (!abs || !existsSync(abs)) return null;
|
|
|
+ try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
|
|
|
+ cache.set(filePath, fileLines);
|
|
|
+ }
|
|
|
+ const raw = fileLines[line - 1];
|
|
|
+ if (raw == null) return null;
|
|
|
+ const t = raw.trim();
|
|
|
+ return t.length > 160 ? t.slice(0, 157) + '…' : t;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Handle codegraph_explore — deep exploration in a single call
|
|
|
*
|
|
|
@@ -991,6 +1174,38 @@ export class ToolHandler {
|
|
|
return this.textResult(`No relevant code found for "${query}"`);
|
|
|
}
|
|
|
|
|
|
+ // Graph-aware glue: findRelevantContext builds the subgraph from name/text
|
|
|
+ // search, so a method that BRIDGES named symbols — e.g. App.tsx's
|
|
|
+ // triggerRender, which calls the named triggerUpdate — is never a search hit
|
|
|
+ // and gets missed, forcing the agent to Read the file to trace it. Pull in
|
|
|
+ // the callers/callees of the entry (root) nodes, but ONLY those that live in
|
|
|
+ // files the subgraph already surfaces (where the agent reads to fill gaps),
|
|
|
+ // so we add wiring without dragging in unrelated files. These get an
|
|
|
+ // importance boost below so they survive the per-file cluster budget.
|
|
|
+ const glueNodeIds = new Set<string>();
|
|
|
+ const subgraphFiles = new Set<string>();
|
|
|
+ for (const n of subgraph.nodes.values()) subgraphFiles.add(n.filePath);
|
|
|
+ const GLUE_NODE_CAP = 60;
|
|
|
+ for (const rootId of subgraph.roots) {
|
|
|
+ if (glueNodeIds.size >= GLUE_NODE_CAP) break;
|
|
|
+ let neighbors: Node[] = [];
|
|
|
+ try {
|
|
|
+ neighbors = [
|
|
|
+ ...cg.getCallers(rootId).map(c => c.node),
|
|
|
+ ...cg.getCallees(rootId).map(c => c.node),
|
|
|
+ ];
|
|
|
+ } catch {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (const nb of neighbors) {
|
|
|
+ if (glueNodeIds.size >= GLUE_NODE_CAP) break;
|
|
|
+ if (subgraph.nodes.has(nb.id)) continue;
|
|
|
+ if (!subgraphFiles.has(nb.filePath)) continue;
|
|
|
+ subgraph.nodes.set(nb.id, nb);
|
|
|
+ glueNodeIds.add(nb.id);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// Step 2: Group nodes by file, score by relevance
|
|
|
const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
|
|
|
const entryNodeIds = new Set(subgraph.roots);
|
|
|
@@ -1100,6 +1315,8 @@ export class ToolHandler {
|
|
|
// Step 4: Read contiguous file sections
|
|
|
lines.push('### Source Code');
|
|
|
lines.push('');
|
|
|
+ lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
|
|
|
+ lines.push('');
|
|
|
|
|
|
let totalChars = lines.join('\n').length;
|
|
|
let filesIncluded = 0;
|
|
|
@@ -1122,6 +1339,38 @@ export class ToolHandler {
|
|
|
const fileLines = fileContent.split('\n');
|
|
|
const lang = group.nodes[0]?.language || '';
|
|
|
|
|
|
+ // Whole-small-file rule: if a relevant file is small enough to afford,
|
|
|
+ // return it ENTIRELY instead of clustering. Clustering exists to tame
|
|
|
+ // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
|
|
|
+ // lossy subset of a file the agent will just Read in full anyway — costing
|
|
|
+ // a round-trip and a re-read every later turn. Reserve clustering for files
|
|
|
+ // too big to ship whole. Still bounded by the total maxOutputChars check.
|
|
|
+ const WHOLE_FILE_MAX_LINES = 220;
|
|
|
+ const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
|
|
|
+ if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
|
|
|
+ const body = fileContent.replace(/\n+$/, '');
|
|
|
+ let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
|
|
|
+ const uniqSymbols = [...new Set(
|
|
|
+ group.nodes
|
|
|
+ .filter(n => n.kind !== 'import' && n.kind !== 'export')
|
|
|
+ .map(n => `${n.name}(${n.kind})`)
|
|
|
+ )];
|
|
|
+ const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
|
|
|
+ const omitted = uniqSymbols.length - headerNames.length;
|
|
|
+ const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
|
|
|
+
|
|
|
+ if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
|
|
|
+ const remaining = budget.maxOutputChars - totalChars - 200;
|
|
|
+ if (remaining < 500) break;
|
|
|
+ wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
|
|
|
+ anyFileTrimmed = true;
|
|
|
+ }
|
|
|
+ lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
|
|
|
+ totalChars += wholeSection.length + 200;
|
|
|
+ filesIncluded++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
// Cluster nearby symbols to avoid reading huge gaps between distant symbols.
|
|
|
// Sort by start line, then merge overlapping/adjacent ranges (within the
|
|
|
// adaptive gap threshold). Include both node ranges AND edge source
|
|
|
@@ -1149,6 +1398,7 @@ export class ToolHandler {
|
|
|
.map(n => {
|
|
|
let importance = 1;
|
|
|
if (entryNodeIds.has(n.id)) importance = 10;
|
|
|
+ else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
|
|
|
else if (connectedToEntry.has(n.id)) importance = 3;
|
|
|
return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
|
|
|
});
|
|
|
@@ -1345,7 +1595,7 @@ export class ToolHandler {
|
|
|
.sort((a, b) => b[1].score - a[1].score);
|
|
|
const remainingFiles = [...remainingRelevant, ...peripheralFiles];
|
|
|
if (remainingFiles.length > 0) {
|
|
|
- lines.push('### Additional relevant files (not shown)');
|
|
|
+ lines.push('### Not shown above — explore these names for their source');
|
|
|
lines.push('');
|
|
|
for (const [filePath, group] of remainingFiles.slice(0, 10)) {
|
|
|
const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
|
|
|
@@ -1364,10 +1614,10 @@ export class ToolHandler {
|
|
|
if (budget.includeCompletenessSignal) {
|
|
|
lines.push('');
|
|
|
lines.push('---');
|
|
|
- lines.push(`> **Complete source code is included above for ${filesIncluded} files.** You do NOT need to re-read these files — the relevant sections are already shown in full. Only use Read/Grep for files listed under "Additional relevant files" if you need more detail.`);
|
|
|
+ lines.push(`> **Complete source for ${filesIncluded} files is included above — do NOT re-read them.** If your question also needs files/symbols listed under "Not shown above" (or any area this call didn't cover), make ANOTHER codegraph_explore targeting those names — it returns the same source with line numbers and is cheaper and more complete than reading. Reserve Read for a single specific line range explore can't surface.`);
|
|
|
} else if (anyFileTrimmed) {
|
|
|
lines.push('');
|
|
|
- lines.push(`> Some file sections were trimmed for size. Use \`codegraph_node\` or Read for the full source if needed.`);
|
|
|
+ lines.push(`> Some file sections were trimmed for size. For a specific symbol you still need, run another \`codegraph_explore\` (or \`codegraph_node\`) with its exact name — line-numbered source, cheaper and more complete than Read.`);
|
|
|
}
|
|
|
|
|
|
// Add explore budget note based on project size
|
|
|
@@ -1376,7 +1626,7 @@ export class ToolHandler {
|
|
|
const stats = cg.getStats();
|
|
|
const callBudget = getExploreBudget(stats.fileCount);
|
|
|
lines.push('');
|
|
|
- lines.push(`> **Explore budget: ${callBudget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${callBudget} calls — do NOT make additional explore calls beyond this budget.`);
|
|
|
+ lines.push(`> **Explore budget: ${callBudget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).** Each call covers ~6 files; if your question spans more, spend your remaining calls on the uncovered area BEFORE falling back to Read — another explore is cheaper and more complete than reading those files. Synthesize once you've used ${callBudget}.`);
|
|
|
} catch {
|
|
|
// Stats unavailable — skip budget note
|
|
|
}
|
|
|
@@ -1393,7 +1643,7 @@ export class ToolHandler {
|
|
|
const cut = output.slice(0, budget.maxOutputChars);
|
|
|
const lastNewline = cut.lastIndexOf('\n');
|
|
|
const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
|
|
|
- return this.textResult(safe + '\n\n... (explore output truncated to budget — use codegraph_node or Read for more)');
|
|
|
+ return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
|
|
|
}
|
|
|
return this.textResult(output);
|
|
|
}
|
|
|
@@ -1432,10 +1682,50 @@ export class ToolHandler {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- const formatted = this.formatNodeDetails(match.node, code, outline) + match.note;
|
|
|
+ const trail = this.formatTrail(cg, match.node);
|
|
|
+ const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
|
|
|
return this.textResult(this.truncateOutput(formatted));
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Build the "trail" for a symbol: its direct callees (what it calls) and
|
|
|
+ * callers (what calls it), each with file:line — so codegraph_node doubles as
|
|
|
+ * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
|
|
|
+ * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
|
|
|
+ * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
|
|
|
+ * dynamic dispatch the static graph couldn't resolve — that absence is itself
|
|
|
+ * a signal (read that one hop) rather than a dead end.
|
|
|
+ */
|
|
|
+ private formatTrail(cg: CodeGraph, node: Node): string {
|
|
|
+ const TRAIL_CAP = 12;
|
|
|
+ const fmt = (e: { node: Node; edge: Edge }) => {
|
|
|
+ const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
|
|
|
+ const synth = this.synthEdgeNote(e.edge);
|
|
|
+ return synth ? `${base} [${synth.compact}]` : base;
|
|
|
+ };
|
|
|
+ const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
|
|
|
+ const seen = new Set<string>([node.id]);
|
|
|
+ const out: Array<{ node: Node; edge: Edge }> = [];
|
|
|
+ for (const e of edges) {
|
|
|
+ if (seen.has(e.node.id)) continue;
|
|
|
+ seen.add(e.node.id);
|
|
|
+ out.push(e);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ };
|
|
|
+ const callees = collect(cg.getCallees(node.id));
|
|
|
+ const callers = collect(cg.getCallers(node.id));
|
|
|
+ if (callees.length === 0 && callers.length === 0) return '';
|
|
|
+ const lines: string[] = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
|
|
|
+ if (callees.length > 0) {
|
|
|
+ lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
|
|
|
+ }
|
|
|
+ if (callers.length > 0) {
|
|
|
+ lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
|
|
|
+ }
|
|
|
+ return lines.join('\n');
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Handle codegraph_status
|
|
|
*/
|