Back to all writing

Field notes

A Memory Tool That Said No Without Saying Why

Hayate Agent's memory store answered a missing source, a wrong kind and an overlong title with the same three words. Four overnight merges make the missing-source refusal go away and make the rest name the field, and a scripted run across three releases shows the difference.

A Memory Tool That Said No Without Saying Why

秋の夜や 拒む言葉が 名を添えて

Autumn night. The words that refuse add a name.

Until late Sunday evening, a model running in Hayate Agent, the harness I build in public (formerly the VanillaCore Agent), could ask to save a lesson to long-term memory and get back three words: "Invalid memory entry". Every entry without a source got that answer, and the tool's description and schema never mentioned a source. To the model, the entry it had to send was simply "an object", with no fields listed. So the model was asked to fill in a form it could not see, and when it got the form wrong, nobody told it which box was empty.

That is worse than a crash. A crash gets noticed. A refused write in the middle of a turn looks like a small hiccup. The model can move on or retry with a guess, and the lesson it meant to keep can be lost. My stance is simple: an error message the model reads is part of the prompt, and "Invalid memory entry" is a bad prompt. Four merges across two repositories, between 21:33 and 22:58 Berlin time, fix exactly this, in plugins #36, core #214, plugins #37 and core #215.

Cover image: Hayate Agent cover on a deep indigo-black background with a small slate-blue brush logo and the words Hayate Agent at the top. Large red Japanese headline メモリは拒んだ 今は項目名を示す, meaning the memory refused, now it shows the field name, above the white English line Memory said no. Now it names the field. A subtle wave pattern runs along the bottom, with github.com/hayate-agent in small slate-blue type.

Three places a memory write can fail

A memory write passes three checkpoints before anything lands on disk. First, the agent runtime validates the tool arguments against the tool's JSON schema, the description of fields and types the model gets with the tool. Then the memory plugin's own code runs. Last, the memory store validates the finished entry before writing it.

Before Sunday night, the first checkpoint had nothing to check. The plugin_memory tool declared its entry parameter as a bare object, so any object passed. The second checkpoint handed the entry straight to the store. The third one knew exactly what it wanted (a key, a title, a text, a source, a valid kind, valid dates) and answered every problem with the entry itself with the same sentence. A missing source, a misspelled kind and an overlong title all produced "Invalid memory entry". Only a few cases outside the entry, such as a missing key, a conflicting rewrite or a full store, had their own messages. Plugins #36 says it in one line: the entry was untyped and slipped past the runtime's validation.

What each merge moved

Plugins #36, merged at 21:33, gives entry a real schema: key, title and text are required, kind is limited to note, lesson, decision or operation, source, links, reviewAfter and expiresAt are optional, and unknown fields are refused. The model now sees the form before it fills it in.

Core #214, merged at 22:29, does two things in the store. A write without a source no longer fails: the source falls back to the conversation's session key, which is the conversation the entry already belongs to. The pull request's own review note addresses the obvious worry, a leak across conversations, by pointing out that reads are restricted to the same session key. And the store's validation now names the field that failed: "Invalid memory entry: kind", "Invalid memory entry: title". The same pull request moves core's pinned plugin revision to #36, so this is the point where the schema and the store fix arrive together.

Plugins #37 and core #215, both merged at 22:58, move the basic checks to the tool boundary itself. The plugin now rejects a missing or blank key, title or text with the field and its limit ("non-blank string, at most 500 characters"), and it reports a missing host conversation context as exactly that. The tool description also gained two sentences about retries: supply a stable key, and retry an unchanged write with the same key, never a new one. The code comment says why the checks moved: validation at the shared tool boundary also covers memory providers that are not the built-in local store.

That choice is the one I would defend in a design review. Making source mandatory in the schema would have been the quick fix, and it would have pushed one more required field onto every model that wants to remember something, for a value the harness already knows. Defaulting it removes a failure instead of explaining it better.

What a scripted run shows

The AI agent that drafts these changelog posts ran the same five probes at three release commits: v0.1.140 (before the night's memory fixes), v0.1.141 (core #214 with plugins #36) and v0.1.142 (core #215 with plugins #37). It used the real memory plugin and the real local memory store, called the tool directly and skipped the runtime's schema check, so the table shows what the plugin and the store do with whatever reaches them. On macOS the store's writer lock needs Linux flock, which the Mac does not have, so the run replaced it with a stand-in that starts the worker without the lock. Core #216 names the same Linux-only lock as the cause of three failures in its own Mac test run.

What reached the memory tool v0.1.140 v0.1.141 v0.1.142
Entry without a source refused: "Invalid memory entry" stored, source = the conversation stored, source = the conversation
Entry with kind "weird" refused: "Invalid memory entry" refused: "Invalid memory entry: kind" refused: "Invalid memory entry: kind"
Key consisting of one space stored stored refused: "Memory put requires entry.key: non-blank string, at most 500 characters"
Call without a host conversation refused: "Invalid memory write key" refused: "Invalid memory write key" refused: "Memory write unavailable: missing or invalid host conversation context"

Two rows are worth a second look. The blank key was accepted by the store at both earlier releases, because the store only checked that a key existed, and a single space exists. And the last row shows the old message pointing at the wrong thing: the key was fine, the conversation context was missing. "Invalid memory write key" points a model at the key, which in this probe was fine.

The new tests agree with the probes. The test core #214 adds fails at v0.1.140 with the bare "Invalid memory entry" and passes at v0.1.141. The end-to-end test core #215 adds fails at v0.1.141 ("Missing expected rejection", because the blank key went through) and passes at v0.1.142. These are controlled local runs, not a running agent. The full-suite counts in the pull requests, 1,198 of 1,198 for #214 and 1,214 of 1,214 for #215, and the independent reviews they describe, are the author's report.

The other half of the same problem

Earlier that evening, at 20:42, core #209 fixed a related failure one checkpoint further out. Some models, the pull request names glm-5.3-flash on the homelab provider, send structured tool arguments as a JSON string instead of a real object or array, and the runtime's validation cannot turn a string into an object, so the call fails with "must be array" or "must be object". #209 adds a small step before validation that parses such a string back into the declared shape, and only when the result actually matches the declared type. Anything else is left alone, so validation still reports the real failure. Its four tests pass at v0.1.142 in the same local setup.

The rest of the night

Core #210 with plugins #35 carries the hashtags from the evening's earlier work into native activity and dispatcher status, and core #212 hands the model up to eight deduplicated earlier user excerpts for a request's tags before it answers, at most 800 characters each. The pull request is explicit that this is relevance, not isolation. Core #211 lets a turn that handles a finished result read the dispatcher status, which before failed with a revoked-authority error. Core #213 replaces process-ID-only lock ownership for the harness home and sessions with SQLite exclusive transactions. The pull request reports a test on real cluster storage in which the lock of a killed owner was taken over and eight competing recoveries ended with exactly one owner, and it says a security review is pending and no fleet rollout is authorized.

In the morning, core #216 and plugins #38 finish the public rename: at v0.1.143 the package and the command are called hayate-agent, while internal registry paths and stored state markers keep their old names. The pull request reports that the sequential rollout of the new image is tracked separately.

What merged and what shipped

All times are Europe/Berlin. The window runs from 20:00 on Sunday, 1 November, to 08:00 on Monday, 2 November 2026, and covers the three public repositories the harness organization listed when checked: core, deployment-base and plugins. Core merged eight pull requests, #209 to #216, between 20:42 and 06:31. Plugins merged four, #35 to #38, and deployment-base merged nothing. Core published eight release tags, v0.1.136 to v0.1.143, and each one sits on the merge commit of one of those pull requests: the memory fixes are first in v0.1.141 (22:33) and v0.1.142 (23:05), the argument fix in v0.1.136 (20:49). Plugins and deployment-base published no releases. A release is an image, not a running agent. Core #215 itself claims no live activation, and this post makes no claim about which installation runs any of it.

Errors are instructions

The night's memory work changes no feature anyone will notice. It makes one refusal disappear and turns the others into sentences a model can act on: which field, what limit, what is actually missing. For an agent, the error text is often the only explanation it gets. A vague one leaves it to guess.

Sources used