Construye un agente de desarrollo .NET, Parte 1: enséñale tu flujo de trabajo real
El agente funcionó a la primera, y eso debió ponerme en alerta.
Le había conectado a Claude los tres comandos que uso todo el día — git status, dotnet build, dotnet test —, le pasé la salida y le pregunté por qué mi build estaba en rojo. Respondió bien. Después, por costumbre, conté lo que le había mandado en realidad. El build reportaba 70 warnings. La solución solo tiene 35. Le estaba enviando a Claude una segunda copia literal de cada diagnóstico, y Claude las leía las dos.
MSBuild imprime cada diagnóstico dos veces: una cuando el compilador lo encuentra, y otra más en el bloque de resumen que va después de Build FAILED.. El ojo humano se salta esa segunda copia con tal eficacia que yo llevaba años leyendo salidas de build sin notarla. Un modelo de lenguaje no se salta nada. Lee el duplicado, te cobra el duplicado, y razona sobre un problema que ahora parece el doble de grande.
Este post trata de la mitad menos vistosa de un agente de desarrollo: no el bucle que llama al modelo, sino lo que le pones delante. El bucle son unas veinte líneas y ya lo escribí antes. Averiguar qué significa de verdad la salida de tus herramientas — y descartar la buena parte que no significa nada — es lo que separa a un agente que ayuda de uno que solo te factura.
Lo que este post da por sabido
El bucle de tool_use en sí — declarar las herramientas, recibir un bloque tool_use, ejecutarlo, devolver un tool_result y repetir hasta que el modelo deje de pedir — lo construí paso a paso en Crea un agente con tool use de Claude en C#. Aquí no lo vuelvo a construir. Si nunca escribiste uno, empieza por ahí; todo lo que viene se conecta con eso.
Todos los ejemplos asumen .NET 10 y el SDK oficial:
dotnet add package Anthropic
Tres herramientas, y por qué ninguna es bash
Lo obvio sería darle al modelo una sola herramienta bash y dejar que ejecute lo que quiera. A veces esa es la decisión correcta — es lo que hace Claude Code, y ahí la amplitud es justamente el objetivo. Aquí es la decisión equivocada, por dos razones.
La primera: una herramienta bash le entrega a tu harness una cadena opaca. Todas las acciones se ven iguales desde fuera, así que no hay nada que inspeccionar, autorizar ni contar. Una herramienta dotnet_build tipada te da un punto de enganche con argumentos reales: puedes decidir que compilar es seguro sin supervisión y que cualquier cosa que toque la red no lo es.
La segunda es el alcance. git status, dotnet build y dotnet test son de solo lectura. Eso es lo que hace razonable apuntar esto a tu propia copia de trabajo un viernes por la tarde. En cuanto agregas bash, el radio de daño pasa a ser todo lo que pueda hacer tu usuario del sistema, y quedas a una sugerencia bien redactada de arruinarte la tarde.
Empieza con bash cuando quieras alcance. Pasa a herramientas tipadas cuando quieras control. Para un agente de uso diario en tu máquina, gana el control.
Pide la salida pensada para programas, no la bonita
Las dos herramientas que importan aquí tienen un segundo formato de salida diseñado para programas, y en ambos casos lo que se imprime por defecto para humanos es lo que no conviene parsear. Esta es la decisión con más impacto de todo el post, y aparece dos veces.
git status imprime algo amable e inestable. git status --porcelain=v2 imprime un formato documentado, con una promesa explícita de compatibilidad. dotnet test imprime un resumen legible; dotnet test --logger trx escribe XML estructurado.
Lo contraintuitivo: el formato para programas suele ser más grande. El archivo TRX de mi suite de 91 tests pesa 149,020 bytes, contra 2,291 bytes de salida en consola. Da igual, porque nunca vas a enviarlo. Lo consultas y emites un resumen. Parsear un contrato estable y tirar el 99% es mejor que aplicarle expresiones regulares a un formato bonito que cambia entre versiones del SDK.
Herramienta uno: git_status
Porcelain v2 entrega una línea por entrada. Los archivos modificados empiezan con 1 (normal) o 2 (renombrado), los no rastreados con ?, y la bandera --branch agrega cabeceras # branch.* con los contadores de adelanto y atraso.
static async Task<string> GitStatusAsync(string repo, CancellationToken ct)
{
string output = await RunAsync("git", ["status", "--porcelain=v2", "--branch"], repo, ct);
string branch = "(detached)";
int ahead = 0, behind = 0;
List<string> changed = [], untracked = [];
foreach (string raw in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
string line = raw.TrimEnd('\r');
if (line.StartsWith("# branch.head "))
{
branch = line["# branch.head ".Length..];
}
else if (line.StartsWith("# branch.ab "))
{
string[] ab = line["# branch.ab ".Length..].Split(' ');
ahead = int.Parse(ab[0]); // "+2"
behind = int.Parse(ab[1]); // "-0"
}
else if (line.StartsWith("1 "))
{
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <ruta>
string[] f = line.Split(' ', 9);
changed.Add($"{f[1]} {f[8]}");
}
else if (line.StartsWith("2 "))
{
// los renombrados traen un campo extra de puntaje, y la ruta viene
// como "nueva<tab>anterior"
string[] f = line.Split(' ', 10);
string[] paths = f[9].Split('\t');
changed.Add($"{f[1]} {paths[0]} (antes {paths[1]})");
}
else if (line.StartsWith("? "))
{
untracked.Add(line[2..]);
}
}
StringBuilder summary = new();
summary.AppendLine($"rama {branch}, {ahead} adelante, {behind} atrás");
summary.AppendLine($"{changed.Count} modificados, {untracked.Count} sin rastrear");
foreach (string entry in changed.Take(40)) summary.AppendLine($" {entry}");
foreach (string entry in untracked.Take(20)) summary.AppendLine($" ?? {entry}");
return summary.ToString();
}
El código XY de dos caracteres sobrevive al resumen porque es la diferencia entre lo que está en el índice y lo que no, y el modelo lo usa. El resto de la línea — los tres modos de archivo y los dos hashes — sirve para herramientas que necesitan calcular diferencias, no para responder qué estoy haciendo ahora.
Herramienta dos: dotnet_build, donde vive la duplicación
Así se ve un build fallido en una solución de tres proyectos, medido y no supuesto:
87 líneas, 20,681 bytes
70 warnings impresos
35 warnings que existen
Cada diagnóstico aparece exactamente dos veces. Encima, cada línea impresa carga la ruta absoluta dos veces — una como ubicación del archivo y otra en el marcador final [...csproj] —, así que un mensaje de 90 caracteres llega dentro de una línea de 500.
La solución es una expresión regular y un diccionario. Elimina duplicados por la tupla que de verdad identifica un diagnóstico — archivo, línea, columna, código — y deja las rutas relativas a la raíz del repositorio:
// Expresión regular generada en tiempo de compilación, no al arrancar. Va dentro
// de una clase porque en una aplicación de archivo único no hay lugar para un
// campo: lo que se declara junto a las instrucciones de nivel superior tiene que
// ser una variable local, y ahí `static readonly` da un CS0106.
static partial class BuildOutput
{
[GeneratedRegex(@"^(?<file>.+?)\((?<line>\d+),(?<col>\d+)\): (?<severity>error|warning) (?<code>[A-Za-z]+\d+): (?<message>.+?)(?: \[[^\]]+\])?$")]
public static partial Regex DiagnosticLine();
}
static async Task<string> DotnetBuildAsync(string repo, string? project, CancellationToken ct)
{
string[] args = project is null
? ["build", "--nologo"]
: ["build", "--nologo", project];
string output = await RunAsync("dotnet", args, repo, ct);
// MSBuild emite cada diagnóstico en línea y otra vez en el bloque de resumen.
Dictionary<(string, int, int, string), Diagnostic> unique = [];
foreach (string raw in output.Split('\n'))
{
Match match = BuildOutput.DiagnosticLine().Match(raw.TrimEnd('\r'));
if (!match.Success) continue;
string file = match.Groups["file"].Value.Trim();
// Los diagnósticos anclados en los .targets del propio SDK son fallos de
// infraestructura, no de tu código. Consérvalos, pero no los presentes
// como si vivieran en el repositorio.
string display = file.StartsWith(repo, StringComparison.OrdinalIgnoreCase)
? Path.GetRelativePath(repo, file).Replace('\\', '/')
: file;
Diagnostic diagnostic = new(
display,
int.Parse(match.Groups["line"].Value),
int.Parse(match.Groups["col"].Value),
match.Groups["severity"].Value,
match.Groups["code"].Value,
match.Groups["message"].Value.Trim());
unique.TryAdd((diagnostic.File, diagnostic.Line, diagnostic.Column, diagnostic.Code), diagnostic);
}
List<Diagnostic> errors = [.. unique.Values.Where(d => d.Severity == "error")];
List<Diagnostic> warnings = [.. unique.Values.Where(d => d.Severity == "warning")];
if (errors.Count == 0 && warnings.Count == 0) return "El build terminó sin diagnósticos.";
StringBuilder summary = new();
summary.AppendLine($"{errors.Count} error(es), {warnings.Count} warning(s).");
// Primero los errores: rara vez un warning es la razón de que el build falle.
foreach (Diagnostic d in errors.Concat(warnings).Take(50))
summary.AppendLine($"{d.File}({d.Line},{d.Column}): {d.Severity} {d.Code}: {d.Message}");
int total = errors.Count + warnings.Count;
if (total > 50) summary.AppendLine($"... {total - 50} más omitidos.");
return summary.ToString();
}
Diagnostic es record Diagnostic(string File, int Line, int Column, string Severity, string Code, string Message);, declarado después de la última instrucción de nivel superior junto con BuildOutput — en una aplicación de archivo único todos los tipos van al final del archivo o te sale un CS8803.
Eso lleva el mismo build de 20,681 bytes a 4,849 — una cuarta parte del original, sin perder nada de valor. El límite también cuenta: una solución donde una interfaz rota genera 900 diagnósticos no necesita los 900 en la ventana de contexto para diagnosticarse. Los primeros cincuenta dicen lo mismo.
El error que cometí primero
Antes de todo lo anterior, mi instinto fue que al agente le faltaba más contexto, no menos. Así que subí la verbosidad:
dotnet build -v n
Ese build pesa 187,412 bytes — nueve veces la salida por defecto, con los mismos tres proyectos y los mismos errores. La línea más larga tiene 25,635 caracteres: la invocación completa de csc, cada referencia, cada analizador, entera. Nada de eso explica un error de compilación, y todo eso desplaza a las tres líneas que sí lo hacen.
Las banderas de verbosidad están pensadas para una persona que baja por la pantalla buscando una cosa. Un agente lo lee todo, en cada turno. Subirla fue lo más caro que le hice a este proyecto, y empeoró las respuestas — lo cual al menos volvió fácil detectar el problema.
Herramienta tres: dotnet_test, vía TRX
El dotnet test moderno ya es contenido en consola: 89 tests que pasan no imprimen nada. Pero los fallos repiten el mismo patrón: cada uno se anuncia dos veces, una como línea [xUnit.net] y otra en el bloque de detalle.
El problema mayor son los stack traces. Entre mis dos tests fallidos había seis marcos, y solo dos apuntaban a mi código. Los otros cuatro se ven así:
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
Ese es el runner de tests entrando por reflexión a tu método. Es idéntico para cada fallo de cada suite que se haya escrito, y no le dice nada al modelo. Dos tercios del stack trace son relleno.
TRX te da todo esto como XML con un esquema estable, así que puedes tomar los tres campos que importan y dejar el resto en disco:
static async Task<string> DotnetTestAsync(string repo, CancellationToken ct)
{
string resultsDir = Path.Combine(Path.GetTempPath(), $"agent-trx-{Guid.NewGuid():N}");
try
{
await RunAsync("dotnet",
["test", "--nologo", "--logger", "trx;LogFileName=run.trx", "--results-directory", resultsDir],
repo, ct);
string trx = Path.Combine(resultsDir, "run.trx");
if (!File.Exists(trx)) return "No se generó TRX: seguramente el proyecto de tests no compiló.";
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
XDocument doc = XDocument.Load(trx);
XElement? counters = doc.Descendants(ns + "Counters").FirstOrDefault();
StringBuilder summary = new();
summary.AppendLine(
$"{counters?.Attribute("passed")?.Value ?? "?"}/{counters?.Attribute("total")?.Value ?? "?"} pasaron, " +
$"{counters?.Attribute("failed")?.Value ?? "?"} fallaron.");
foreach (XElement result in doc.Descendants(ns + "UnitTestResult")
.Where(r => (string?)r.Attribute("outcome") == "Failed")
.Take(15))
{
string name = ((string?)result.Attribute("testName") ?? "(desconocido)").Split('.')[^1];
string message = result.Descendants(ns + "Message").FirstOrDefault()?.Value.Trim() ?? "";
// Conserva solo los marcos que están en el código del proyecto.
IEnumerable<string> frames = (result.Descendants(ns + "StackTrace").FirstOrDefault()?.Value ?? "")
.Split('\n')
.Select(f => f.Trim())
.Where(f => f.Length > 0 && !f.StartsWith("at System."))
.Take(3);
summary.AppendLine();
summary.AppendLine($"FALLÓ {name}");
foreach (string line in message.Split('\n')) summary.AppendLine($" {line.TrimEnd('\r')}");
foreach (string frame in frames) summary.AppendLine($" {frame}");
}
return summary.ToString();
}
finally
{
if (Directory.Exists(resultsDir)) Directory.Delete(resultsDir, recursive: true);
}
}
Filtrar por at System. es una regla tosca que ocultaría un fallo genuino dentro de la biblioteca base. En tres meses de uso diario eso no ha pasado ni una vez, y si pasa, el mensaje de la aserción igual nombra qué salió mal. Aquí lo tosco y barato le gana a lo ingenioso y frágil.
Los números, de punta a punta
Medido sobre una solución .NET 10 de tres proyectos con 91 tests de xUnit. Cualquier fila se reproduce con wc -c:
| Fuente | Crudo | Filtrado | Proporción |
|---|---|---|---|
| Build fallido, 35 diagnósticos | 20,681 B / 87 líneas | 4,849 B / 37 líneas | 4.3x |
| Tests fallidos, 2 de 91 | 2,291 B / 31 líneas | 878 B / 17 líneas | 2.6x |
| Archivo TRX de esa misma corrida | 149,020 B | 878 B | 170x |
| Build con verbosidad normal | 187,412 B | — | mejor no |
Cuatro veces más pequeño no es el titular. El titular es que más o menos la mitad de lo que estaba enviando era un duplicado byte por byte, y no pude verlo hasta que lo conté.
Ejecutar el proceso sin abrir agujeros
Las tres herramientas pasan por un solo método auxiliar. Dos detalles suyos no son opcionales.
static async Task<string> RunAsync(string file, string[] args, string cwd, CancellationToken ct)
{
ProcessStartInfo psi = new()
{
FileName = file,
WorkingDirectory = cwd,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
// ArgumentList escapa cada valor por separado. Nunca armes la línea de
// comando concatenando texto que viene del modelo.
foreach (string arg in args) psi.ArgumentList.Add(arg);
using Process process = Process.Start(psi)
?? throw new InvalidOperationException($"No se pudo iniciar {file}.");
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromMinutes(5));
Task<string> stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
Task<string> stderr = process.StandardError.ReadToEndAsync(timeout.Token);
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw new TimeoutException($"{file} no terminó en cinco minutos.");
}
return await stdout + await stderr;
}
Usar ArgumentList en vez de una cadena unida significa que una ruta de proyecto inventada por el modelo no puede colar un segundo comando. Y un dotnet test colgado tiene que morirse por su propio temporizador, o tu agente espera para siempre mientras el modelo te cobra la paciencia.
El system prompt es donde vive tu flujo de trabajo
Las herramientas son genéricas. Lo que vuelve tuyo al agente es el párrafo que describe el repositorio — y es la parte que todo el mundo se salta.
List<TextBlockParam> system =
[
new()
{
Text = """
Eres un asistente de desarrollo para el repositorio ClaudeReviewBot.
Tres proyectos. src/ClaudeReviewBot.Core tiene la lógica y es el que
importa. src/ClaudeReviewBot es un envoltorio de CLI delgado.
tests/ es xUnit.
TreatWarningsAsErrors está activo, así que cualquier warning rompe el
build. CS1591 (falta comentario XML) es ruido que nunca nos ha
importado. Los warnings de nulabilidad no son ruido: trátalos como
defectos reales.
Revisa el estado antes de responder. Si la pregunta es sobre el build,
compila. Si es sobre qué cambió, llama a git_status. No especules.
""",
CacheControl = new CacheControlEphemeral(),
},
];
Ese bloque es idéntico byte por byte en cada turno, y eso lo vuelve el punto de corte ideal para la caché. CacheControlEphemeral significa que lo escribes una vez y lo lees a una fracción del precio durante el resto de la sesión. Como además va delante de la conversación en el prefijo, todo el historial que viene detrás también se mantiene en caché.
Fíjate en lo que hace ese texto. Que CS1591 sea ruido y los warnings de nulabilidad no, no es un dato técnico sobre C#; es un criterio de tu equipo, y el modelo no tiene otra forma de aprenderlo. Esa frase es toda la premisa de la serie en una línea.
MessageCreateParams parameters = new()
{
Model = "claude-opus-5",
MaxTokens = 8000,
System = system,
Tools = [.. tools],
Messages = messages,
};
La primera cosa útil que hizo
Nada de demos. Renombré una propiedad de un record usado en tres proyectos, olvidé un uso y pregunté:
por qué está en rojo el build
El agente llamó a dotnet_build, obtuvo tres errores CS1061 sin duplicar, todos nombrando ChangedFile.Patch, y después llamó a git_status por su cuenta y vio que ChangedFile.cs era el único archivo en el índice. Conectó las dos cosas: el renombrado se aplicó en la declaración y en dos de los tres lugares que la usan.
Esa segunda llamada es lo interesante. Nada en mi prompt decía que correlacionara errores de compilación con el árbol de trabajo. Tenía una herramienta capaz de decirle qué había cambiado, una pregunta que daba a entender que algo se rompió hace poco, y suficiente espacio en el contexto para pensar — porque el contexto no estaba lleno de warnings duplicados y líneas de comando de csc.
Cuándo no usar esto
Si puedes leer el error del compilador tú mismo, léelo tú mismo. Es más rápido y es gratis. Un CS1061 suelto en un archivo que acabas de tocar no necesita una ida y vuelta a un modelo de lenguaje.
Esto se gana su lugar cuando la pregunta cruza varias fuentes: un test que falla por razones que el build apenas insinúa, un cambio cuyo alcance no es evidente, el clásico lunes por la mañana de qué estaba haciendo yo. Y conviene recordar la lección de el bot de revisión de PR: a veces el número correcto de herramientas es menor del que crees, y de vez en cuando es cero.
Puntos clave
- MSBuild imprime cada diagnóstico dos veces, una en línea y otra en el resumen. 70 impresos, 35 reales. Elimina duplicados por archivo, línea, columna y código antes de que nada llegue al modelo.
-
Pide el formato pensado para programas.
git status --porcelain=v2ydotnet test --logger trxson contratos de estabilidad; la salida de consola es una interfaz de usuario, y las interfaces cambian entre versiones del SDK. - Más grande en disco puede significar más pequeño en contexto. El archivo TRX pesa 149,020 bytes y el resumen que vale la pena enviar pesa 878. Lo consultas; nunca lo reenvías.
- Subir la verbosidad empeora al agente, no lo mejora. La verbosidad normal fue nueve veces la carga para el mismo build, con líneas sueltas de 25,635 caracteres, y las respuestas se degradaron.
- Las reglas de filtrado son tu flujo de trabajo. Qué warnings importan, qué proyectos importan, qué significa terminado: ese criterio no vive en otro lado que tu código de herramientas y tu system prompt.
Siguiente en la serie: Construye un agente de desarrollo .NET, Parte 2: dale voz — dictado y síntesis de voz para hablarle en vez de escribirle, por qué una respuesta hablada necesita un system prompt distinto al de una escrita, y qué le pasa al diseño de herramientas cuando la respuesta tiene que ser lo bastante corta como para escucharla.
Publicado el día 256 del año: 2^8, todo lo que cabe en un byte. Me pareció el día indicado para un post que se pasa entero contándolos.
01000110 01100101 01101100 01101001 01100011 01101001 01100100 01100001 01100100 01100101 01110011 00100000 01100100 01100101 01110110 01110011 00100001




Top comments (0)