Ver código fonte

feat(mcp): self-sufficient flow output + fix explore budget regression

- Surface synthesized-edge evidence in trace, the node trail, and context call
  paths: a dynamic-dispatch hop now shows "callback via onUpdate @App.tsx:3148"
  with the registration site inline (and trace inlines each hop's call-site
  source line) -- the exact glue agents previously Read/Grep'd to reconstruct.
- Fix non-monotonic explore output budget: the 500-5000 file tier capped
  maxCharsPerFile at 2500, BELOW the <500 tier's 3800, so on god-file projects
  (excalidraw's 415 KB App.tsx) one explore returned <1% of the file and forced
  a Read. Raised to 6500/file, 28000 total.
- Stop explore from inviting Read: truncation/trim notes said "use Read for
  more"; they now steer to another codegraph_explore and treat returned source
  as already Read.

Measured on excalidraw: best-case flow answer went from 5 reads / 131s to
0 reads / 73s with ~3-4 codegraph calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 mês atrás
pai
commit
d9dee47669
2 arquivos alterados com 409 adições e 15 exclusões
  1. 105 1
      src/context/index.ts
  2. 304 14
      src/mcp/tools.ts

+ 105 - 1
src/context/index.ts

@@ -259,7 +259,7 @@ export class ContextBuilder {
 
     // Return formatted output or raw context
     if (opts.format === 'markdown') {
-      return formatContextAsMarkdown(context);
+      return formatContextAsMarkdown(context) + this.buildCallPathsSection(subgraph);
     } else if (opts.format === 'json') {
       return formatContextAsJson(context);
     }
@@ -267,6 +267,110 @@ export class ContextBuilder {
     return context;
   }
 
+  /**
+   * Surface short call-paths among the symbols this context already found,
+   * derived in-memory from the subgraph's `calls` edges (no extra queries).
+   *
+   * This bakes the value of path-finding INTO the always-loaded `context` tool.
+   * Agents reliably read context's output but do NOT discover/adopt a standalone
+   * trace tool (in deferred-MCP harnesses they only ToolSearch-select tools they
+   * already know). Delivering the flow here means "how does X reach Y" is
+   * answered without the agent needing to find, load, or choose a new tool.
+   * Chains stop where the static call graph ends (e.g. dynamic dispatch) — that
+   * truncation is honest, and the agent can codegraph_node the last hop to bridge.
+   */
+  private buildCallPathsSection(subgraph: Subgraph): string {
+    const adj = new Map<string, string[]>();
+    for (const e of subgraph.edges) {
+      if (e.kind !== 'calls') continue;
+      if (!subgraph.nodes.has(e.source) || !subgraph.nodes.has(e.target)) continue;
+      const list = adj.get(e.source);
+      if (list) list.push(e.target);
+      else adj.set(e.source, [e.target]);
+    }
+    if (adj.size === 0) return '';
+
+    const MAX_HOPS = 6;
+    const chains: string[][] = [];
+    let budget = 2000; // bound DFS work on dense subgraphs
+    const dfs = (id: string, path: string[], seen: Set<string>): void => {
+      if (budget-- <= 0) return;
+      const next = (adj.get(id) ?? []).filter((t) => !seen.has(t));
+      if (next.length === 0 || path.length >= MAX_HOPS) {
+        if (path.length >= 3) chains.push([...path]); // >=3 nodes = a real flow, not a single call
+        return;
+      }
+      for (const t of next) {
+        seen.add(t);
+        dfs(t, [...path, t], seen);
+        seen.delete(t);
+      }
+    };
+    const starts = (subgraph.roots.length > 0
+      ? subgraph.roots.filter((id) => adj.has(id))
+      : [...adj.keys()]
+    ).slice(0, 5);
+    for (const s of starts) dfs(s, [s], new Set([s]));
+    if (chains.length === 0) return '';
+
+    // Keep only chains that connect TWO OR MORE query-relevant symbols (roots).
+    // A chain from a root into an arbitrary callee (render → onMagicFrameGenerate)
+    // is structurally valid but tangential to the question; requiring ≥2 roots
+    // keeps the chain anchored to what the user actually asked about. Rank by
+    // #roots then length, and drop any that are a sub-path of a longer kept chain.
+    const rootSet = new Set(subgraph.roots);
+    const rootCount = (c: string[]): number => c.reduce((n, id) => n + (rootSet.has(id) ? 1 : 0), 0);
+    const relevant = chains.filter((c) => rootCount(c) >= 2);
+    relevant.sort((a, b) => rootCount(b) - rootCount(a) || b.length - a.length);
+    const kept: string[][] = [];
+    for (const c of relevant) {
+      const key = c.join('>');
+      if (kept.some((k) => k.join('>').includes(key))) continue;
+      kept.push(c);
+      if (kept.length >= 3) break;
+    }
+    if (kept.length === 0) return '';
+    const name = (id: string): string => subgraph.nodes.get(id)?.name ?? id;
+
+    // Synthesized (dynamic-dispatch) hops are real `calls` edges but invisible to
+    // static parsing — mark them inline so the agent sees WHERE the callback was
+    // wired up (`registered @file:line`) instead of grepping for it. Keyed by
+    // "source>target".
+    const synthByPair = new Map<string, string>();
+    for (const e of subgraph.edges) {
+      if (e.kind !== 'calls' || e.provenance !== 'heuristic') continue;
+      const m = e.metadata as Record<string, unknown> | undefined;
+      if (!m?.synthesizedBy) continue;
+      const at = typeof m.registeredAt === 'string' ? ` @${m.registeredAt}` : '';
+      const label = m.synthesizedBy === 'callback'
+        ? `callback via ${m.via ? `\`${String(m.via)}\`` : 'registrar'}${at}`
+        : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
+      synthByPair.set(`${e.source}>${e.target}`, label);
+    }
+    const renderChain = (c: string[]): string => {
+      let s = name(c[0]!);
+      for (let i = 1; i < c.length; i++) {
+        const synth = synthByPair.get(`${c[i - 1]}>${c[i]}`);
+        s += synth ? ` →[${synth}] ${name(c[i]!)}` : ` → ${name(c[i]!)}`;
+      }
+      return s;
+    };
+    const hasSynth = kept.some((c) => c.some((_, i) => i > 0 && synthByPair.has(`${c[i - 1]}>${c[i]}`)));
+    const lines = [
+      '',
+      '## Call paths',
+      '',
+      'Execution flow among the key symbols (traced through the call graph):',
+      '',
+      ...kept.map((c) => `- ${renderChain(c)}`),
+      '',
+      hasSynth
+        ? '_Hops marked `[callback/event …]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._'
+        : '_codegraph_node any symbol above for its source + its own callers/callees._',
+    ];
+    return '\n' + lines.join('\n') + '\n';
+  }
+
   /**
    * Find relevant subgraph for a query
    *

+ 304 - 14
src/mcp/tools.ts

@@ -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
    */