¿Cómo construir tu propio harness de agentes?

@mfpiccolo
INGLÉShace 2 meses · 28 may 2026
339K
1.9K
242
35
4.9K

TL;DR

El autor sostiene que los frameworks de agentes tradicionales son demasiado rígidos y propone un enfoque de motor modular 'iii', donde cada componente del harness —desde las credenciales hasta los motores de políticas— es un worker independiente y reemplazable.

La mayoría de los equipos de agentes no construyen un arnés. Lo adoptan. LangChain, LangGraph, OpenAI Agents SDK, Anthropic SDK, CrewAI, AutoGen, el bucle, las herramientas, la memoria y la orquestación se toman de la estantería como una decisión única. El arnés es un framework que importas. Si algo dentro no encaja, lo bifurcas, lo peleas o lo trabajas por los bordes.

Mike Piccolo - inline image

Creo que esa forma es incorrecta, y es la razón por la que todo equipo de agentes de larga duración termina reescribiendo su arnés desde cero. El arnés no es una sola cosa. Son diez o doce cosas diferentes empaquetadas juntas porque el ecosistema circundante no te da una manera de componerlas. Los paquetes de Pi agent van por buen camino, pero aún están en el paradigma de "Agregar otro servicio e integrarlo con todos los demás". El motor iii trata a todos los workers por igual y elimina por completo la lógica de integración. El enrutador de proveedores, la bóveda de credenciales, el motor de políticas, la puerta de aprobación, el catálogo de modelos, el almacenamiento de sesiones, el rastreador de presupuesto, el fanout de hooks posteriores a la llamada y el bucle de turnos durable son preocupaciones independientes. Todos son interoperables con tu cola, servidor HTTP/API, streaming e incluso workers del navegador. Un framework que los envía como un solo bloque te está vendiendo una compensación que no tenías que hacer.

La apuesta detrás de iii es que no deberían ser un solo bloque. Debería haber un conjunto de workers sobre un motor compartido, cada uno reemplazable, cada uno versionado de forma independiente, cada uno conectado por un único primitivo: un disparador (iii.trigger()) que todos los demás workers también usan. El arnés se convierte en una pila de workers instalables, y "construir el tuyo" deja de significar "bifurcar un framework". Significa "intercambiar algunos workers".

Este artículo recorre cómo se ve eso realmente. La pila completa que impulsa un turno de agente iii hoy, por qué cada capa es su propio worker y cómo reemplazar cualquiera de ellos.

Los 15 trabajos que un arnés de agente debe hacer

Si despojas a un arnés de agente de producción a sus responsabilidades, obtienes una lista que se ve más o menos así:

  1. Aceptar una solicitud de turno de un cliente y persistirla
  2. Resolver credenciales para el proveedor de modelos que se llamará
  3. Consultar qué puede hacer realmente el modelo elegido (visión, herramientas, streaming, ventana de contexto)
  4. Conducir la máquina de estados por turno: provisionar, transmitir asistente, ejecutar herramientas, dirigir, desmantelar
  5. Cargar y servir cuerpos de skill que describen la forma de solicitud de cada función, códigos de error y notas de uso
  6. Ensamblar el prompt del sistema, el párrafo de modo, el preámbulo de identidad, el directorio de trabajo y el apéndice de skills predeterminadas
  7. Transmitir tokens de vuelta al cliente a medida que el modelo los produce
  8. Revisar cada llamada de herramienta (que es solo una función) contra una política antes de ejecutarla
  9. Pausar las llamadas de herramienta que necesitan una decisión humana y enrutar la respuesta al turno correcto
  10. Rastrear el gasto de LLM contra presupuestos por espacio de trabajo o por agente
  11. Ejecutar hooks antes y después de las llamadas de herramienta (registro, redacción, efectos secundarios personalizados)
  12. Persistir la sesión como un árbol ramificado para que funcionen las bifurcaciones y reanudaciones
  13. Compactar el historial de la sesión cuando la ventana de contexto se llena
  14. Emitir un flujo de eventos al que se suscribe la UI
  15. Pieza faltante en la construcción de cada empresa de agentes que veo. Llevar un rastreo de OpenTelemetry a través de cada paso para poder depurarlo

Todo arnés de agente serio hace la mayoría de estos. Los costosos hacen todos. Los baratos toman atajos y reconstruyen las esquinas más tarde cuando llegan a producción. Los frameworks los agrupan en un monolito y envían una versión de cada uno. Esa última parte es la que te cuesta, porque un año después, descubres que el motor de políticas que quieres no es el motor de políticas que envía el framework, y reemplazarlo significa reemplazar el arnés.

El arnés iii envía cada uno de esos trece trabajos como un worker separado en el registro workers.iii.dev. Cada uno habla el mismo protocolo WebSocket. Cada uno registra funciones y disparadores en el mismo bus del motor. Cada uno es agregable mediante iii worker add, intercambiable y escribible en cualquier lenguaje que tenga SDK.

La pila, por worker

Aquí está la pila de producción real del monorepo iii-hq/workers, con el trabajo de cada worker en una línea. Todo el paquete se envía en github.com/iii-hq/workers/harness:

Mike Piccolo - inline image

Once workers. Un motor. Cada uno tiene una versión publicada. Cada uno es ejecutable de forma independiente como un proceso autónomo (pnpm dev:<worker> en desarrollo, iii worker add <worker-específico> como binario de lanzamiento) o como parte del punto de entrada compuesto que los inicia juntos.

La razón por la que esto importa: cada recuadro en esa tabla es un lugar donde alguien puede darte un worker diferente, y tú te quedas con el resto. ¿No te gusta el catálogo de modelos estático? Conecta un worker que registre models::list y lea de una API en vivo. ¿No te gustan las credenciales respaldadas por archivos? Conecta un worker que registre auth::get_token y lea de un gestor de secretos. ¿Quieres un FSM de turnos diferente para un flujo de trabajo que se ramifica de otra manera? Reemplaza turn-orchestrator, cada dependiente llama a run::start y lee turn_state a través del mismo bus, por lo que el resto de la pila no cambia.

Cómo se ejecuta realmente el bucle

La forma de un turno se ve así, recorriendo los workers en el orden en que se disparan.

Un navegador/CLI/chat POSTea un turno a través de harness::trigger con {session_id, message_id, payload}. El meta-worker del arnés reenvía el payload a run::start. Ese salto existe para que el envoltorio de span de OpenTelemetry pueda sembrar los IDs de sesión y mensaje como equipaje, que se propaga a cada llamada anidada de iii.trigger a través de todos los workers en la pila. El árbol de trazas al otro lado es un grafo conectado.

run::start llega al turn-orchestrator. Persiste la solicitud de ejecución, siembra el registro TurnStateRecord inicial en el estado de iii en session/<sid>/turn_state, y regresa inmediatamente. El trabajo real ocurre dentro de la máquina de estados durable, despertada por publicaciones en la FIFO turn-step.

Los dos estados terminales son stopped (salida limpia a través de finishSession()) y failed (una excepción inesperada de un manejador llega aquí, confirma la cola para que deje de reintentar, y muestra message_complete{stop_reason:'error'} más agent_end para que la UI muestre la razón). El desmantelamiento es un puerto finishSession() en línea llamado desde cualquier camino de fin de turno, no un paso encolado separado.

provisioning hace tres cosas. Inicia una microVM iii-sandbox si la ejecución necesita aislamiento. Llama a directory::skills::download para cada espacio de nombres en system_default_skills (por defecto ["iii://iii-directory/index"]) para que iii-directory precargue en caché los cuerpos de skill con los que comienza la ejecución. Y ensambla el prompt del sistema en tres capas: un párrafo de modo elegido de run_request.mode (plan, ask o agent), el preámbulo de identidad de iii que enseña al modelo la convención agent_trigger y el patrón de descubrimiento bajo demanda directory::skills::get, y un índice adjunto de las skills predeterminadas con las que arranca el agente. El llamante puede sobrescribir todo el prompt pasando system_prompt en run::start; de lo contrario, el orquestador lo construye. Los esquemas de función provienen del catálogo del motor en vivo.

assistant_streaming llama a provider::<nombre>::stream en el worker de proveedor que coincida con el campo provider de la ejecución. El worker del proveedor obtiene credenciales a través de auth::get_token (auth-credentials), transmite la respuesta SSE del modelo a un canal de iii, y el orquestador drena ese canal emitiendo eventos message_update en agent::events para el fanout de la UI. La creación del canal y el bucle de lectura viven detrás de un MessagePump basado en pull en provider-stream.ts, por lo que el estado de streaming se mantiene enfocado en las transiciones.

Cuando el asistente devuelve llamadas de herramienta, el FSM entra en function_execute. Cada llamada de herramienta pasa por dispatchWithHook, el único punto de estrangulamiento en el orquestador. consultBefore llama a policy::check_permissions directamente con un tiempo de espera de 5 segundos. El worker de políticas (el meta-worker del arnés, en la pila predeterminada) lee iii-permissions.yaml, compara el function_id de la llamada con el conjunto de reglas, y devuelve uno de tres resultados:

  • allow: el despacho procede; el orquestador dispara la función objetivo y escribe el resultado
  • deny: el despacho se cortocircuita con un DenialEnvelope, el resultado se convierte en un registro de denegación
  • needs_approval: la llamada individual se estaciona en la lista awaiting_approval del turno. El resto del lote sigue despachando. El turno transiciona a function_awaiting_approval solo cuando hay una o más entradas pendientes.

La reactivación de aprobación es reactiva y compartida. El orquestador registra exactamente un disparador de estado turn::on_approval en el ámbito approvals. Cuando la consola llama a approval::resolve, el worker approval-gate escribe approvals/<sid>/<cid> = {decision, reason} en el estado de iii. Esa escritura dispara turn::on_approval, que avanza la sesión afectada. function_awaiting_approval lee solo las decisiones que acaban de llegar, despacha cada una a medida que llega (allow se convierte en un despacho preaprobado, deny o aborted se convierte en una denegación sintética), y avanza cuando awaiting_approval[] está vacío. No hay funciones de reanudación por llamada que registrar. No hay re-escaneo de inicio para recuperar aprobaciones pendientes. Un solo disparador cubre cada sesión.

A prueba de fallos por construcción: si el worker de políticas es inalcanzable o el tiempo de espera de 5 segundos expira, consultBefore deniega la llamada con un envoltorio gate_unavailable. Si iii::durable::publish mismo falló, el fanout de hooks devuelve publish_failed: true y el orquestador lo trata como una denegación.

Algunas ganancias de latencia surgen de esta forma. El hook posterior a la llamada de función cortocircuita publish_collect a través de un caché de presencia de suscriptores cuando no hay ningún suscriptor durable registrado para el tema, eliminando aproximadamente 500 ms por llamada de función ejecutada. tearing_down está en línea en finishSession(), eliminando un salto de cola durable por turno. context-compaction se suscribe a una transmisión dedicada agent::turn_end que el orquestador emite en los límites de los turnos, por lo que las activaciones del compactador son por turno en lugar de por evento. El disparador de estado session-create fanout se activa solo por ámbito y coincide en proceso, por lo que la llamada RPC anterior harness::session::is_create_event por escritura ha desaparecido.

Después de que el lote se completa, steering_check decide si continuar, detenerse o alcanzar max_turns. Si continúa, vuelve a assistant_streaming. Si se detiene o alcanza el máximo, finishSession() se ejecuta en línea: emite agent_end, libera el sandbox, transiciona a stopped.

Durante toda la ejecución, cada worker que participa emite spans de OTel etiquetados con iii.session.id, iii.message.id y iii.function.id. Esas etiquetas son lo que el engine::traces::group_by del motor lee para poblar "Agrupar por sesión" / "Agrupar por mensaje" / "Agrupar por función" en la UI de trazas. La instrumentación es automática: src/runtime/worker.ts envuelve cada registerFunction en un Proxy para que ningún código por worker tenga que recordar agregar spans.

Construye el tuyo

La parte interesante es que ninguno de los workers anteriores es especial. Cada uno es un proceso que abre un WebSocket al motor, registra algunas funciones y disparadores, y se ejecuta. El contrato es el mismo que el contrato que usa todo worker de aplicación. El arnés está construido sobre el mismo primitivo sobre el que está construida tu lógica de negocio.

Lo que significa que "construir tu propio arnés" se descompone en la misma operación que "escribir cualquier worker". Tú eliges la capa que quieres reemplazar, escribes un worker que registre las mismas funciones en el bus, ejecutas iii worker add, y el resto de la pila comienza a usar tu worker.

Dos capas no aparecen en la tabla de workers anterior pero son importantes para cómo se comporta el arnés. Las skills son cómo cada worker anuncia lo que hacen sus funciones. Cada worker puede publicar una skill en iii://<worker>/<function> que el agente obtiene a través de directory::skills::get antes de llamar a esa función por primera vez. El prompt del sistema se ensambla por turno a partir de un párrafo de modo, el preámbulo de identidad de iii y los cuerpos de skill predeterminados con los que se configuró la ejecución. Ambos son impulsados por el bus: las skills son servidas por el worker iii-directory, el prompt del sistema es ensamblado por el turn-orchestrator. Ambos son reemplazables.

Cinco ejemplos concretos.

Reemplazar el catálogo de modelos con una API en vivo. Escribe un worker que registre models::list, models::get, models::supports. Haz que obtenga desde el endpoint de catálogo de tu proveedor cada N minutos y lo almacene en caché. Publícalo. iii worker add tu-org/dynamic-models-catalog. Detén el worker models-catalog estático. El turn-orchestrator nunca nota la diferencia. Llama a iii.trigger('models::list') y el motor enruta al worker que registró ese id de función más recientemente.

Agregar un nuevo proveedor. La forma de provider-kimi y provider-lmstudio ya lo demuestran. Cada uno es un worker que registra provider::<nombre>::stream y provider::<nombre>::complete, drena un flujo SSE de la API ascendente a un canal de iii, y escribe su uso de modelo a llm-budget a través de budget::record. Agregar un quinto proveedor es escribir una carpeta con un iii.worker.yaml y un register.ts. Publica en el registro, o mantenlo local. El turn-orchestrator elige el proveedor por el campo provider de la ejecución; los nuevos proveedores están disponibles en el instante en que el worker se conecta.

Servir skills desde un almacén de artefactos privado. Escribe un worker que registre directory::skills::get y directory::skills::list, respaldado por tu sistema de documentos interno o un bucket S3 privado. Desconecta o renombra el worker iii-directory predeterminado. El bootstrap del orquestador llama a directory::skills::download por espacio de nombres; tu worker responde. El patrón del agente de "obtener la skill por función antes de llamar a una nueva función" sigue funcionando sin cambios porque la forma del cable es la misma.

Sobrescribir el prompt del sistema por completo. run::start acepta un campo opcional system_prompt. Pásalo y el orquestador usa tu cadena textualmente, omitiendo el ensamblaje del párrafo de modo + preámbulo de identidad + apéndice de skills. Útil cuando tienes un activo de prompt existente que quieres que el arnés respete sin modificación. La descarga de skills aún se ejecuta en bootstrap, por lo que el agente mantiene el descubrimiento bajo demanda directory::skills::get incluso con un prompt personalizado.

Reemplazar la superficie de UI de la puerta de aprobación. El worker approval-gate predeterminado registra approval::resolve. El esquema del cable es una llamada de función:

El manejador persiste approvals/<sid>/<cid> = {decision, reason} en el estado de iii. El único disparador de estado turn::on_approval del orquestador recoge esa escritura y despierta la sesión correcta. Si quieres manejar las aprobaciones desde Slack en lugar de la consola, escribe un worker de Slack que escuche comandos de barra /approve <id> y /deny <id>, luego llame a approval::resolve con el payload correcto. El orquestador nunca nota la diferencia. Todo el worker approval-gate permanece intacto. Agregaste un nuevo worker; no reemplazaste el existente.

Si quieres un motor de políticas diferente (OPA, Cedar, tu propio DSL), escribe un worker que registre policy::check_permissions y devuelva { decision, rule_id?, matched_constraint? }. Desconecta el worker de políticas predeterminado (que está envuelto dentro del meta-worker del arnés, por lo que deshabilitarías ese manejador o ejecutarías un meta-worker reducido). El consultBefore del turn-orchestrator no nota la diferencia. Mismo tiempo de espera de 5 segundos, mismas semánticas de cierre ante fallos, misma forma del cable.

El punto de estos ejemplos no son los reemplazos específicos. Es la forma de la operación. Cada capa del arnés en la pila de iii es alcanzable a través de uno o dos IDs de función en el bus. Reemplazar una capa es escribir un worker que registre esos IDs. El resto del sistema permanece.

El arnés es un control deslizante, no una bifurcación en el camino

El debate clásico sobre arneses se enmarca como delgado vs grueso. El bucle delgado de Anthropic versus el DAG explícito de LangGraph. El marco asume que eliges un lado y vives con él.

Cuando el arnés está compuesto de workers en el mismo bus, delgado vs grueso es solo un recuento de cuántos workers instalas. Un arnés delgado es turn-orchestrator más provider-anthropic más auth-credentials más un meta-worker de arnés mínimo. Eso es todo. Sin aprobaciones, sin presupuestos, sin motor de políticas, sin fanout de hooks. Ejecuta cualquier cosa. Confía en el modelo. Útil para agentes de investigación autónomos, bucles experimentales, cualquier cosa interna.

Un arnés grueso son los trece workers más context-compaction más un worker de políticas personalizado más un approval-gate personalizado más una superficie de aprobación integrada con Slack más el worker de presupuesto que aplica límites por espacio de trabajo. Útil para un agente que ejecuta flujos de trabajo de clientes donde cada llamada de herramienta debe ser auditable y cada gasto de modelo debe acumularse en un panel financiero.

La distancia arquitectónica entre delgado y grueso no es una reescritura. Es un cambio de configuración. Mismo protocolo de cable, misma forma de traza, misma historia de observabilidad. El control deslizante se mueve agregando y eliminando workers de tu config.yaml. Todo lo demás se mantiene.

Se aplica también dentro de un solo worker. El turn-orchestrator acaba de enviar una refactorización que colapsó su FSM de once estados a siete, eliminó el mecanismo turn::approval_resume::<sid>/<cid> por llamada en favor de un único disparador de estado reactivo turn::on_approval en el ámbito approvals, y puso tearing_down en línea dentro de un puerto finishSession(). Cada otro worker en la pila (approval-gate, session, llm-budget, providers, models-catalog, auth-credentials, hook-fanout, context-compaction) permaneció sin cambios. La forma del cable de approval::resolve no se movió. Los contratos se mantuvieron. Esa es la propiedad que te da la composición: una reescritura interna importante de un worker es un cambio autónomo porque cada vecino se comunica con él a través de IDs de función a nivel de bus.

Esta es la parte que el modelo de framework no puede darte. Un framework elige una posición en el control deslizante por ti y te bloquea. El modelo de worker deja el control deslizante en tu mano.

Lo que esto significa en la práctica

Si has estado ejecutando un agente sobre un framework y sintiendo los mismos problemas de límites que la mayoría de los equipos encuentran a escala, la respuesta probablemente no sea "reescribir el arnés en nuestro propio framework". El motor de políticas no se extiende como necesitas. La UI de aprobación está conectada a la superficie de chat del framework. El almacén de credenciales no puede hablar con tu gestor de secretos. El rastreador de presupuesto está en una base de datos sidecar que la traza no puede ver. La respuesta es cambiar a un sustrato donde el arnés está descompuesto desde el principio.

La forma más rápida de sentir el argumento es clonar github.com/iii-hq/workers, pnpm install, pnpm build, y ejecutar el punto de entrada compuesto. Obtendrás el arnés completo de catorce workers apuntando a un motor iii. Puedes deshabilitar cualquier worker eliminando su entrada de la lista de arranque. Puedes intercambiar cualquier worker escribiendo un reemplazo que registre los mismos IDs de función. Puedes extender cualquier worker agregando un suscriptor a sus temas de hook. hook-fanout::publish_collect es el genérico sobre el que se construye todo hook de iii.

Los docs viven en iii.dev/docs. El motor está en github.com/iii-hq/iii. El registro de workers está en workers.iii.dev. El paquete del arnés está en github.com/iii-hq/workers/harness.

La apuesta

Un arnés no es algo que instalas. Un arnés es un conjunto de trabajos que tu sistema debe hacer para que un agente se ejecute de manera duradera, segura y observable. La era de los frameworks agrupó esos trabajos porque nada por debajo te daba una manera de componerlos.

La apuesta de iii es que un primitivo: un worker que se conecta al motor a través de WebSocket y registra funciones y disparadores, es lo suficientemente pequeño para absorber cada uno de esos trabajos por separado, y que la pila resultante es más útil que cualquier framework porque cada capa es reemplazable de forma independiente.

No adoptas el arnés iii. Instalas los workers que quieres, escribes los que necesitas, y terminas con un arnés con la forma exacta de tu sistema. Mismo protocolo en cada capa. Misma traza a través de cada llamada. Mismo iii worker add para las partes que tomas del registro que para las partes que publicas tú mismo.

Así es como se ve "construir tu propio arnés de agente" cuando el sustrato tiene la forma correcta. Elige los workers. Escribe los que faltan. Compón. El arnés es la composición.

Únete a nosotros para construir el arnés de agente perfecto que el mundo moderno necesita: discord.gg/iiidev

iii es de código abierto. Comienza en iii.dev/docs. Los workers del arnés están en github.com/iii-hq/workers y el motor está en github.com/iii-hq/iii.

— Mike Piccolo, Fundador y CEO @iiidevs

Guardar con un clic

Lee artículos virales en profundidad con IA en YouMind

Guarda la fuente, haz preguntas concretas, resume el argumento y convierte un artículo viral en notas reutilizables en un único espacio de trabajo con IA.

Explora YouMind
Para creadores

Convierte tu Markdown en un artículo de 𝕏 impecable

Cuando publicas tus propios textos largos, dar formato en 𝕏 a imágenes, tablas y bloques de código es un fastidio. YouMind convierte un borrador completo en Markdown en un artículo de 𝕏 impecable y listo para publicar.

Prueba Markdown a 𝕏

Más patrones por descifrar

Artículos virales recientes

Explorar más artículos virales