Building a .NET Dev Agent, Part 3: Make It Remember You
On Monday morning I asked my dev agent what I had been doing on Friday. It ran git status, put my own branch name on the screen, and said: "I can't see Friday's history, only the current state."
That is an honest answer, and the answer of a colleague who forgets everything every night. It was also the most it could say, because everything else the agent knew about my repository, I had typed into its system prompt myself: three projects, which one matters, which warning is noise. That paragraph was memory, written by hand, in the one place the agent cannot update.
This post replaces both with a memory the agent writes itself, using Claude's memory tool, and measures the parts a demo leaves out: what the agent chooses to save, what that costs every morning, and what happens when a memory stops being true. Everything runs on Claude Haiku 5.5, against the same repository and the same voice agent as Part 2.
Where Part 2 left the agent
Building a .NET Dev Agent, Part 2: Give It a Voice put a voice on Part 1's agent: push-to-talk, a local Whisper for speech-to-text, a local Piper voice for the replies, and a show_on_screen tool for anything too long to listen to. Underneath is a manual tool-use loop with four tools: git_status, dotnet_build, dotnet_test and show_on_screen.
Within a session it already remembers. Every turn goes into a List<MessageParam> and is sent back with the next request, so it forgets nothing until the process exits, and then it forgets everything. Part 3 changes three things:
- The system prompt loses the paragraph about my repository. The agent learns those facts instead, and keeps them in a memory directory, one per repository.
- A fifth tool,
read_file. Part 2's live session ended on "I can't read the code from here" and promised the missing tool for this part. - One bug fix, in code the companion repository added after Part 2. Measuring this post found it, and it also produced the best wrong memory of the week.
The test: a Friday and a Monday
Memory is not a speech problem, so every measurement here uses typed text instead of recordings: the same words on every run, with no transcription errors in the way. The repository is Part 2's, with the same half-finished rename: ChangedFile.Patch became Diff, one call site in ReviewOrchestrator was left behind, and the build fails with one CS1061, the compiler's error for a member that does not exist.
On "Friday" the agent gets six turns:
- "Why is the build red?"
- "The core project is the one I care about. The CLI is just a wrapper."
- "And CS1591 is noise, ignore it. Nullable warnings are real."
- "What did I change?"
- "I'm renaming Patch to Diff in ChangedFile. I'll finish ReviewOrchestrator on Monday."
- "Run the tests."
On "Monday", in a new process, three questions:
- "What was I doing on Friday?"
- "Build the project I care about."
- "What's left to finish the rename?"
I wrote down what counts as right before running anything. The first answer has to say what was left, the ReviewOrchestrator call site, out loud or on screen. The second has to build the Core project without asking which one. The third has to name the call site, backed by a tool the agent ran that morning rather than by a note. Every configuration ran three times on Haiku 5.5 at low effort, as in Part 2, and four of them ran a fourth time with a cold cache, for the latency further down; those four count four Mondays in the table. Each configuration's Mondays all start from the same Friday run, so the table varies Monday, not what Friday chose to save; the Friday variation shows up in the next sections.
Five ways to remember, measured
The usual answers to "how does an agent remember?" come in four shapes, and the last one comes in two versions here:
- Keep the history. Save Friday's whole conversation and send it again on Monday.
- Keep a window. Save only the last few turns: here, Friday's last two.
- Summarize. At the end of Friday, one extra call writes notes for next time, and Monday starts from the notes.
- Store facts. During the session the agent decides what to save, with Claude's memory tool, and the next session reads it back, either on demand or loaded into the prompt in advance.
And, as the baseline, the same agent with no memory at all. This is Monday for each one, assembled from the runs' captures:
| Monday starts with | First request | Says what was left | Builds Core without asking | API calls, three questions |
|---|---|---|---|---|
| No memory | 1,178 tokens | 0 of 4 | 0 of 4 | 7–9 |
| All of Friday's history | 3,362 tokens | 4 of 4 | 4 of 4 | 5–7 |
| Friday's last two turns | 1,408 tokens | 0 of 3 | 1 of 3 | 5–7 |
| A summary of Friday | 1,407 tokens | 1 of 3 | 3 of 3 | 8 |
| Memory, read on demand | 2,932 tokens | 4 of 4 | 4 of 4 | 9–12 |
| Memory, preloaded | 3,210 tokens | 4 of 4 | 4 of 4 | 8–11 |
No morning cost more than a quarter of a cent on Haiku, so price is not what separates them. Behavior is.
No memory is this post's agent with memory switched off: no paragraph about my repository in the prompt, and read_file included. It did what you would expect: asked to build "the project I care about", it asked which one in one of four runs and built the whole solution in the other three.
The whole history gave the best answers, and it is a trap. (The cold run opened with "I don't have Friday's conversation", then named the call site from that very conversation on screen.) Six turns of Friday weigh 2,184 tokens, and nothing ever leaves. Next Friday the agent carries two Fridays, in a month it carries a month, and that includes every build output it ever read, presented to the model as things that happened in this conversation. You cannot trim it the obvious way, either. That is the next section.
The window lost twice. It kept Friday's newest turns, "I'll finish ReviewOrchestrator on Monday" and "Run the tests", and still never said what was left: two runs answered "I can't see Friday". The date lived in Friday's first message, which the window dropped, so nothing told the model those turns were Friday's. And the facts Monday needed, which project to build and which warning to ignore, were turns two and three: two of three runs asked me which project I meant. A window drops what is oldest, not what is least important.
The summary had the answer and mostly did not use it. The notes said, in the model's own Friday words, that I would "finish the ReviewOrchestrator call site on Monday". Two of three runs ran git_status and answered from that alone. The third got it right, after opening with "I can't see Friday's activity from here" while those notes sat in its system prompt. Why the notes lost to the tool, when the preloaded memory below did not, I can't separate: the memory came with its own rules and with the memory tool's instruction, and the notes came with neither.
Stored facts got both questions right in every run, in both versions. What separates the two is further down. First, the window.
The sliding window is a 400 now
Haiku 5.5 can think before it answers, and every thinking block comes back with a signature, a seal the API checks when you send the block back. On current Claude models the seal covers the conversation that produced the block: everything that came before it, which the API calls the block's prefix. Cut the oldest turns off the front of a history and send it again, and each thinking block left in it now follows a conversation that never happened.
My first window kept the thinking blocks, and on my account the request went through. The check is enforced by default only for accounts created on or after August 31, 2026; older accounts get it only when a request asks for it. So I sent the same request again, asking for it, with the thinking-binding-controls-2026-08-01 beta header and prefix_mismatch_behavior set to "error". The answer was an HTTP 400, invalid_request_error, with this message:
messages.3.content.2: Invalid `signature` in `thinking` block. The block is bound to a different conversation. Remove the block, or set `thinking.block_binding.prefix_mismatch_behavior` to "drop_block". Content that preceded this block when it was created is missing from this request, starting at `messages.0`. A newer account gets that by default. The window in the table strips the thinking blocks from the turns it keeps, and the API accepts that. The other ways out are to let the API drop the stale blocks (drop_block, which the error itself suggests), to summarize and start a conversation that replays nothing, or to let the server clear old turns with context editing or compaction. Under all of them is one rule: a history that carries thinking blocks can only grow at the end. That rule is also why the date moved out of the agent's system prompt and into the first message of each session. A system prompt that says "Monday" is not a prefix of one that said "Friday".
What it chose to remember
This is the memory file the agent wrote on the Friday run that every memory configuration in the table started its Mondays from, as the store saved it:
Project memory (ClaudeReviewBot), updated 2026-11-06 - Developer cares most about src/ClaudeReviewBot.Core. The CLI project is just a wrapper; lower priority. - Branch rename-patch-to-diff: renaming ChangedFile.Patch to Diff (uncommitted, 4 files). - Open build error: src/ClaudeReviewBot.Core/Review/ReviewOrchestrator.cs line 111 still uses file.Patch. Developer will finish this himself on Monday (2026-11-09). Don't fix it unless asked. - Developer says CS1591 is noise and nullable warnings are real; last Core build showed 0 warnings. Most of it is right: which project matters, the convention about warnings, the plan for Monday with the full path. Some of it is not. The open build error at line 111, the four uncommitted files and "last Core build showed 0 warnings" are state, which the tools already know and which is out of date by Monday.
The prompt told it not to. My first version of the rules said "never save what a tool can tell you: build or test results, changed files, error counts", and both Friday runs saved the build error. The second version named it outright, "whether the build passes, errors and their line numbers", and both runs saved it again. That is four memories out of four.
Saved state would be harmless if it were only read as history. It was not. With the first version's memory, all three on-demand Monday runs put some version of "The build is red because ReviewOrchestrator still uses the old name" on the screen under their spoken answer, without running a build. It happened to be true, because nobody had touched the repository over the weekend. After a weekend where somebody fixed it, it would have been a confident, wrong line.
So I stopped trying to keep state out of memory through the prompt, and told the agent to check a memory on its way back in. That rule is in every memory configuration of the table above, and it is not a guarantee: on Monday the agent opened ReviewOrchestrator.cs to check the note before repeating it in 2 of the 4 preloaded runs and 1 of the 4 on-demand runs. The others repeated the note about line 111 straight from memory. Where the rule earns its place is the section after next.
The memory store
Claude's memory tool (memory_20250818) runs on your side. The model sends commands (view, create, str_replace, insert, delete, rename) against paths under a virtual /memories directory, and your code carries them out wherever you like. Adding it to the agent is one entry in the tool list, new MemoryTool20250818() in the C# SDK, with no schema to write.
Everything else is the handler. Every path and every line of text it receives comes from the model, so it gets the treatment you would give user input. This is the top of MemoryStore.cs:
using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; /// <summary> /// The storage behind Claude's memory tool (memory_20250818). The model asks for file /// operations on a virtual /memories directory; this class runs them on a real folder, one per /// repository, outside the repository so a memory is never committed. Every path and every /// line of text the model sends is untrusted input. /// </summary> sealed partial class MemoryStore { public const string Root = "/memories"; // Memory is loaded into the first request of every session, so its size is paid every // morning. These caps keep it to roughly 1,500 tokens; past them the model is told to // merge or delete entries instead of appending more. const int MaxFileChars = 4_000; const int MaxTotalChars = 6_000; static readonly StringComparison PathComparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; readonly string directory; // full path, ending in a separator public MemoryStore(string directory) { Directory.CreateDirectory(directory); this.directory = DevTools.WithSeparator(Path.GetFullPath(directory)); // A crash between writing a temp file and moving it leaves the temp file behind. // It is hidden, so nothing else would ever count or remove it. foreach (string orphan in Directory.EnumerateFiles(this.directory, ".tmp-*")) { try { File.Delete(orphan); } catch (IOException) { // Not an orphan: another session's write is still in flight, and it moves its own file. } } } /// <summary>One folder per repository, under the user's local application data.</summary> public static MemoryStore ForRepository(string repo) { string full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(repo)); string key = PathComparison == StringComparison.OrdinalIgnoreCase ? full.ToLowerInvariant() : full; string hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(key)))[..8]; string name = Path.GetFileName(full) is { Length: > 0 } folder ? folder : "repo"; return new(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VoiceDevAgent", "memories", $"{name}-{hash}")); } /// <summary>Runs one memory command. Failures go back to the model as text, never as exceptions.</summary> public (string Content, bool IsError) Run(IReadOnlyDictionary<string, JsonElement> input) { try { using FileStream gate = Lock(); string result = Text(input, "command") switch { "view" => View(Text(input, "path"), input.GetValueOrDefault("view_range")), "create" => Create(Text(input, "path"), Text(input, "file_text")), "str_replace" => Replace(Text(input, "path"), Text(input, "old_str"), OptionalText(input, "new_str")), "insert" => Insert(Text(input, "path"), Number(input, "insert_line"), Text(input, "insert_text")), "delete" => Delete(Text(input, "path")), "rename" => Rename(Text(input, "old_path"), Text(input, "new_path")), string other => throw new MemoryException($"Error: unknown command {other}"), }; return (result, false); } catch (MemoryException ex) { return (ex.Message, true); } } /// <summary>Every memory file, for the system prompt at the start of a session. Empty when there are none.</summary> public string Snapshot() { StringBuilder all = new(); foreach (string file in Files().Order(StringComparer.Ordinal)) all.Append($"<memory path=\"{ToVirtual(file)}\">\n{File.ReadAllText(file).TrimEnd()}\n</memory>\n"); return all.ToString(); } The six commands return the strings the memory tool's documentation specifies, because the model reads them (only the snippet of the edited file that the docs add after a str_replace is left out); they are in the repository. The part worth reading is where paths come in and files go out:
/// <summary> /// Maps "/memories/a/b.md" to a real path inside the folder, or refuses. Traversal is refused /// by its parts, before the OS gets to interpret them: "..", hidden names, backslashes, /// drive letters and percent-escapes never reach Path.GetFullPath. /// </summary> string Resolve(string path) { if (path != Root && !path.StartsWith(Root + "/", StringComparison.Ordinal)) throw new MemoryException($"Error: {path} is outside {Root}"); string relative = path[Root.Length..]; foreach (string part in relative.Split('/', StringSplitOptions.RemoveEmptyEntries)) { if (part.StartsWith('.') || part.IndexOfAny(['\\', ':', '%', '\0']) >= 0) throw new MemoryException($"Error: {path} is not an allowed path"); } string full = Path.GetFullPath(Path.Combine(directory, relative.TrimStart('/'))); string withSeparator = DevTools.WithSeparator(full); if (!withSeparator.StartsWith(directory, PathComparison)) throw new MemoryException($"Error: {path} is outside {Root}"); return Directory.Exists(full) ? withSeparator : full; } string ExistingFile(string path, string missing) { string full = Resolve(path); if (!File.Exists(full)) throw new MemoryException(missing); return full; } /// <summary>Checks the caps and the secret filter, then replaces the file atomically.</summary> void Write(string path, string full, string text) { if (LooksLikeSecret().IsMatch(text)) throw new MemoryException($"Not written: {path} would contain something shaped like a credential. Never store secrets in memory."); if (text.Length > MaxFileChars) throw new MemoryException($"Not written: {path} would be {text.Length:N0} characters, over the {MaxFileChars:N0} limit. Merge or delete entries that are no longer true, then try again."); long others = Files().Where(f => !string.Equals(f, full, PathComparison)).Sum(f => (long)File.ReadAllText(f).Length); if (others + text.Length > MaxTotalChars) throw new MemoryException($"Not written: memory would total {others + text.Length:N0} characters, over the {MaxTotalChars:N0} limit. Merge or delete entries that are no longer true, then try again."); // Write beside the target and move over it: a crash mid-write leaves the old file, not half a new one. Directory.CreateDirectory(Path.GetDirectoryName(full)!); string temp = Path.Combine(directory, $".tmp-{Guid.NewGuid():N}"); using (FileStream file = new(temp, FileMode.CreateNew, FileAccess.Write)) { file.Write(Encoding.UTF8.GetBytes(text)); file.Flush(flushToDisk: true); // on disk before the move, so a power cut can't leave an empty file } File.Move(temp, full, overwrite: true); } /// <summary> /// Two sessions on the same repository (one spoken, one typed) can edit the same file, and /// str_replace is read-modify-write. Each command holds an exclusive lock file while it runs, /// so no command sees half of another's write. It does not merge the two sessions: each model /// writes from what it last read, and a create replaces whatever the other saved since. /// </summary> FileStream Lock() { string path = Path.Combine(directory, ".lock"); for (int attempt = 0; ; attempt++) { try { return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } catch (DirectoryNotFoundException) when (attempt < 40) { // The folder was deleted mid-session to make the agent forget: start an empty // store instead of reporting the memory as busy. Directory.CreateDirectory(directory); } catch (IOException) when (attempt < 40) { Thread.Sleep(50); } catch (IOException) { throw new MemoryException("Error: memory is busy in another session; try again"); } } } A few decisions in there:
- The memory lives outside the repository, under the user's local application data, one folder per repository path. It holds what you said out loud about your project, and that should never end up in a commit.
- Traversal is refused by its parts.
.., hidden names, backslashes, drive letters and%-escapes are rejected beforePath.GetFullPathever sees them, and the resolved path is checked against the root anyway. - Caps that say what to do. A write that would push a file past 4,000 characters, or the whole store past 6,000, is refused with a message telling the model to merge or delete entries. The memory is loaded every morning, so its size is a cost that repeats.
- Atomic writes and a lock file. Two sessions on the same repository, one spoken and one typed, can both
str_replacethe same file. The lock keeps each command whole, but it does not merge the sessions: each model writes from what it last read, so acreatefrom one replaces whatever the other saved since. Deleting the folder mid-session starts an empty store. - Text shaped like the usual credentials is refused, whatever the model decides: API keys and tokens, AWS access key IDs and Google API keys, JWTs, PEM keys and the password in a connection string. It is a denylist, so it catches the common shapes, not every secret.
Errors go back to the model as text, never as exceptions, and that mattered more than I expected. The first version threw on a view_range of [1.5, 10] and took the whole process down; a review before publishing found it, and now every malformed input is an error result. And seven of the 52 memory commands in these runs were str_replace calls carrying old_path, the field rename uses, instead of path. Each one got "Error: missing path" back as a tool result, and the session carried on instead of crashing.
Loading memory without paying for it twice
The memory tool is not free to carry. I measured the request one piece at a time with the token-counting endpoint:
| The request contains | Input tokens |
|---|---|
| Part 2's prompt and five tools | 1,151 |
| + the memory tool | 2,589 |
| + the memory rules | 2,905 |
| + Friday's memory file, preloaded | 3,183 |
The tool's definition, and the instruction the API attaches to it, weigh 1,438 tokens. The memory itself weighs 278. The instruction begins "IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE DOING ANYTHING ELSE", and the model takes it literally. With memory read on demand, the first call of 6 of the 7 Monday runs was view /memories and nothing else; the second read the file alongside git_status. In a voice loop, that is one extra round trip, 1.1 to 2.5 seconds here, before the first word.
Source:https://dev.to/jgomezdev/building-a-net-dev-agent-part-3-make-it-remember-you-293e


