Construye un agente de desarrollo .NET, Parte 3: haz que te recuerde

El lunes por la mañana le pregunté a mi agente de desarrollo qué había estado haciendo el viernes. Ejecutó git status, puso en pantalla el nombre de mi propia rama y me dijo: "I can't see Friday's history, only the current state." No podía ver el historial del viernes, solo el estado actual.

Es una respuesta honesta, la de un compañero que lo olvida todo cada noche. Y no podía decir mucho más, porque todo lo demás que el agente sabía de mi repositorio se lo había escrito yo en el system prompt: tres proyectos, cuál importa, qué advertencia es ruido. Ese párrafo era memoria escrita a mano, en el único lugar que el agente no puede actualizar.

Un robot frente a un globo de diálogo que solo tiene un signo de interrogación, con una terminal y el ícono de una rama detrás, y una nota escrita a mano clavada a su lado, fuera de su alcance, con un candado.

Este post cambia las dos cosas por una memoria que escribe el propio agente con la herramienta de memoria de Claude, y mide lo que una demo no muestra: qué decide guardar, cuánto cuesta cargarlo cada mañana y qué pasa cuando un recuerdo deja de ser cierto. Todo corre sobre Claude Haiku 5.5, con el mismo repositorio y el mismo agente de voz de la Parte 2.

Dónde dejó la Parte 2 al agente

Construye un agente de desarrollo .NET, Parte 2: dale voz le dio voz al agente de la Parte 1: se pulsa una tecla para hablar, un Whisper local transcribe, una voz local de Piper lee las respuestas y una herramienta show_on_screen muestra en pantalla lo que es demasiado largo para escucharlo. Por debajo hay un ciclo de tool_use escrito a mano, con cuatro herramientas: git_status, dotnet_build, dotnet_test y show_on_screen.

Dentro de una sesión ya recuerda. Cada turno se guarda en una List<MessageParam> y se reenvía con la petición siguiente, así que no olvida nada hasta que el proceso termina, y entonces lo olvida todo. La Parte 3 cambia tres cosas:

  • El system prompt pierde el párrafo sobre mi repositorio. El agente aprende esos datos y los guarda en un directorio de memoria, uno por repositorio.
  • Una quinta herramienta, read_file. La sesión real de la Parte 2 terminó en "I can't read the code from here", y prometió esa herramienta para esta parte.
  • La corrección de un bug en código que el repositorio compañero agregó después de la Parte 2. Apareció al medir este post y, de paso, produjo el mejor recuerdo equivocado de la semana.

La prueba: un viernes y un lunes

La memoria no es un problema de voz, así que todas las mediciones usan texto escrito en lugar de grabaciones: las mismas palabras en cada corrida, sin errores de transcripción de por medio. El repositorio es el de la Parte 2, con el mismo renombrado a medias: ChangedFile.Patch pasó a llamarse Diff, quedó pendiente un uso en ReviewOrchestrator y el build falla con un CS1061, el error del compilador cuando un miembro no existe.

El "viernes", el agente recibe seis turnos:

  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."

Es decir: por qué está rojo el build, que el proyecto Core es el que importa, que CS1591 es ruido pero las advertencias de nulabilidad no, qué cambié, que ReviewOrchestrator lo termino el lunes, y que corra los tests. El "lunes", en un proceso nuevo, tres preguntas:

  1. "What was I doing on Friday?" (¿qué estaba haciendo el viernes?)
  2. "Build the project I care about." (compila el proyecto que me importa)
  3. "What's left to finish the rename?" (¿qué falta para terminar el renombrado?)

Antes de correr nada dejé escrito qué cuenta como acierto. La primera respuesta tiene que decir qué quedó pendiente, el uso en ReviewOrchestrator, en voz alta o en pantalla. La segunda tiene que compilar el proyecto Core sin preguntar cuál. La tercera tiene que nombrar ese uso, respaldado por una herramienta que el agente ejecutó esa mañana y no por una nota. Cada configuración corrió tres veces con Haiku 5.5 y esfuerzo low, igual que en la Parte 2, y cuatro de ellas corrieron una cuarta vez con la caché fría, para medir la latencia más abajo; en la tabla, esas cuatro cuentan cuatro lunes. Todos los lunes de una misma configuración parten del mismo viernes, así que la tabla compara los lunes, no lo que el viernes decidió guardar; esa variación aparece en las secciones siguientes.

Cinco formas de recordar, medidas

Las respuestas habituales a "¿cómo recuerda un agente?" tienen cuatro formas, y la última aparece aquí en dos versiones:

  • Guardar el historial. Se guarda la conversación entera del viernes y se reenvía el lunes.
  • Guardar una ventana. Solo los últimos turnos; aquí, los dos últimos del viernes.
  • Resumir. Al cerrar el viernes, una llamada extra escribe notas para la próxima vez, y el lunes arranca con ellas.
  • Guardar hechos. Durante la sesión, el agente decide qué guardar con la herramienta de memoria de Claude, y la sesión siguiente lo lee: bajo demanda, o cargado de antemano en el prompt.

Y, como punto de partida, el mismo agente sin ninguna memoria. Así fue el lunes con cada una, según las capturas de las corridas:

El lunes arranca con Primera petición Dice qué quedó pendiente Compila Core sin preguntar Llamadas a la API, tres preguntas
Sin memoria 1.178 tokens 0 de 4 0 de 4 7–9
Todo el historial del viernes 3.362 tokens 4 de 4 4 de 4 5–7
Los dos últimos turnos del viernes 1.408 tokens 0 de 3 1 de 3 5–7
Un resumen del viernes 1.407 tokens 1 de 3 3 de 3 8
Memoria, leída bajo demanda 2.932 tokens 4 de 4 4 de 4 9–12
Memoria, precargada 3.210 tokens 4 de 4 4 de 4 8–11

Ninguna mañana costó más de un cuarto de centavo con Haiku, así que no es el precio lo que las diferencia, sino lo que hacen.

Cinco carriles del calendario al agente: la conversación entera, los turnos viejos cortados con tijeras, los turnos comprimidos en una sola nota con un embudo, una nota que el agente guarda en un cajón y otra que ya tiene delante antes de empezar.

Sin memoria es el agente de este post con la memoria apagada: sin el párrafo sobre mi repositorio en el prompt y con read_file. Pasó lo esperable: cuando le pedí compilar "el proyecto que me importa", en una de cuatro corridas preguntó cuál y en las otras tres compiló la solución entera.

El historial completo dio las mejores respuestas, y es una trampa. (La corrida en frío empezó diciendo "I don't have Friday's conversation", es decir, que no tenía la conversación del viernes, y luego en pantalla nombró lo pendiente sacándolo de esa misma conversación.) Seis turnos del viernes pesan 2.184 tokens y de ahí no sale nada nunca. El viernes siguiente el agente carga dos viernes, al mes carga un mes entero, incluida cada salida de build que leyó, que el modelo recibe como cosas que pasaron en esta misma conversación. Tampoco se puede recortar por el camino obvio; de eso trata la sección siguiente.

La ventana perdió dos veces. Conservó los últimos turnos del viernes, "lo termino el lunes" y "corre los tests", y aun así nunca dijo qué quedó pendiente: dos corridas respondieron "I can't see Friday". La fecha estaba en el primer mensaje del viernes, que la ventana descartó, así que nada le indicaba al modelo que esos turnos eran del viernes. Y lo que el lunes necesitaba, qué proyecto compilar y qué advertencia ignorar, estaba en los turnos dos y tres: en dos de las tres corridas me preguntó a qué proyecto me refería. Una ventana descarta lo más antiguo, no lo menos importante.

El resumen tenía la respuesta y casi nunca la usó. Las notas decían, con las palabras del propio modelo el viernes, que yo iba a "finish the ReviewOrchestrator call site on Monday". Dos de tres corridas ejecutaron git_status y respondieron solo con eso. La tercera acertó, después de empezar con "I can't see Friday's activity from here" con esas notas en su system prompt. Por qué las notas perdieron contra la herramienta y la memoria precargada de más abajo no, no lo puedo separar: la memoria venía con sus propias reglas y con la instrucción de la herramienta de memoria, y las notas no traían ninguna de las dos.

Los hechos guardados acertaron las dos preguntas en todas las corridas, en sus dos versiones. Lo que separa a una de otra viene más abajo. Antes, la ventana.

La ventana deslizante ahora es un 400

Haiku 5.5 puede pensar antes de responder, y cada bloque de razonamiento (thinking) vuelve con una firma, un sello que la API comprueba cuando le reenvías el bloque. En los modelos actuales de Claude ese sello cubre la conversación que produjo el bloque: todo lo que venía antes, lo que la API llama su prefijo. Si cortas los turnos más viejos del inicio de un historial y lo reenvías, cada bloque de razonamiento que quede adentro aparece a continuación de una conversación que nunca existió.

Mi primera ventana conservaba esos bloques, y en mi cuenta la petición pasó. La verificación solo se aplica por defecto a las cuentas creadas desde el 31 de agosto de 2026; en las anteriores, solo cuando la petición la pide. Así que repetí la misma petición pidiéndola, con el encabezado beta thinking-binding-controls-2026-08-01 y prefix_mismatch_behavior en "error". La respuesta fue un HTTP 400, invalid_request_error, con este mensaje:

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`. 

Eso es lo que recibe por defecto una cuenta más nueva. La ventana de la tabla quita los bloques de razonamiento de los turnos que conserva, y eso la API lo acepta. Las otras salidas son pedirle a la API que descarte los bloques que ya no valen (drop_block, que el propio error sugiere), resumir y empezar una conversación nueva que no reenvíe nada, o dejar que el servidor limpie los turnos viejos con edición de contexto o compactación. Debajo de todas hay una regla: un historial que lleva bloques de razonamiento solo puede crecer por el final. Por esa misma regla la fecha salió del system prompt del agente y pasó al primer mensaje de cada sesión. Un system prompt que dice "lunes" no es el prefijo de uno que decía "viernes".

Qué decidió recordar

Este es el archivo de memoria que escribió el agente el viernes del que partieron todos los lunes con memoria de la tabla, tal como lo guardó:

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. 

Casi todo está bien: qué proyecto importa, la convención sobre las advertencias y el plan del lunes con la ruta completa. Una parte no. El error del build en la línea 111, los cuatro archivos sin confirmar y "last Core build showed 0 warnings" son estado: algo que las herramientas ya saben y que el lunes está desactualizado.

El prompt le decía que no lo hiciera. La primera versión de mis reglas pedía "never save what a tool can tell you: build or test results, changed files, error counts", y las dos corridas del viernes guardaron el error del build. La segunda versión lo nombraba sin rodeos, "whether the build passes, errors and their line numbers", y las dos corridas lo volvieron a guardar. Cuatro memorias de cuatro.

Ese estado guardado sería inofensivo si solo se leyera como historia, pero no fue así. Con la memoria de la primera versión, las tres corridas del lunes que la leían bajo demanda pusieron en pantalla, debajo de la respuesta hablada, alguna variante de "The build is red because ReviewOrchestrator still uses the old name" sin compilar. Era cierto porque nadie había tocado el repositorio el fin de semana. Si alguien lo hubiera arreglado, habría sido una línea falsa escrita con total seguridad.

Así que dejé de intentar que el prompt mantuviera el estado fuera de la memoria, y le pedí al agente que verifique cada recuerdo al leerlo. Esa regla está en todas las configuraciones con memoria de la tabla, y no es una garantía: el lunes el agente abrió ReviewOrchestrator.cs para comprobar la nota antes de repetirla en 2 de las 4 corridas precargadas y en 1 de las 4 bajo demanda. Las demás repitieron directo de la memoria la nota sobre la línea 111. Dónde se gana su lugar se ve dos secciones más abajo.

El almacén de memoria

La herramienta de memoria de Claude (memory_20250818) se ejecuta de tu lado. El modelo envía comandos (view, create, str_replace, insert, delete, rename) sobre rutas dentro de un directorio virtual /memories, y tu código los lleva a cabo donde quieras. Agregarla al agente es una entrada en la lista de herramientas, new MemoryTool20250818() en el SDK de C#, sin esquema que escribir.

Todo lo demás es el handler. Cada ruta y cada línea de texto que recibe vienen del modelo, así que merecen el mismo trato que la entrada de un usuario. Este es el comienzo de MemoryStore.cs:

using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; /// <summary> /// El almacenamiento detrás de la herramienta de memoria de Claude (memory_20250818). El modelo pide /// operaciones de archivo sobre un directorio virtual /memories; esta clase las ejecuta en una carpeta real, /// una por repositorio, fuera del repositorio para que una memoria nunca termine en un commit. Cada ruta /// y cada línea de texto que envía el modelo es entrada no confiable. /// </summary> sealed partial class MemoryStore { public const string Root = "/memories"; // La memoria se carga en la primera petición de cada sesión, así que su tamaño se paga cada // mañana. Estos topes la dejan en unos 1.500 tokens; al pasarlos, se le pide al modelo que // combine o borre entradas en lugar de seguir agregando. 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; // ruta completa, terminada en separador public MemoryStore(string directory) { Directory.CreateDirectory(directory); this.directory = DevTools.WithSeparator(Path.GetFullPath(directory)); // Un fallo entre escribir el archivo temporal y moverlo deja ese temporal atrás. // Está oculto, así que nada más lo contaría ni lo borraría nunca. foreach (string orphan in Directory.EnumerateFiles(this.directory, ".tmp-*")) { try { File.Delete(orphan); } catch (IOException) { // No es huérfano: otra sesión todavía lo está escribiendo, y ella misma lo moverá. } } } /// <summary>Una carpeta por repositorio, en los datos locales de la aplicación del usuario.</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>Ejecuta un comando de memoria. Los fallos vuelven al modelo como texto, nunca como excepciones.</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>Todos los archivos de memoria, para el system prompt al empezar una sesión. Vacío si no hay ninguno.</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(); } 

Los seis comandos devuelven los textos que define la documentación de la herramienta de memoria, porque el modelo los lee (solo falta el fragmento del archivo editado que la documentación agrega tras un str_replace); están en el repositorio. Lo que vale la pena leer es por dónde entran las rutas y por dónde salen los archivos:

/// <summary> /// Traduce "/memories/a/b.md" a una ruta real dentro de la carpeta, o la rechaza. Salir del directorio /// se rechaza parte por parte, antes de que el sistema operativo las interprete: "..", nombres ocultos, /// barras invertidas, letras de unidad y secuencias con % nunca llegan a 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>Revisa los topes y el filtro de secretos, y luego reemplaza el archivo de forma atómica.</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."); // Escribe al lado del destino y lo reemplaza: un fallo a mitad de escritura deja el archivo viejo, no medio archivo nuevo. 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); // en disco antes de mover, para que un corte de luz no deje un archivo vacío } File.Move(temp, full, overwrite: true); } /// <summary> /// Dos sesiones sobre el mismo repositorio (una por voz, otra escrita) pueden editar el mismo archivo, y /// str_replace lee, modifica y escribe. Cada comando mantiene un archivo de bloqueo exclusivo mientras corre, /// así que ningún comando ve a medias la escritura de otro. No combina las dos sesiones: cada modelo /// escribe a partir de lo último que leyó, y un create reemplaza lo que la otra haya guardado desde entonces. /// </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) { // Alguien borró la carpeta a mitad de sesión para que el agente olvide: empieza un almacén // vacío en vez de decir que la memoria está ocupada. 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"); } } } 

Algunas decisiones que hay ahí:

  • La memoria vive fuera del repositorio, en los datos locales de la aplicación del usuario, una carpeta por ruta de repositorio. Guarda lo que dijiste en voz alta sobre tu proyecto, y eso no debería terminar nunca en un commit.
  • Se rechaza cualquier intento de salir del directorio, parte por parte. .., los nombres ocultos, las barras invertidas, las letras de unidad y las secuencias con % se descartan antes de que Path.GetFullPath las vea, y la ruta resuelta se compara con la raíz de todos modos.
  • Topes que dicen qué hacer. Una escritura que dejaría un archivo por encima de 4.000 caracteres, o el almacén entero por encima de 6.000, se rechaza con un mensaje que le pide al modelo combinar o borrar entradas. La memoria se carga cada mañana, así que su tamaño es un costo que se repite.
  • Escritura atómica y un archivo de bloqueo. Dos sesiones sobre el mismo repositorio, una por voz y otra escrita, pueden hacer str_replace sobre el mismo archivo. El bloqueo hace que cada comando se ejecute entero, pero no combina las sesiones: cada modelo escribe a partir de lo último que leyó, así que un create de una reemplaza lo que la otra haya guardado desde entonces. Si borras la carpeta a mitad de sesión, el siguiente comando empieza con un almacén vacío.
  • Se rechaza el texto con forma de las credenciales habituales, decida lo que decida el modelo: claves y tokens de API, identificadores de claves de acceso de AWS y claves de API de Google, JWT, claves PEM y la contraseña de una cadena de conexión. Es una lista de exclusión, así que atrapa las formas comunes, no cualquier secreto.

Los errores vuelven al modelo como texto, nunca como excepciones, y eso importó más de lo que esperaba. La primera versión lan...