DEV Community

Cover image for Building a .NET Dev Agent, Part 3: Make It Remember You
Juan Gómez
Juan Gómez

Posted on

Building a .NET Dev Agent, Part 3: Make It Remember You

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.

A robot head facing a speech bubble that holds only a question mark, a terminal with a branch icon behind it, and a hand-written note pinned beside it, out of its reach behind a padlock.

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:

  1. "Why is the build red?"
  2. "The core project is the one I care about. The CLI is just a wrapper."
  3. "And CS1591 is noise, ignore it. Nullable warnings are real."
  4. "What did I change?"
  5. "I'm renaming Patch to Diff in ChangedFile. I'll finish ReviewOrchestrator on Monday."
  6. "Run the tests."

On "Monday", in a new process, three questions:

  1. "What was I doing on Friday?"
  2. "Build the project I care about."
  3. "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.

Five lanes from a calendar to the agent: the whole conversation carried along, the old turns cut off with scissors, the turns squeezed through a funnel into one note, a note the agent files in a drawer, and a note set in front of it before it starts.

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`.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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();
    }
Enter fullscreen mode Exit fullscreen mode

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");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 before Path.GetFullPath ever 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_replace the same file. The lock keeps each command whole, but it does not merge the sessions: each model writes from what it last read, so a create from 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.

The first request of a morning as one bar: a short segment labeled The Agent's Tools and Prompt, a much wider one labeled Memory Tool Definition and Instructions, and a thin one for the memory itself, under a moon and a sun with a broken link between them.

So the agent loads the memory itself, when the session starts, into a second block of the system prompt, and says so: "You do not need to view it again." Preloaded, no run viewed it, 0 of 7. That saved less time than it sounds: across the seven runs each, turn one averaged 5.6 seconds preloaded against 6.1 on demand, and the ranges overlap, 3.3 to 8.1 against 4.8 to 7.7. With the second version of the rules, the two preloaded runs that read the code before answering took 6.1 and 6.9 seconds. The tool stays, for writing.

internal VoiceAgent(AnthropicClient client, string repo, MemoryStore? memory, DateOnly today, AgentSetup setup)
{
    this.client = client;
    this.repo = repo;
    this.memory = memory;
    this.setup = setup;
    tools = memory is null ? DevToolDefinitions : [.. DevToolDefinitions, new MemoryTool20250818()];

    // The system prompt is built once and frozen for the whole session. Writes to memory
    // during the session go to disk and show up in the NEXT session's prompt. Changing
    // the system prompt mid-session would break the prompt cache, and on Haiku 5.5 it
    // also invalidates every thinking block already in the history.
    string prompt = BasePrompt;
    if (memory is not null) prompt += MemoryRules;
    if (memory is not null && setup.VerifyRule) prompt += VerifyRule;

    string context = "";
    if (memory is not null && setup.PreloadMemory)
    {
        string snapshot = memory.Snapshot();
        context = snapshot.Length == 0
            ? "Your memory directory is empty. This is the first session in this repository."
            : "Your memory, loaded when this session started. You do not need to view it again:\n" + snapshot;
    }
    if (setup.Notes is not null) context = "Notes from your previous session:\n" + setup.Notes;

    system = context.Length == 0
        ? [new() { Text = prompt, CacheControl = new CacheControlEphemeral() }]
        : [new() { Text = prompt }, new() { Text = context, CacheControl = new CacheControlEphemeral() }];

    // The date goes in the session's first message, not in the system prompt, so a
    // history carried from an earlier day is still a prefix of the next request.
    sessionNote = $"[Session started {today.ToString("dddd, MMMM d, yyyy", CultureInfo.InvariantCulture)}.]\n";

    if (setup.History is not null) Messages.AddRange(setup.History);
}
Enter fullscreen mode Exit fullscreen mode

The system prompt is built once and frozen for the session. A memory the agent writes mid-session goes to disk and appears in the next session's prompt, not in this one, for two reasons. Changing the system prompt mid-session throws away the prompt cache, and on Haiku 5.5 it also invalidates every thinking block in the history: the same check as the 400 above, tripped by system instead of messages. The agent does not need the fresh copy anyway: what it just wrote is already in the conversation.

The cache does its job within a session. In one preloaded Monday, 29,833 of the 31,798 input tokens were read from the cache, at a tenth of the input price. Between sessions it does nothing: a cache entry lives five minutes by default, an hour if you pay twice the price to write it, and nobody talks to their build agent an hour after leaving for the weekend. The first request of every morning writes the whole prompt to the cache again, at 1.25 times the input price. Measured cold, one run per configuration, that first request took 1.6 to 3.4 seconds, against 1.1 to 2.3 with the cache warm from the run before. At these prompt sizes most of that spread is the API's own variation; the whole history, the largest prompt, was the slowest cold.

These are the rules the agent gets with its memory:

const string MemoryRules = """

    You have a memory directory that survives between sessions, one per repository.
    - Save what the tools cannot tell you: which project matters and why, conventions,
      what the developer is in the middle of and plans to do next, decisions and why.
    - Never save what a tool can tell you: whether the build passes, errors and their
      line numbers, test results, the list of changed files. All of it is out of date by
      the next session; run the tool instead.
    - Write file and project paths in full, relative to the repository root.
    - Keep one file, /memories/project.md, under 40 lines. Date what you write about work
      in progress. Update or remove entries that stop being true; do not append duplicates.
    - Save quietly: do not mention memory in your spoken reply unless asked.
    """;

const string VerifyRule = """

    A memory is a note from an earlier session, not a fact about now. Before you act on
    one or repeat it as current, check it with a tool. When a tool contradicts a memory,
    trust the tool, fix the memory, and say in a few words what changed.
    """;
Enter fullscreen mode Exit fullscreen mode

When a memory is wrong

The last rule is the one that earns its place. To test it, I gave the agent the memory from the other Friday run with these rules, and changed the repository underneath it, three ways. Each scenario ran twice with the rule and twice without:

  • S1: I finished the rename over the weekend and committed it. Question: "What's left to finish the rename?"
  • S2: The Core project moved from src/ClaudeReviewBot.Core to src/ReviewBot.Core. Question: "Build the project I care about."
  • S3: The branch was merged into main and deleted. Question: "What was I doing on Friday?"
Scenario With the rule Without it
S1, work finished 2 of 2 checked and said nothing was left 2 of 2 the same
S2, project moved 2 of 2 rebuilt and fixed the path in memory 2 of 2 rebuilt, 1 fixed the memory
S3, branch merged 2 of 2 checked the code and said the work was finished 0 of 2 checked; both led with Friday's red build

S1 did not need the rule, because the question itself sends the agent to the build. S2 failed loudly, which is the friendly way to fail: the stored path did not exist, the build tool said so, and every run fell back to building the whole solution. S3 is the dangerous one, because nothing fails at all. Without the rule, the spoken answer was:

"On Friday you were renaming ChangedFile.Patch to Diff, and the build was red at line 111 of the review orchestrator. The rest is on screen."

With it:

"On Friday you were renaming ChangedFile.Patch to Diff. The work looks finished: the tree is clean on main, one commit ahead, and the line that was failing now uses Diff."

To be fair to the runs without the rule, both did notice something on screen, further down. One wrote: "Git now shows you on main with a clean tree and one commit ahead, so the rename was probably committed, but I haven't confirmed that." Both runs with the rule read line 111 before answering.

S2 shows the other side of letting the agent edit its own memory. Both runs with the rule corrected the path, and both added a new rule in the same breath, generalized from one failure. These are the two lines it wrote in one of them, as the store saved them:

- Core project is the one the developer cares about: src/ReviewBot.Core (not ClaudeReviewBot.Core; path verified 2026-11-09). The CLI is only a thin wrapper around it.
- Build the whole solution with no project argument; the per-csproj path I guessed did not exist.
Enter fullscreen mode Exit fullscreen mode

The first line is right. The second is wrong: building the Core project works fine once you pass its new path, and the next line of that same file still points at the old one. A memory the agent corrects is still a memory the agent writes.

Two runs per cell is a small sample. Read the table as "the rule changed the outcome where nothing failed", not as a rate.

Two panels: on the left, the agent reads its note and answers with a red cross without looking at the already-merged branch below; on the right, it checks the branch with a magnifying glass first, corrects the note with a pencil and answers with a green check.

Two things that broke along the way

The agent went quiet. In 16 of 133 measured turns, the model called show_on_screen and then ended its turn with no text at all, with and without the memory tool loaded. On a screen, an answer that is only a list is fine. Through a speaker it is silence, and silence sounds like the agent hung. A line in the prompt, "Always end with something to say", did not stop it; every one of those turns already had it. One line of code in AskAsync makes sure the speaker never goes quiet:

bool shown = false;
string? reply = await RunTurnAsync(text => { shown = true; screen(text); }, ct);
// A turn can end on a tool call with no text at all. Spoken, that is silence, and
// silence sounds like the agent hung. Never return nothing.
if (reply?.Length == 0) reply = shown ? "Details are on screen." : "Okay.";
if (reply is not null) return reply;
Enter fullscreen mode Exit fullscreen mode

It is Part 2's lesson again: a rule that lives only in the prompt holds until the first time it does not.

The repository root had two separators. In my first Friday runs, every per-project build and every read_file came back "outside the repository". The repository sits at R:\, a subst drive that keeps my profile path out of the captures. After Part 2, the companion repository added a path check to dotnet_build that built the root as TrimEndingDirectorySeparator(path) + separator. TrimEndingDirectorySeparator leaves a root alone, so R:\ became R:\\, and no path inside started with that. The fix, with a regression test for a drive root:

internal static string RepoRoot(string repo) => WithSeparator(Path.GetFullPath(repo));

/// <summary>
/// Appends a separator only when one is missing. TrimEndingDirectorySeparator leaves a
/// root alone, so trimming and appending turned a repository at "R:\" into "R:\\", and
/// every path inside it was refused as outside the repository.
/// </summary>
internal static string WithSeparator(string path) =>
    Path.EndsInDirectorySeparator(path) ? path : path + Path.DirectorySeparatorChar;
Enter fullscreen mode Exit fullscreen mode

The fix was a line. The side effect is the interesting part: before I found the bug, the agent had already written this into its memory:

- Repo root is R:\ ; Core lives at src/ClaudeReviewBot.Core. Tools reject per-project paths and the R:\src paths as "outside the repository", so only whole-solution builds work.
Enter fullscreen mode Exit fullscreen mode

That is a careful, accurate note about a bug, and it became false the moment the bug was fixed. Nobody tells the memory. It is scenario S2, reached by accident, and in S2 the agent wrote the same kind of note again.

The missing tool: read_file

Part 2's agent could build the code and could not read it. This is the fifth tool:

using System.Text;
using System.Text.RegularExpressions;

// Part 3's one new development tool. Part 2's live session ended on "I can't read the code
// from here"; this is the missing tool. It only reads: unlike dotnet_build and dotnet_test,
// which run the repository's own code, it executes nothing.
static partial class DevTools
{
    const int MaxReadLines = 200;
    const int MaxReadChars = 16_000;
    const long MaxReadBytes = 1_000_000;

    // Whatever this tool returns is sent to the model's API. A build agent never needs these.
    // It is a denylist, so it is incomplete: anything else in the repository can reach the API.
    static readonly string[] SecretExtensions = [".pfx", ".p12", ".key", ".pem", ".snk", ".env"];
    static readonly string[] SecretNames = ["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "secrets.json"];

    /// <summary>
    /// Up to 200 numbered lines, and 16,000 characters, of one file inside the repository. The
    /// numbers let the model say "line 111", and ask for the next range itself when a file is longer.
    /// </summary>
    internal static (string Content, bool IsError) ReadFile(string repo, string path, int? startLine, int? endLine)
    {
        string full, root = RepoRoot(repo);
        try
        {
            full = Path.GetFullPath(Path.Combine(repo, path));
        }
        catch (ArgumentException)
        {
            return ($"\"{path}\" is not a valid path.", true);
        }

        if (!full.StartsWith(root, PathComparison)) return ($"\"{path}\" is outside the repository.", true);

        string relative = Path.GetRelativePath(root, full).Replace('\\', '/');
        // On NTFS "id_rsa::$DATA" opens id_rsa and ".git::$INDEX_ALLOCATION" opens .git, while
        // every check below compares names. No file a build agent needs has a colon in its path.
        if (relative.Contains(':')) return ($"\"{relative}\" has a colon in it; ask with the plain file name.", true);
        string[] parts = relative.Split('/');
        string name = parts[^1];
        // "GIT~1" is the 8.3 short name Windows can give ".git"; the name checks below would miss it.
        if (parts.Any(part => ShortName().IsMatch(part)))
            return ($"\"{relative}\" uses a short (8.3) name; ask with the full name.", true);
        if (parts.Contains(".git", StringComparer.OrdinalIgnoreCase))
            return ($"\"{relative}\" is inside .git.", true);
        if (name.StartsWith(".env", StringComparison.OrdinalIgnoreCase)
            || SecretExtensions.Contains(Path.GetExtension(name), StringComparer.OrdinalIgnoreCase)
            || SecretNames.Contains(name, StringComparer.OrdinalIgnoreCase))
            return ($"\"{relative}\" may hold secrets and is not read.", true);

        // A link anywhere on the way, a folder symlink or a junction as much as the file
        // itself, can point outside the repository; the checks above only saw names.
        string current = root;
        foreach (string part in parts)
        {
            current = Path.Combine(current, part);
            FileSystemInfo entry = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
            if (entry.LinkTarget is not null) return ($"\"{relative}\" goes through a link, which is not followed.", true);
        }

        FileInfo file = new(full);
        if (!file.Exists) return ($"\"{relative}\" does not exist.", true);
        if (file.Length > MaxReadBytes) return ($"\"{relative}\" is {file.Length:N0} bytes, too large to read.", true);

        string text = File.ReadAllText(full);
        if (text.Contains('\0')) return ($"\"{relative}\" is a binary file.", true);

        string[] lines = text.Replace("\r\n", "\n").TrimEnd('\n').Split('\n');
        int from = Math.Max(1, startLine ?? 1);
        if (from > lines.Length) return ($"\"{relative}\" has {lines.Length} lines.", true);
        int to = Math.Clamp(endLine ?? int.MaxValue, from, Math.Min(lines.Length, from + MaxReadLines - 1));

        // A minified file can hold all of its size on one line, and whatever this returns stays in
        // a history that can only grow at the end. So a read also stops at a character budget.
        StringBuilder body = new();
        int last = from - 1;
        for (int i = from; i <= to; i++)
        {
            string line = lines[i - 1];
            if (body.Length + line.Length > MaxReadChars)
            {
                if (i == from)
                {
                    body.Append($"{i,5}  {line[..MaxReadChars]} [line cut at {MaxReadChars} characters]\n");
                    last = i;
                }
                break;
            }
            body.Append($"{i,5}  {line}\n");
            last = i;
        }
        return ($"{relative}, lines {from}-{last} of {lines.Length}\n{body}", false);
    }

    [GeneratedRegex(@"~\d")]
    private static partial Regex ShortName();
}
Enter fullscreen mode Exit fullscreen mode

It is read-only and stays inside the repository. It refuses .git, .env and *.env files, key files and the usual private-key names, any path that goes through a link or a Windows short name like GIT~1, any path with a colon (on NTFS, id_rsa::$DATA opens id_rsa), binaries, and files over 1 MB. It returns at most 200 numbered lines and 16,000 characters, so the model can say "line 111" and ask for the next range itself, and one minified file cannot flood a history that only grows. Everything it returns is sent to the model's API, which is the reason for the refusals: a voice agent hears "read the config" far more easily than you meant to say it. It is still a denylist, and anything not on it can reach the API, so point the agent at repositories you would be comfortable pasting into a chat.

On Monday it is how the agent checked its own note, when it did: 2 of the 4 preloaded runs opened ReviewOrchestrator.cs around line 111 before repeating what the memory said, and 1 of the 4 on-demand runs. The only other change is in Program.cs, which opens the store when the session starts:

// One memory per repository, outside it, so it is never committed. Delete the folder to
// make the agent forget.
MemoryStore memory = MemoryStore.ForRepository(repo);
Console.WriteLine(memory.Snapshot().Length == 0 ? "No memory yet for this repository." : "Memory loaded for this repository.");
VoiceAgent agent = new(new AnthropicClient(), repo, memory, DateOnly.FromDateTime(DateTime.Now));
Enter fullscreen mode Exit fullscreen mode

What Part 4 needs from this

Part 4 takes the agent off the laptop to run 24/7, and three things in this post assume a laptop:

  • The memory is a local folder keyed by a path. A server needs it in storage that outlives the container, keyed by user and repository.
  • The lock is a lock file. It works for two processes on one machine, and not for two instances behind a load balancer.
  • The cache lives five minutes, or an hour at twice the write price. Being always on does not keep it warm between conversations hours apart; the first request of each one is still paid in full.

When not to give your agent memory

  • When a wrong memory costs more than a question. An agent that asks "which project?" every morning is annoying. One that confidently builds the wrong project is worse, and memory is how you get the second one.
  • When the history fits. For one long day, keeping the whole conversation was the most accurate shape I measured, and the simplest. Memory earns its keep across days.
  • On machines you share or do not control. The memory is what you said about your work, in plain text, outside the repository, where your usual cleanup never looks.
  • When nothing is worth keeping. If every question can be answered by running a tool, a memory only adds a second, older answer.

Key Takeaways

  • Memory is choosing what to keep, not keeping everything. The whole history answered best on day one and grows without limit; a window forgets by age, so the oldest facts went first.
  • On Haiku 5.5, a history can only grow at the end. Trimming it from the front with thinking blocks inside is a 400 for accounts created since August 31, 2026, and for older ones that ask for the check.
  • You cannot prompt state out of a memory, so check it on the way back in. Four of four memories saved the build error against explicit rules. With a "verify before you trust it" rule, the agent checked the code and caught a merged branch 2 of 2 times; without it, both runs led with Friday's red build. The rule is not a guarantee: about half the Monday runs still repeated a note unchecked.
  • Preload the memory instead of letting the model fetch it. The memory tool's own instruction cost an extra round trip before the first answer in 6 of 7 runs; loaded into a frozen system prompt, none. Turn one got only about half a second faster on average, and some of the time saved went to checking the note.
  • The memory tool costs more to describe than to hold. It added 1,438 tokens of tool definition and instruction around a 278-token memory, paid again every morning, because the cache does not survive the night.

The complete code is on GitHub, ready to clone and run: Juxn89/dotnet-voice-dev-agent. Part 3's changes are in its CHANGELOG.md, with the drive-root fix.


Next in the series: Building a .NET Dev Agent, Part 4: Deploying to Azure or AWS for 24/7 Uptime, taking the agent and its memory off the laptop, and what an always-on agent costs when the cache still forgets within the hour.

Top comments (0)