DEV Community

Cover image for De runtime de efectos a agente de código: construyendo la alfa de Brass Agent
Augusto Vivaldelli
Augusto Vivaldelli

Posted on

De runtime de efectos a agente de código: construyendo la alfa de Brass Agent

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:

  • Async como representación de efectos async.
  • Runtime para ejecutar esos efectos.
  • Scope para ownership de recursos.
  • Fiber para 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
Enter fullscreen mode Exit fullscreen mode

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

La forma mental quedó así:

User intent
  -> AgentAction
  -> PermissionService
  -> ApprovalService, si hace falta
  -> ToolPolicy
  -> Async tool effect
  -> Observation
  -> Reducer
  -> next AgentAction
Enter fullscreen mode Exit fullscreen mode

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

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

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

Lo importante no era la cantidad de features, sino el flujo:

state
  -> decideNextAction(state)
  -> invokeAction(action)
  -> observation
  -> reduce(state, observation)
  -> loop
Enter fullscreen mode Exit fullscreen mode

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

Ese patrón se repitió en otras herramientas:

external API
  -> wrapped as Async
  -> cancelable
  -> typed error
  -> policy controlled
Enter fullscreen mode Exit fullscreen mode

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

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

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

Luego sumamos rollback:

patch.apply
  -> patch.applied
  -> validation fails
  -> patch.rollback
  -> git apply --reverse --check
  -> git apply --reverse
Enter fullscreen mode Exit fullscreen mode

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:

  1. El LLM genera un patch.
  2. El patch aplica.
  3. La validación sigue fallando.
  4. 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
Enter fullscreen mode Exit fullscreen mode

Pero con límites:

{
  "patchQuality": {
    "enabled": true,
    "maxRepairAttempts": 1
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

Y luego un protocolo JSON Lines para integraciones:

brass-agent --protocol-json "fix the failing tests"
Enter fullscreen mode Exit fullscreen mode

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

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 Fix y Apply 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Focus mode

El chat en la sidebar era muy chico, así que agregamos:

Brass Agent: Open Chat in Editor
Enter fullscreen mode Exit fullscreen mode

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

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

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

En otro:

npm run repo:check
Enter fullscreen mode Exit fullscreen mode

En otro:

cargo check
Enter fullscreen mode Exit fullscreen mode

Entonces agregamos:

Brass Agent: Configure Workspace
Enter fullscreen mode Exit fullscreen mode

y desde chat:

/workspace
Enter fullscreen mode Exit fullscreen mode

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

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

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

Y arma un perfil:

Project profile: mixed
stacks: node, rust, tauri, desktop, bridge, monorepo
likely validation: npm run repo:check
Enter fullscreen mode Exit fullscreen mode

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

puede:

read src/user/UserService.ts
search AuthClient
read src/auth/AuthClient.ts
Enter fullscreen mode Exit fullscreen mode

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

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

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

no siempre significa:

la tarea salió bien
Enter fullscreen mode Exit fullscreen mode

A veces sólo significa:

la CLI no crasheó
Enter fullscreen mode Exit fullscreen mode

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

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

Eso permite:

instalar plugin una vez
abrir cualquier repo
usar Brass Agent
Enter fullscreen mode Exit fullscreen mode

Local smoke tests

Antes de publicar, agregamos smoke tests locales:

npm run agent:test:smoke
Enter fullscreen mode Exit fullscreen mode

El test crea un repo temporal y valida:

  • El CLI built existe.
  • read-only inspect funciona 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.md realista.
  • Versión de extensión como 0.1.0-alpha.0.
  • LICENSE dentro de la extensión para evitar warnings de VSIX.
  • Limpieza de node_modules, dist, .vsix, out, bundled del ZIP final.

También preparamos una descripción de PR y dejamos claro que esto es:

0.1.0-alpha.0
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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)