Durante los últimos días estuve armando una primera alfa de Brass Agent, un agente de desarrollo construido encima de brass-runtime.
La idea original no era simplemente “hacer otro Copilot”. La pregunta era otra:
¿Qué pasaría si un agente de código estuviera construido sobre un runtime de efectos, con cancelación, scopes, fibers, permisos, observabilidad y rollback desde el diseño inicial?
Esa pregunta terminó convirtiéndose en una alfa bastante completa:
- CLI local.
- Extensión de VS Code.
- Chat tipo Copilot.
- Patch preview.
- Apply seguro.
- Rollback.
- Project intelligence.
- Configuración por workspace.
- Model onboarding desde VS Code.
- Soporte para Google/Gemini y providers OpenAI-compatible.
- Context discovery.
- Redaction.
- Smoke tests locales.
Este artículo resume el proceso completo: decisiones, arquitectura, features, errores encontrados y por qué todavía lo considero una alfa, no una versión estable.
El punto de partida: brass-runtime
brass-runtime ya existía como un runtime de efectos en TypeScript. Tenía varias piezas interesantes:
-
Asynccomo representación de efectos async. -
Runtimepara ejecutar esos efectos. -
Scopepara ownership de recursos. -
Fiberpara concurrencia estructurada. - Cancelación.
- Finalizers.
- Streams, queues y primitives de concurrencia.
Eso lo hacía una base natural para construir un agente.
Un agente de código real no es solamente “un LLM que responde texto”. Internamente hace muchas cosas peligrosas o caras:
- Lee archivos.
- Busca en el repo.
- Corre comandos.
- Llama modelos.
- Genera patches.
- Aplica cambios.
- Valida.
- Reintenta.
- Hace rollback.
Todo eso son efectos. Y si son efectos, tiene sentido que pasen por un runtime que los pueda gobernar.
La tesis inicial fue:
Un coding agent no debería ejecutar side effects directamente. Debería producir acciones, y esas acciones deberían interpretarse como efectos controlados.
Las reglas de arquitectura
Antes de escribir demasiado código, definimos algunas reglas para no mezclar responsabilidades:
src/core
↑
src/agent
↑
brass-agent CLI
↑
VS Code extension
Y las reglas concretas:
1. src/core no sabe que src/agent existe.
2. src/agent depende de src/core.
3. La CLI depende de src/agent.
4. VS Code no reimplementa el agente.
5. VS Code invoca la CLI y consume su protocolo.
6. Ninguna tool ejecuta side effects sin pasar por capabilities.
7. Todo trabajo externo debe ser cancelable.
8. Los patches deben pasar por preview, approval y/o policy.
La forma mental quedó así:
User intent
-> AgentAction
-> PermissionService
-> ApprovalService, si hace falta
-> ToolPolicy
-> Async tool effect
-> Observation
-> Reducer
-> next AgentAction
Ese pipeline se volvió el corazón de Brass Agent.
P0: hardening del runtime
Antes de crear el agente, apareció una pregunta importante:
Si cierro un scope, ¿se interrumpen correctamente sus fibers hijos?
Para un runtime genérico esto ya importa. Para un agente de código importa muchísimo más. Si el agente corre npm test, una llamada HTTP o un proceso largo, y el usuario cancela, no puede quedar trabajo zombie corriendo de fondo.
Entonces el primer cambio fue endurecer Scope.closeAsync para interrumpir children antes de esperarlos.
También ajustamos withScopeAsync para que cerrara scopes de forma awaitable/cancelable, en vez de disparar el cierre y devolver antes de que terminara.
Esto sentó una base clave:
cancelar un run del agente
-> interrumpe fibers
-> dispara finalizers
-> mata procesos hijos
-> cierra recursos
P1: el primer vertical slice del agente
Después vino el MVP de src/agent.
La primera versión fue deliberadamente chica:
- Leer
package.json. - Detectar scripts.
- Correr una validación simple.
- Llamar al LLM.
- Proponer un patch.
- No escribir por defecto.
El agente se modeló con tipos simples:
export type AgentAction =
| { type: "fs.readFile"; path: string }
| { type: "fs.searchText"; query: string }
| { type: "shell.exec"; command: readonly string[] }
| { type: "llm.complete"; prompt: string; purpose: "plan" | "patch" | "explain" }
| { type: "patch.propose"; patch: string }
| { type: "agent.finish"; summary: string };
Y observaciones:
export type Observation =
| { type: "fs.fileRead"; path: string; content: string }
| { type: "shell.result"; command: readonly string[]; exitCode: number; stdout: string; stderr: string }
| { type: "llm.response"; purpose: string; content: string }
| { type: "patch.proposed"; patch: string }
| { type: "agent.done"; summary: string };
Lo importante no era la cantidad de features, sino el flujo:
state
-> decideNextAction(state)
-> invokeAction(action)
-> observation
-> reduce(state, observation)
-> loop
Node adapters: FS y Shell cancelables
El primer gran riesgo era child_process.
Un agente que corre comandos sin cancelación es peligroso. Por eso el Shell adapter se implementó como un efecto cancelable: si el fiber se interrumpe, el proceso hijo recibe SIGTERM.
Conceptualmente:
const NodeShell: Shell = {
exec: (command, options) =>
async((_env, cb) => {
const child = spawn(command[0], command.slice(1), {
cwd: options.cwd,
shell: false,
});
child.on("close", code => {
cb(Exit.succeed({ exitCode: code ?? 1, stdout, stderr }));
});
return () => {
child.kill("SIGTERM");
};
}),
};
Ese patrón se repitió en otras herramientas:
external API
-> wrapped as Async
-> cancelable
-> typed error
-> policy controlled
LLM adapters: OpenAI-compatible y Google/Gemini
La primera capa de modelos fue OpenAI-compatible.
Después agregamos un adapter nativo de Google/Gemini, usando generateContent.
La idea fue mantener una interfaz simple:
export type LLM = {
complete(request: LLMRequest): Async<unknown, AgentError, LLMResponse>;
};
Y que el resto del agente no sepa si detrás hay:
- Gemini.
- OpenAI.
- Un provider compatible.
- Un fake LLM para tests.
Esto permitió tener smoke tests offline con:
BRASS_LLM_PROVIDER=fake
BRASS_FAKE_LLM_RESPONSE="..."
Apply mode, approvals y rollback
Al principio el agente sólo proponía patches.
Después agregamos --apply, pero con varias restricciones:
LLM response
-> extract unified diff
-> validate paths
-> git apply --check
-> approval
-> git apply
-> rerun validation
Luego sumamos rollback:
patch.apply
-> patch.applied
-> validation fails
-> patch.rollback
-> git apply --reverse --check
-> git apply --reverse
La regla fue:
VS Code nunca aplica patches directamente. VS Code muestra, aprueba y delega en
brass-agent.
Eso evita que la UI tenga lógica duplicada o bypass de seguridad.
Patch quality loop
Una vez que apply funcionaba, apareció otro caso real:
- El LLM genera un patch.
- El patch aplica.
- La validación sigue fallando.
- El agente debería poder pedir una reparación.
Así nació el patch quality loop:
llm.plan
-> patch.apply
-> validation
-> if failed: llm.patch
-> patch.apply
-> validation
Pero con límites:
{
"patchQuality": {
"enabled": true,
"maxRepairAttempts": 1
}
}
Y con una regla importante:
Si el patch vino de
--apply-patch-file, no se regenera otro patch invisible.
Eso preserva la UX de VS Code:
preview patch A
-> usuario aprueba patch A
-> se aplica exactamente patch A
Observability y protocolo
El agente necesitaba verse en vivo. No bastaba con devolver un JSON final.
Se agregó un event stream:
agent.run.started
agent.action.started
agent.action.completed
agent.action.failed
agent.observation.recorded
agent.patch.applied
agent.run.completed
Y luego un protocolo JSON Lines para integraciones:
brass-agent --protocol-json "fix the failing tests"
La extensión de VS Code consume ese protocolo.
Esto permitió mantener otra regla importante:
VS Code = cliente fino
brass-agent CLI = boundary estable
src/agent = semántica del agente
src/core = runtime
De CLI a VS Code
La CLI fue la primera superficie.
Pero usar el agente sólo con comandos era demasiado friccional. Así que agregamos una extensión de VS Code.
Al principio era simple:
- Output channel.
- Run History.
- Comandos como
Propose FixyApply Fix.
Después fue creciendo:
- Patch preview en Webview.
- Sidebar.
- Batch runner.
- Doctor.
- Configure Model.
- Configure Workspace.
- Chat.
- Code Actions.
- Inline Assist.
- Problems-aware commands.
- Project Dashboard.
- Chat en editor/focus mode.
La extensión terminó teniendo tres grandes superficies:
Project Dashboard
-> qué entiende Brass del repo
Chat
-> interacción principal tipo Copilot
Run History
-> runs anteriores, patches y summaries
El salto a UX tipo Copilot
El usuario no debería tener que pensar en flags.
La UX ideal era:
abrir VS Code
-> Brass Agent Chat
-> escribir “arreglá los errores de este archivo”
-> ver progreso
-> revisar patch
-> aplicar
Para acercarnos a eso agregamos:
Chat
Comandos como:
/inspect
/fix-tests
/fix-problems
/fix-current-file
/explain-last
/apply-last
/rollback-last
/model
/workspace
/project
Code Actions / Lightbulb
Sobre diagnostics o selección:
Explain with Brass Agent
Fix with Brass Agent
Generate test with Brass Agent
Refactor selection with Brass Agent
Inline Assist
Una acción contextual para pedir ayuda sobre el código actual:
Ask about this code
Explain this code
Fix this code
Refactor this code
Generate tests
Custom instruction
Focus mode
El chat en la sidebar era muy chico, así que agregamos:
Brass Agent: Open Chat in Editor
Ahora el chat puede vivir como una pestaña grande del editor.
Onboarding de modelo desde VS Code
Otro problema de DX era configurar API keys.
No quería depender siempre de .env, ni pedirle al usuario que exporte variables desde la terminal.
Entonces la extensión agregó:
Brass Agent: Configure Model
El usuario puede elegir:
- Google/Gemini.
- OpenAI-compatible.
- Fake/offline.
- Auto-detect.
Y las keys se guardan en VS Code Secret Storage, no en el repo.
Esto hizo que el flujo quedara mucho más amigable:
instalar VSIX
-> Configure Model
-> pegar key
-> /doctor
-> /inspect
Workspace config desde la UI
Otro punto interesante apareció al usarlo en repos reales.
El agente no siempre puede adivinar cuál es el comando correcto.
En un repo puede ser:
npm test
En otro:
npm run repo:check
En otro:
cargo check
Entonces agregamos:
Brass Agent: Configure Workspace
y desde chat:
/workspace
Eso permite crear o actualizar .brass-agent.json desde VS Code.
Ejemplo:
{
"language": {
"response": "es"
},
"project": {
"packageManager": "npm",
"validationCommands": [
"npm run repo:check"
]
},
"permissions": {
"shell": {
"inheritDefaults": true,
"allow": [
"npm run repo:check"
]
},
"patchApply": {
"decision": "ask"
}
}
}
Idioma: match-user y español
Un problema real: yo escribía en español, pero el agente respondía en inglés.
Eso pasaba porque el prompt interno estaba sesgado a inglés.
Se agregó una política de idioma:
{
"language": {
"response": "es"
}
}
También existe match-user / auto.
Ahora el agente puede responder en español si el usuario escribe en español, o si el workspace lo fuerza.
Project intelligence: entender repos reales
Después de usarlo en repos reales, quedó claro que “mirar package.json” no alcanza.
El agente ahora detecta perfiles como:
- Node.
- Rust.
- Tauri.
- Desktop app.
- Bridge/service.
- Monorepo.
- Serverless.
- npm / pnpm / yarn / bun.
Busca markers como:
package.json
package-lock.json
Cargo.toml
Cargo.lock
src-tauri/tauri.conf.json
apps/
packages/
bridges/
turbo.json
nx.json
pnpm-workspace.yaml
serverless.yml
Y arma un perfil:
Project profile: mixed
stacks: node, rust, tauri, desktop, bridge, monorepo
likely validation: npm run repo:check
Eso entra al prompt del modelo y también aparece en el Project Dashboard.
Context discovery
Antes de llamar al LLM, el agente intenta conseguir contexto útil.
Por ejemplo, si un error menciona:
src/user/UserService.ts:12 Cannot find name AuthClient
puede:
read src/user/UserService.ts
search AuthClient
read src/auth/AuthClient.ts
Pero esto trajo problemas: al principio buscaba palabras basura como Previous, Validation, Remaining, o términos heredados del resumen anterior.
Tuvimos que mejorar:
- Stopwords.
- Follow-up gating.
- Fresh context para slash commands.
- No arrastrar contexto previo en
/inspect. - Compactar prompts.
Esta fue una de las lecciones más importantes:
En una UI conversacional, decidir cuándo usar contexto anterior es tan importante como tener memoria.
Redaction y seguridad
El agente puede leer archivos y mandar contexto al modelo. Eso implica riesgos.
Agregamos redaction para patrones típicos:
- API keys.
- Bearer tokens.
- GitHub tokens.
- Slack tokens.
- JWTs.
password=...secret=...api_key=...
También agregamos exclude globs:
{
"context": {
"excludeGlobs": [
".env*",
"node_modules/**",
"dist/**",
"build/**",
"secrets/**",
"*.pem",
"*.key"
]
}
}
Y una regla operacional:
Las API keys del modelo viven en VS Code Secret Storage o env vars, no en
.brass-agent.json.
El problema de los errores LLM
Durante las pruebas, muchas veces aparecía:
Agent stopped with LLMError.
Eso era inútil.
No decía si era:
- Rate limit.
- Quota.
- Key inválida.
- Payload demasiado grande.
- Modelo incorrecto.
- Safety block.
- Timeout.
Entonces mejoramos los summaries para mostrar errores más útiles.
También empezamos a marcar runs como failed aunque el proceso del CLI terminara con exit 0 si el estado final contenía agent.error.
Esto es importante porque:
process exited with 0
no siempre significa:
la tarea salió bien
A veces sólo significa:
la CLI no crasheó
Instalación global y CLI bundled
Otra mejora de DX fue permitir que el plugin funcione en cualquier repo sin hacer npm link manual.
Primero agregamos:
npm run agent:link
npm run agent:vscode:install:global
Después fuimos más lejos: el VSIX puede incluir el CLI construido dentro de la extensión.
Entonces la extensión resuelve el CLI en este orden:
1. BRASS_AGENT_COMMAND
2. CLI bundled dentro del VSIX
3. workspace node_modules/.bin/brass-agent
4. checkout cercano de brass-runtime
5. brass-agent en PATH
Eso permite:
instalar plugin una vez
abrir cualquier repo
usar Brass Agent
Local smoke tests
Antes de publicar, agregamos smoke tests locales:
npm run agent:test:smoke
El test crea un repo temporal y valida:
- El CLI built existe.
-
read-only inspectfunciona con fake LLM. - Apply mode puede usar un diff controlado.
- El patch modifica un archivo real.
- La validación pasa después del patch.
Esto no reemplaza una suite completa, pero sirve para evitar romper el flujo principal.
Release hygiene antes de publicar
Antes de subir a GitHub hicimos algunas correcciones finales:
-
.gitignore. - README con imports correctos.
-
SECURITY.mdrealista. - Versión de extensión como
0.1.0-alpha.0. -
LICENSEdentro de la extensión para evitar warnings de VSIX. - Limpieza de
node_modules,dist,.vsix,out,bundleddel ZIP final.
También preparamos una descripción de PR y dejamos claro que esto es:
0.1.0-alpha.0
No stable.
Estado actual de la alfa
La alfa incluye:
CLI:
- brass-agent --doctor
- brass-agent --init
- brass-agent --where
- brass-agent --preset inspect
- brass-agent --apply
- brass-agent --rollback-patch-file
- brass-agent --batch-file
VS Code:
- Chat
- Chat in Editor
- Project Dashboard
- Run History
- Patch Preview
- Configure Model
- Configure Workspace
- Code Actions
- Inline Assist
- Problems-aware commands
Agent:
- workspace discovery
- env loading
- LLM adapters
- project intelligence
- context discovery
- redaction
- patch repair loop
- rollback safety
- local smoke tests
Lo que todavía falta
Todavía no lo llamaría estable.
Faltan cosas importantes:
- Dogfooding en más repos.
- Más tests del core del agente.
- Tests de extensión.
- Mejor evaluación de prompts.
- Métricas de costo.
- Mejor compaction de memoria.
- Un
AgentPlanIR explícito. - Optimización real de acciones.
- Batching de FS/search.
- Caching por path/query/mtime.
- CI y release automation.
La arquitectura futura que más me interesa es esta:
User intent
-> declarative request
-> AgentPlan IR
-> plan optimizer
-> batched executor
-> compact memory update
-> final summary / patch / rollback
Es decir: mantener la capa de usuario declarativa y flexible, pero convertir internamente el trabajo en planes optimizados que ejecutan con batches, memoria compacta y la menor cantidad posible de overhead.
Lecciones aprendidas
1. Un agente es un programa concurrente, no sólo un wrapper de LLM
Corre procesos, lee archivos, llama APIs, maneja timeouts, cancela, reintenta y hace rollback.
Un runtime de efectos encaja muy bien con eso.
2. La seguridad tiene que estar en el pipeline
No alcanza con “confiar en la UI”.
El pipeline debe tener:
permissions
approvals
path validation
git apply --check
redaction
rollback
3. La UX tipo Copilot no es sólo chat
También es:
- Lightbulb.
- Inline assist.
- Patch preview.
- Project status.
- Model onboarding.
- Workspace config.
4. La memoria conversacional puede ser peligrosa
Si arrastrás demasiado contexto previo, contaminás runs nuevos.
/inspect debe arrancar limpio.
why did that fail? sí necesita contexto.
Ese gating es clave.
5. El agente necesita entender el proyecto
No todos los repos tienen npm test.
Algunos tienen:
repo:check
bridge:doctor
cargo check
serverless.yml
Tauri
monorepos
workflows custom
La project intelligence cambia mucho la calidad del agente.
Cierre
Brass Agent todavía es una alfa, pero ya tiene una forma clara:
brass-runtime
-> effect runtime
brass-agent
-> agent semantics
brass-agent CLI
-> stable boundary
VS Code extension
-> Copilot-like DX
Lo más interesante para mí no fue “agregar IA al editor”. Fue comprobar que un agente de desarrollo se beneficia muchísimo de tener una base con:
- efectos explícitos,
- cancelación,
- scopes,
- permisos,
- observabilidad,
- policies,
- patches seguros,
- rollback.
La alfa ya está lista para dogfooding.
El siguiente objetivo no es sumar features por sumar, sino probarla en repos reales y mejorar la calidad del agente desde ahí.
Top comments (0)