Agent memoryopen sourcetechnical debtdesign

Taking on 13,000 lines of someone else’s code

It could not be a dependency and it could not be a fork. I picked the third option, and the price was a set of rules with no exceptions.

Sep 5, 20267 min

The storage and retrieval engine behind the memory service was not written here. It came from an open-source library. But as described in the first post, that library's extraction prompt had to be changed — Korean conversations were being extracted into English.

That is where the choice appears.

Three roads

OptionOur changesUpstream updatesDiffability
Use as a dependencyimpossibleautomaticnot needed
Forkfreecherry-pick requiredblurs quickly
Vendorfreeselectivemaintainable

As a dependency the prompt cannot be touched. As a fork it can, but within months nobody can tell whether a given line was always like that or something we did.

I took the third. Pull the code into the repository wholesale, but keep it in a state where a diff against the original is always possible.

13,000 lines
code brought in
60
files
18
points we modified

The price is discipline

Vendoring is not copying. Copying is easy; keeping it is not. What makes it keepable is following a few rules without exception.

No formatter touches it. Linters, import sorting — all of it goes in the exclude list. Format it once and it is no longer the original, and diffing becomes impossible from that moment. If a commit hook objects, disable the hook and commit. That is a rule, not laziness.

Every changed line carries a comment saying why. When comparing against upstream later, that comment is the only clue. An uncommented change reads to the next person as "I guess the original was like that."

The import is split into three commits. This is the crux.

UpstreamCommit 1Commit 2Commit 3not one line changedpath rewrites onlyour changesand everything after
1 / 4

01The original, verbatim. This commit is the reference point. Mix anything else in and the reference point is gone.

Follow this and you can always diff commit 1 against today to see everything you changed. Mix it and that ability is gone.

What you learn from having to read it

You end up with no choice but to read someone else's code. Things invisible from the outside start showing up.

A class that is async in name only

The library ships both a synchronous and an asynchronous class. Obviously you use the async one.

Then you open it.

Where the async class actually awaits
The name
AsyncMemory
The implementation
await http_client.post(...) # non-blocking I/O
await conn.execute(...) # non-blocking I/O
통과Genuine async means the I/O itself does not block.

Which is the same thing we already do by handing sync code to a thread pool. The only difference is whether you hand over a whole request or one call at a time.

Switching would gain essentially nothing and cost something specific: maintaining our changes twice. Eighteen modifications live on the sync path; all of them would have to be duplicated and kept in step.

And the real bottleneck was elsewhere.

Concurrent request throughput
before
20 req/s
wider pool
46 req/s

All that changed was doubling the connection pool. 2.3×. Going async would still have jammed at the pool — measuring the configuration was faster than changing the architecture.

A feature with no switch

This one still makes me slightly uneasy.

Version 2 of the library contains an undocumented entity linking feature. It extracts entities from memory sentences, embeds them into a separate table, and boosts memories sharing an entity at search time. A reasonable feature.

It is also close to useless for Korean. The model is hardcoded for English, and the check for whether a token is a name is whether its first character is uppercase. Korean has no uppercase, so nothing ever qualifies. Turned on, it costs and does nothing.

It could be fixed — swap the model, rewrite the capitalisation test and the English stopword filter. But that collides head-on with "keep our diff minimal". So it was not fixed; it was written down. The best available move is to tell the next person where the mine is.

Config keys that are silently ignored

Pass a wrong key name to the config object and nothing is raised. It is ignored.

  • The database connection string is not dsn but another name. Get it wrong and it does not fail to connect — it falls back to a default, quietly
  • Point the history store at the wrong path and it drops to a local file database instead of PostgreSQL. No log
  • The graph config from v1 no longer exists in v2, but passing it raises nothing. It is ignored

All of it the same character described in the previous post. Nothing throws.

Debt taken on deliberately

The delete path is not atomic. Removing the vector row, writing the history entry and cleaning up run in sequence on separate connections with no transaction. If the first commits and the rest fail, you get a memory that is gone with no record of its deletion.

The vendored code was not restructured to be atomic. That would explode the diff, which undermines the premise of the whole strategy. Instead there is a compensating layer outside it — backfilling missing history, sweeping rows left orphaned.

It is not clean. But it is deliberate debt: replace the engine and the compensating layer disappears with it. Carrying it until then beats tearing up the vendored code and losing the ability to diff.

What is left

Vendoring is not copying code, it is the discipline of staying diffable. Keeping formatters off and splitting the commits is not fastidiousness; it is the minimum condition for the strategy to work at all. Break it once and what you have is a fork.

Being forced to read someone else's code teaches you things. That the async class is async in name only, that config keys are silently ignored, that a feature hides behind an implicit switch — all of it came from reading. As a dependency all three would have stayed unknown until they became incidents.

What you cannot fix, document. Entity linking is useless for Korean and we cannot fix it. What we could do is write down the condition that activates it and what happens when it does. If you cannot clear the mine, at least mark it.