Skip to content

Why an AI agent should edit symbols, not strings

A failed edit costs three tool calls: the miss, the re-read, the retry. Empryo's ast_edit targets a function or type by name and changes the syntax tree directly. No search string, no line numbers, nothing to mismatch. It started as my Master's thesis.

Why an AI agent should edit symbols, not strings

You ask an AI agent to change one line. It asks the editor to find a string and replace it. The file uses a tab where the model typed two spaces, so the search finds nothing. The agent re-reads the file, rebuilds the string, and tries again. That is three tool calls and a few thousand tokens for a one-line change.

The model understood the code fine. The edit failed because the model had to reproduce the file's exact bytes, and that is something language models are bad at.

A disclosure first: I build Empryo, solo and unfunded, and this post is about one of its tools. The idea is older than the product. More on that at the end.

What find-and-replace asks of a modelLink to this section

A string edit asks the model to get three things right at once. Missing any of them breaks the edit.

  • Exact bytes. Tabs or spaces, Windows or Unix line endings, a trailing space the model never saw. One mismatch and the search comes back empty.
  • A unique match. return null; appears five times in the file. The tool can't tell which one you meant. The model has to pad the search string with surrounding lines until it is unique, and every extra line is another chance for a typo.
  • Valid syntax afterwards. The replacement lands in the middle of an expression, a brace goes missing, and nobody notices until the typechecker runs.

This is how text editing fails on any real file. It gets worse as the session goes on, because the file on disk keeps drifting away from the copy the model last read.

Name the symbol insteadLink to this section

Empryo's ast_edit tool drops the search string. It works on TypeScript and JavaScript files. You say which symbol you want by kind and name, and the tool changes the parsed syntax tree:

ts
ast_edit({
  path: "src/auth.ts",
  action: "set_body",
  target: "function",
  name: "validateToken",
  newCode: "return verify(token, key);",
})

There is nothing to mismatch here. No whitespace to copy, no line number that can go stale, no ambiguity. The file has one validateToken, and the tool finds it however the file has been reformatted since the model last looked. Empryo's Genome, its live index of every symbol in your project, already knows each symbol's kind and location, so the agent doesn't need an extra read just to find the target.

Small edits become very small. Changing a return type is one operation:

ts
ast_edit({
  path: "src/api.ts",
  action: "set_return_type",
  target: "function",
  name: "fetchUser",
  value: "Promise<User>",
})

Changes with several steps run as one batch. Here the function becomes async, gains a parameter, and its type gets imported. If any step fails, none of them are applied, so the file is never left half-edited:

ts
ast_edit({
  path: "src/api.ts",
  operations: [
    { action: "set_async", target: "function", name: "fetchUser", value: "true" },
    { action: "add_parameter", target: "function", name: "fetchUser", value: "cache: boolean" },
    { action: "add_named_import", value: "./types", newCode: "User" },
  ],
})

The error messages are written for a model to act on. If you ask for a symbol that doesn't exist, the error lists every symbol of that kind in the file. The retry becomes a lookup instead of another guess.

The sixtieth edit works like the firstLink to this section

String editing gets less reliable during a session. The more the agent has changed a file, the less its memory of the file matches what is on disk, and the more searches miss. Addressing by name doesn't have that problem. validateToken is still validateToken after forty edits, a reformat, and a reshuffle of the imports.

An agent that can trust its edits stops re-reading files to check them. That is where most of the savings come from: not the tokens of a single edit, but the checking loop you no longer need.

It started as a thesisLink to this section

The approach predates Empryo. My Master's thesis, *Typed vs Untyped Programming Languages* (with Artur Matusiak, 2022), showed that converting JavaScript to TypeScript could be done entirely through syntax-tree changes with the ts-morph library, with no string manipulation anywhere. ast_edit takes that idea and hands it to an agent. If a program can rewrite code by structure, a model never has to reproduce bytes at all.