Una herramienta puede funcionar sin incidentes durante semanas y seguir ocultando una bomba de relojería. Añades a tu Agent una acción para «consultar las publicaciones públicas de una plataforma», cambias el valor predeterminado de limit de 25 a 20 y sumas una opción al enum de sort. El código pasa los tests, se integra y llega a producción.
Tres días después empiezan los errores esporádicos. El modelo invoca la herramienta con un valor del enum que eliminaste la semana anterior; la validación en tiempo de ejecución lo rechaza y el stack trace señala la capa de dispatch. Te pasas media hora revisándola, aunque no tiene ni una línea incorrecta. El problema está en otro lugar: modificaste la firma de la función, pero no actualizaste la descripción de herramienta que lee el modelo. El modelo sigue trabajando con el esquema anterior, genera llamadas para esa forma antigua y, naturalmente, ya no encajan.
Eso es la desincronización de descripciones de herramientas. Es la clase de bug más habitual y más difícil de rastrear en ingeniería de Agents, por un motivo muy concreto: el error y su causa raíz están en lugares distintos. El fallo aparece en la capa de ejecución; la causa vive en un JSON que nadie piensa en abrir. La solución no es «acordarse de mantenerlo sincronizado». Hay que eliminar estructuralmente la segunda copia para que no haya nada que pueda divergir.
El origen de la desincronización
Si separamos el problema, su raíz es simple: estás manteniendo dos fuentes de verdad.
La primera es el código que se ejecuta de verdad: la firma de la función, la validación de argumentos, los valores por defecto y las restricciones de los enums. Es la parte rígida. Si está mal, falla de forma evidente.
La segunda es la descripción de herramienta que consume el modelo: name, description y el esquema JSON de parameters. Esta es blanda. Si contiene un error, nada explota inmediatamente. El modelo genera una llamada incorrecta y el fallo termina apareciendo más abajo, en la capa de ejecución.
Mientras una persona sea responsable de mantener ambas alineadas, la divergencia no es una posibilidad, sino una cuestión de tiempo. Cambias un argumento en el código y olvidas la descripción. Cambias la descripción y olvidas el código. O modificas ambos, pero sus significados dejan de coincidir. Nada de eso se manifiesta al hacer el cambio: espera a que el modelo genere una llamada que toque esa diferencia, cuando probablemente ya has olvidado lo que editaste hace dos semanas. Solo hay una salida: convertir las dos copias en una sola.
Una única fuente de verdad: la declaración define la interfaz
El cambio de enfoque es este: en realidad no necesitas ese JSON de descripción de herramienta.
La declaración del parser de una función, junto con su docstring, ya contiene todos los campos necesarios para describir una herramienta. Así se ve una declaración convencional con argparse:
subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
"--sort",
choices=("hot", "new", "top", "rising", "controversial"),
default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)
El help aporta la descripción breve del comando. choices define la restricción del enum; default, el valor predeterminado; type, el tipo del parámetro; y el argumento posicional es el campo obligatorio. Todo lo que el modelo necesita para invocar la herramienta está ahí: qué hace el comando, qué parámetros acepta, cuáles son obligatorios, qué valores admite el enum y cuáles son los valores por defecto. Además, esa misma declaración es la que el runtime utiliza para parsear y validar, así que no puede alejarse de la lógica de ejecución: forma parte de ella.
Por tanto, deja de escribir una segunda descripción de herramienta. La postura correcta es asumir que ese documento no existe. Solo hay código y, cuando necesitas una descripción, la proyectas desde el código. La proyección va en una única dirección, del código a la descripción, nunca al revés.
Generar todo el catálogo desde las declaraciones
Cuando aceptas que la declaración es la interfaz, la descripción de las herramientas deja de escribirse a mano. Un derivador debe generarlas todas.
Su trabajo es mecánico: recorre cada contexto de plataforma, importa su parser y convierte la lista de acciones de argparse en tres estructuras inmutables: Platform, Command y Parameter. Cada Parameter contiene su nombre, tipo, indicador de obligatoriedad, valores del enum, valor por defecto y texto de ayuda. El resultado es un modelo de lectura de la interfaz derivado íntegramente del código.
A partir de ese modelo, cualquier formato de salida queda aguas abajo. describe --format json emite la interfaz completa legible por máquina para que un Agent seleccione herramientas. render_skill() genera un catálogo de capacidades que pueden leer tanto una persona como un modelo. El número de comandos del catálogo no es una constante escrita a mano: se calcula al vuelo con sum(len(platform.commands)). Ahora mismo son 22 contextos de plataforma y 241 comandos, y no se ha tecleado manualmente ni uno de ellos en el catálogo.
Esto aporta una propiedad muy cómoda. Añadir una plataforma consiste en añadir su contexto, y el catálogo incorpora todos sus comandos automáticamente. Cambiar un parámetro implica editar la declaración del parser, y el enum y el valor por defecto correspondientes se actualizan solos en el catálogo. Nunca te encuentras con «he creado un comando pero olvidé registrarlo» o «cambié un parámetro, pero el catálogo está desactualizado», porque el acto de registrar no existe. El catálogo se calcula; no se mantiene.
(Este instinto de derivar en vez de mantener es el mismo que aparece al usar operaciones de conjuntos para decidir qué se ha migrado realmente entre lenguajes, tema de la pieza sobre migraciones entre lenguajes.)
CI convierte la desincronización en un fallo al hacer commit
La derivación resuelve que «un comando nuevo entra automáticamente en el catálogo», pero queda una brecha. Alguien modifica una declaración del parser, olvida ejecutar de nuevo el derivador y no hace commit del catálogo regenerado. La copia del repositorio vuelve a estar obsoleta y la divergencia se cuela por la puerta de atrás.
La última barrera se instala en CI y se reduce a una sola aserción:
docs-check:
$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
path = Path("skill/SKILL.md"); \
assert path.read_text(encoding="utf-8") == render_skill(), \
"skill/SKILL.md is out of sync with the code; run make docs"'
Toma el catálogo versionado en el repositorio y lo compara byte a byte con otro regenerado a partir del código actual. Basta un carácter de diferencia para que CI falle, con un mensaje que indica que el catálogo está desactualizado y que hay que ejecutar make docs.
El valor de esa línea es que adelanta el momento en que detectas la divergencia. Antes era un fantasma de runtime: explotaba dos semanas más tarde en producción, con un stack trace apuntando al lugar equivocado. Ahora es una X roja en el momento del commit. El pull request se bloquea, el error te dice que el catálogo no está actualizado y regenerarlo corrige el problema. La desincronización pasa de ser «el bug más difícil de rastrear» a un error de compilación que eliminas con un comando. Ese es el ciclo completo de tratar la interfaz como código: la declaración es la fuente, el catálogo de capacidades es el artefacto de build y CI es la comprobación de tipos. No escribirías a mano un artefacto de build ni tolerarías que no coincidiera con su fuente; una descripción de herramienta merece el mismo trato.
Qué fijar en código y qué delegar al modelo
La derivación y CI garantizan que la descripción de interfaz sea correcta. Pero antes hay que tomar otra decisión: ¿una capacidad debe quedar implementada como código fijo o conviene que el modelo la orqueste en cada momento? Si esa división es errónea, una interfaz exacta no te salvará.
Conviene mirar las capacidades en tres capas.
Una primitiva de bajo nivel lee un tipo de dato o ejecuta una acción clara. Su entrada es estable, su salida está estructurada y se puede probar de manera aislada. Esta capa es código puro y no consume razonamiento. Aquí se encuentra la gran mayoría de los 241 comandos.
Un flujo de trabajo determinista es un proceso con un orden fuerte dentro de una plataforma, estado compartido y una condición clara de éxito. Piensa en un pipeline creativo, creative-pipeline, que ejecuta esta secuencia: encontrar oportunidades, después Top Ads, luego matching de creadores, un brief creativo y, por último, una comprobación previa a la generación. El orden y las dependencias entre pasos están fijados. Esta capa también debe congelarse en código, porque si el orden ya está decidido, hacer que el modelo lo planifique de nuevo cada vez es más lento y menos estable. Marcarlo requiere una línea: asignar al comando set_defaults(_command_level="workflow"). Es la única línea de este tipo en el codebase, y gracias a ella el catálogo muestra workflows y primitivas en dos niveles distintos.
La orquestación del Agent cubre investigación entre plataformas, decisiones en tiempo real y redirecciones tras un fallo. Esta es la capa que sí dejas al modelo, porque la siguiente consulta depende de lo que haya revelado la anterior y no puedes dejarlo escrito de antemano.
El criterio es bastante claro. Si una capacidad necesita estado estable por etapas, contexto compartido o efectos secundarios de generación, congélala en código. Si implica ampliar consultas, verificar entre plataformas o redirigir tras un fallo, déjala en manos del modelo. Ambos errores tienen coste. Codificar una hipótesis de investigación en el cliente es congelar demasiado: el día que cambie la plataforma, volverás a editar código. Entregar al modelo una secuencia fija para que la recomponga cada vez es congelar demasiado poco: ahorras una decisión del modelo y compras una buena dosis de inestabilidad.
Seis estados de etapa para que el modelo sepa degradar
Para que la capa de orquestación pueda decidir, los resultados que devuelve la capa inferior deben ser comprensibles para el modelo. Un booleano opaco de éxito o fracaso no basta. Si le entregas un success: false, solo puede adivinar cuál debería ser el siguiente paso.
Por eso cada etapa de un workflow devuelve un estado de etapa, no un booleano, y existen seis: completed, empty, ready, skipped, unavailable y blocked. La información importante está en distinguir los estados que no avanzaron:
skippedindica que el operador desactivó ese paso deliberadamente, por ejemplo, al establecer el límite de una ruta de recopilación en 0. No es un error y el modelo no debe reintentarlo.unavailablesignifica que algo de lo que depende ese paso no está disponible temporalmente, como una interfaz que devuelve un error o una sesión ausente. El modelo puede saltárselo y continuar, o pedir una sesión nueva y volver después.blockedsignifica que no se cumple una precondición, por ejemplo, que la evidencia de investigación está vacía o que falla la comprobación previa. El modelo no debe forzar el paso siguiente: debe volver atrás y completar la evidencia.
Volvamos al pipeline creativo. Evalúa por separado «la comprobación previa de la plataforma está lista» y «la evidencia de investigación está lista», con un ready = platform_ready and research_ready final. Si cualquiera de las dos falla, la etapa de generación devuelve blocked junto a una lista blockers que explica qué la bloquea; si todos los resultados de búsqueda comercial están vacíos, simplemente no envía el trabajo de generación.
¿Por qué este diseño beneficia al modelo? Un modelo de orquestación que lee seedance_generation: blocked junto a blockers: [research_evidence_empty] sabe que debe volver a buscar evidencia, en lugar de reintentar el envío. Si recibe organic_discovery: skipped, entiende que responde a la intención del usuario y no a un fallo, así que no lo toca. Si una etapa figura como unavailable, sabe que puede degradar alrededor de ella. Al separar «desactivado deliberadamente», «no disponible temporalmente» y «precondición no cumplida», el modelo puede elegir la ruta de degradación correcta. Si reduces las tres a false, incluso un buen modelo se limita a dar vueltas.
Un modelo para cada capa
La arquitectura anterior exige capacidades muy distintas al modelo en cada capa. (La pieza sobre ingeniería inversa en cuatro etapas presenta la misma tabla de cuatro niveles en un contexto de ingeniería inversa; aquí se aplica al stack de Agent.) Asignar un modelo por capa evita desperdiciar capacidad:
Trabajo en el stack de Agent | Capacidad necesaria | Elección | model id |
|---|---|---|---|
Cargar en contexto el JSON de | Contexto largo; lee el catálogo completo de una pasada | Kimi K3 |
|
Orquestación: leer estados de etapa y bloqueadores, decidir si degradar, redirigir o continuar | Razonamiento sólido; toma la decisión correcta según el estado | Claude Opus 5 |
|
Generar en lote texto de descripciones de herramientas apto para modelos a partir de docstrings | Económico; ejecuta cientos de llamadas con alta concurrencia | Claude Sonnet 5 |
|
Atribución de errores en llamadas de herramientas: leer el error y la declaración, decidir si es divergencia o un cambio aguas arriba | Razonamiento intermedio; explica a partir de campos concretos | GPT-5.6 Sol |
|
La capa de orquestación merece especial atención. Leer blocked y skipped para decidir el siguiente movimiento es el único paso de este flujo en el que cambiar de modelo altera visiblemente el resultado, porque evalúa precisamente si el modelo sabe decidir correctamente a partir de un estado. Un modelo más débil interpreta skipped como un fallo y lo reintenta, o ve blocked y envía la solicitud de todos modos. Un modelo con buen razonamiento lee los blockers y redirige con precisión. Es una diferencia parecida a determinar si la sección de contraevidencia realmente rebate su propia tesis en la pieza sobre fingerprinting: cualquiera puede producir el candidato; lo difícil es emitir el juicio correcto.
No tienes que aceptar esa diferencia sin comprobarla. Ponla a prueba:
Toma una respuesta real de uno de tus workflows, con sus
stagesyblockers, o fabrica una respuestablockedconblockers: [research_evidence_empty].Entrega esa respuesta, tu catálogo de capacidades —el JSON de
describe— y una instrucción para decidir la siguiente acción aclaude-opus-5ygpt-5.6-solpor separado.Fíjate en una única cosa: ¿la siguiente acción propuesta distingue correctamente
blocked—volver a buscar evidencia—,skipped—intención del usuario, no tocarlo— yunavailable—conseguir una sesión o degradar alrededor—, o reintentaskippedcomo si hubiera fallado?La proporción de rutas de degradación correctas es tu criterio de selección. Determina si tu Agent se queda girando ante un fallo real o lo rodea por sí solo.
El verdadero freno es el coste de cambiar de modelo
Los cuatro modelos proceden de tres proveedores, y el coste de cambiar entre ellos es especialmente alto en function calling. tools / tool_calls de OpenAI y tool_use / tool_result de Anthropic son formatos distintos. Si sustituyes el modelo de orquestación por otro que juzga mejor, tienes que reescribir toda tu ruta de dispatch de herramientas y análisis de errores. Esa es la razón real por la que la mayoría acaba fijando un solo modelo en la capa de orquestación, incluso cuando ese modelo interpreta mal los estados de etapa con frecuencia.
AIReiter elimina esa capa de complejidad. Una clave, una interfaz compatible con OpenAI y los cuatro modelos detrás: cambiar de modelo se reduce a modificar el campo model del cuerpo de la solicitud.
# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
}'
# Generate tool-description text in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Error attribution:
# "model": "gpt-5.6-sol"
Para function calling nativo solo tienes que añadir un array tools; el protocolo de herramientas de OpenAI atraviesa esta interfaz sin cambios, así que cambiar de modelo sigue siendo una edición de un único campo. Si ya utilizas el SDK de OpenAI, apunta base_url a https://aireiter.com/api/v1 y no modifiques nada más. Con el SDK de Anthropic, usa POST /api/v1/messages con la misma clave.
En precio, los modelos Claude tienen un 30% de descuento sobre tarifa, los modelos GPT cuestan la mitad y Kimi K3 está disponible con la misma clave. En este stack, el descuento cae donde importa. Cada avance de la capa de orquestación supone otra llamada al nivel de razonamiento, lo que la convierte en la capa más frecuente y más cara de todo el Agent; el descuento de Claude se aplica justo ahí. Generar en lote descripciones de herramientas desde 241 docstrings es trabajo de Sonnet con alta concurrencia, también descontado. Esas dos partidas concentran la mayor parte del coste. Las llamadas a GPT-5.6 para atribuir errores son mucho menos numerosas.
Pruébalo sin registrarte: haz unas cuantas rondas manuales, pasa la misma respuesta
blockeda ambos modelos y comprueba cuál degrada correctamente antes de integrar uno en la capa de orquestación.
Para cerrar
La desincronización de descripciones de herramientas no se arregla con un «recuerda sincronizarlas». Ese enfoque solo disfraza un defecto estructural como un problema de disciplina personal. La solución real es eliminar la estructura de dos fuentes: la declaración del parser y el docstring son la única fuente; el catálogo de capacidades es un artefacto de build derivado de ella, y una sola aserción de CI actúa como comprobación de tipos. La divergencia deja de ser un fantasma de runtime y se convierte en una X roja al hacer commit.
Pero la derivación solo garantiza que la descripción sea exacta. No dice nada sobre si la separación por capas es correcta. Qué capacidades congelas en código y cuáles dejas que el modelo orqueste, junto con los seis estados de etapa que permiten al modelo decidir entre «reintentar o degradar», son las dos decisiones que determinan si tu Agent puede operar por sí mismo. En este stack, el modelo desempeña dos trabajos concretos: resuelve la disyuntiva en la capa de orquestación y atribuye la causa cuando falla una llamada de herramienta. Decidir si una capacidad debe congelarse y qué ruta de degradación tomar depende de los estados de etapa que diseñes y del CI que escribas, no del modelo.
Es la misma postura que plantean las piezas sobre reconciliación de migraciones basada en conjuntos y sobre por qué no crear un Model de respuesta unificado: la IA comprime el tiempo de una etapa, pero el veredicto permanece dentro de las restricciones que codificas. Cuando todo lo demás funciona con fluidez, la única fricción que queda es cambiar de modelo, un problema de infraestructura que una interfaz unificada resuelve.