notes · · 6 min

The files are not the answer

An agent asked to change one function opens three files and spends most of its context on code that has nothing to do with the task. A slice is the other shape of the same question: the declarations the function depends on, and nothing that merely lives beside them.

On this page · 4 sections
  1. The other shape of the question
  2. The edges are the point
  3. What it does not do
  4. What it costs

Ask an agent to change one function and watch what it reads. It opens the file the function is in, most of which is about something else. Then the file holding a helper it calls. Then the one behind that. For the function this post takes as its example, that is three files and 157 545 characters, to answer a question whose real answer is 18 707.

The rest is not noise in the ordinary sense. It is perfectly good code that has nothing to do with the task, and it arrives because files are how we store code, not how code depends on code.

The other shape of the question

code_slice takes a symbol — the name, as the case for addressing a tool by symbol rather than by position argues it should — and returns the declarations it depends on, grouped by the file they happen to live in, each one labelled with how it was reached. The paths below are shortened after the first mention; the tool prints them in full:

$ prod-code slice signature::change
Function change at crates/prod-code-mcp/src/signature.rs:521:14
slice of `change`: 16 item(s), 18707 bytes from 157545 bytes of source (88% smaller)
outside the workspace, not followed: Ok, Option, Path, PathBuf, Result, SocketAddr, Some,
String, Url, Vec, and_then, anyhow, and 60 more

=== crates/prod-code-mcp/src/remote_fs.rs
[function] uri_to_path         remote_fs.rs:52-55    (depth 2, used by references)

=== crates/prod-code-mcp/src/signature.rs
[enum]     Param               signature.rs:26-35    (depth 1, used by change)
[class]    Reference           signature.rs:38-38    (depth 2, used by references)
[struct]   Declared            signature.rs:42-47    (depth 1, used by change)
[struct]   SignatureChange     signature.rs:51-71    (depth 1, used by change)
[function] split_at_top_level  signature.rs:198-231  (depth 2, used by parse_declared)
[function] offset_of           signature.rs:234-248  (depth 1, used by change)
[function] param_span          signature.rs:252-308  (depth 1, used by change)
[function] split_params        signature.rs:312-340  (depth 2, used by parse_declared)
[function] parse_declared      signature.rs:343-364  (depth 1, used by change)
[struct]   Plan                signature.rs:370-374  (depth 1, used by change)
[function] plan                signature.rs:377-419  (depth 1, used by change)
[function] change              signature.rs:521-733  (the seed)
[function] references          signature.rs:790-831  (depth 1, used by change)
[function] line_col_at         signature.rs:833-842  (depth 1, used by change)

=== crates/prod-code-mcp/src/tools.rs
[function] execute_lsp_query   tools.rs:1972-1982    (depth 2, used by references)

Sixteen declarations instead of three files. The three files are 157 545 bytes; the slice is 18 707, and it carries the whole of each declaration — signature, body, doc comment — because a declaration cut in half is worse than no declaration at all.

Three file boxes — signature.rs, tools.rs and remote_fs.rs, 157 545 bytes between them — three declarations highlighted in signature.rs and one in each of the others, the rest greyed out. Below them, what the slice keeps: 16 items and 18 707 bytes, 88 percent smaller, each line labelled with the depth it was reached at and the declaration that reached it. Beside it, a separate bucket of types from outside the workspace — String, Vec, Result and 69 more — named but not followed
The slice follows the analyzer's edges, not the file boundaries. Two of the sixteen items live in other files, and most of what is in those files never appears.

Note the outside the workspace line. String, Vec, Result, anyhow and sixty-eight more are named and not followed: they are outside the workspace, the agent already knows them, and following them would drag the standard library into a context window to explain a function that merely returns a Result.

The edges are the point

Every line says how the item got there. parse_declared is at depth 1 because change calls it; split_params is at depth 2 because parse_declared calls it. That is not decoration — it is the difference between a slice and a pile.

An agent reading (depth 2, used by references) next to uri_to_path knows, without opening anything, that this function is two hops away and only matters through the reference lookup. It can decide to ignore it. With three files open it cannot decide anything, because nothing in a file says why it is on screen.

The depth is also the knob:

items slice of source
--depth 1 11 15 703 B 37 817 B 58% smaller
--depth 2 (default) 16 18 707 B 157 545 B 88% smaller
--depth 3 18 20 869 B 172 968 B 88% smaller

Depth 1 stays inside one file, so the comparison is against that file alone and the saving looks smaller. Depth 2 reaches into two more files and the saving jumps, which is the useful reading of that table: the further the dependencies spread, the more a slice is worth. Depth 3 adds two items and 2 kB — the graph is nearly closed by then, which is the usual shape.

There is a byte budget too, for when a symbol’s neighbourhood is genuinely large:

$ prod-code slice signature::change --max-bytes 4096
slice of `change`: 1 item(s), 8269 bytes from 37817 bytes of source (78% smaller)

The budget stops the walk; it does not truncate a declaration. One item came back and it is 8 269 bytes, because that is how big the seed is. A budget that cut the seed in half would produce something that compiles in nobody’s head.

Neither of the two tools that already exist answers this question, and it is worth being precise about why. An outline lists what a file contains. It is a table of contents: useful for orienting, useless for dependency, and it stops at the file’s edge — the two items above that live in other files would never appear.

Grep finds text. It would find parse_declared in change’s body, and also in a comment, in a test name, and in the unrelated function of another crate that happens to use the same word. Then it hands back lines, and a line is not a declaration: the agent still has to open the file to see the body it matched inside.

The slice walks the edges the analyzer already resolved while type-checking. parse_declared is in the slice because the compiler knows change calls that parse_declared, and the function’s whole text comes with it.

What it does not do

  • The unit is a declaration, not a statement. This is not data-flow slicing: ask for a function and you get the functions and types it uses, not “the six lines that affect this variable”. Inside a body, everything comes.
  • Foreign code is named, not followed. A dependency’s type is listed on that line and left there. If the thing you actually need to read is inside serde, a slice will not bring it; code_source will, and that is a different tool on purpose.
  • A macro hides its edges. What a declarative macro expands into is not walked, so a dependency that exists only after expansion is missing from the slice. The analyzer knows it; the slicer does not ask yet.
  • Rust and Go are the languages it has been run against in anger. The mechanism is the analyzer’s definition edges, so it applies anywhere those exist, but the depth heuristics were tuned on these two.

What it costs

$ time prod-code slice signature::change
real 0.41     # 0.44 on a second run

Almost all of that is now the slice. A bare prod-code status, which asks a gateway nothing about any workspace, costs 0.02 s on the same machine; the binary starting up on its own is 0.01 s, and the LAN round trip is a third of a millisecond.

That status row read 0.53 s while this post was being written, and the slice above took 0.83 s, for a reason that had nothing to do with either. Every invocation asks a gateway two questions before it sends a query — what does the cluster look like, and which node holds this checkout — and the gateway answered both by probing for the language servers it can run. One of those probes asks npm where its global modules live, which is 213 ms of node starting up. Per question. On every command. It is fixed: the probe runs once at startup and once a minute after that, and a status request answers in 0.2 ms, measured on the node itself, where it used to take 437.

What the slice itself costs is the difference between those rows — nearly all of the 0.41 s. Each edge is a textDocument/definition against an analyzer that already holds the answer in memory. Cold, the workspace load comes first — the same forty-five seconds everything else in this series pays once per worktree.

The saving is the point, and it is worth stating plainly rather than as a percentage. Eighteen thousand characters instead of a hundred and fifty-seven thousand is roughly five thousand tokens instead of thirty-nine thousand. That is not a nicer number. That is the difference between an agent that can hold the task and the code at the same time and one that reads the code, forgets the task, and asks you what it was doing.

Cite this article
Citation
Alexander Panasenko (2026-09-28). The files are not the answer. https://prod.codes/blog/the-files-are-not-the-answer/