Boilerplate completo para construir soluciones agénticas sobre IBM watsonx Orchestrate (ADK 2.x) con una capa web encima. Destilado de cotemar-poc-n1 y dun-casos-prueba. Incluye: - 11 docs (best practices, architecture patterns, ADK cheatsheet, observability, tool authoring, deployment, RUNBOOK, DEPLOY_TO_NEW_WOX, known-issues con 16 errores reales y fix, eval strategy, INDEX) - 13 templates WxO (orchestrator/specialist/single-meta agents, 3 connections, KB + runbook, observable_tool decorator, coercion helpers, Python tools, OpenAPI spec, backend filter endpoint, webhook validator HMAC, MCP connection) - 6 scripts (deploy idempotente con fallback ADK, undeploy, reset, check-adk-version, new-specialist scaffold, eval-agents runner) - 4 eval pieces (linter de best practices, runner, smoke-test, direct backend probe) + scenario templates - 8 subagentes Claude (.claude/agents/) — wxo-architect, wxo-agent-author, wxo-tool-author, runbook-author, mock-builder, backend-tool-builder, eval-author, web-layer-builder - Skill bundle fit-wxo-bootstrap/ (SKILL.md + 3 templates) listo para copiar a ~/.claude/skills/ - Web layer default FastAPI+HTMX con vista timeline observable + endpoint receptor de trazas - Docker compose Coolify-ready (healthcheck wget -qO-, sin labels Traefik, network split internal:true) - CI Gitea workflow con lint + smoke Best practices enforced por evals/lint_wxo_yaml.py: - A1: máx 10 tools por agente - A3: orchestrator sin tools de remediación - A6: agente sin propósito (react sin tools ni KB) - T1: @observable_tool obligatorio, no @tool directo - T3: _compat shim inline en tools.py - D1: docker-compose sin wget --spider - I-005: OpenAPI con description per-operation - I-009: sin labels Traefik manuales Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Known Issues — Errores reales con su fix
Cada item acá fue pagado en sangre por Cotemar o Dun. Cuando te toque debuggear, leé primero, googleá después.
I-001 — recursion_limit reached (langgraph)
Síntoma: después de 2-3 tool calls el run muere con
GraphRecursionError.
Causa: WxO usa langgraph internamente con recursion_limit=30
hardcoded (no configurable vía YAML ni API). Cada turno ReAct quema
~10 frames.
Fix: Si tu flujo es lineal y conocido, patrón meta-tool: colapsá
N tools en una sola run_full_case(id) que el backend orquesta
internamente. El agente solo invoca esa tool.
# Antes: agent llama 5 tools en orden
@tool def get_company(id): ...
@tool def search_credits(id): ...
@tool def build_profile(id): ...
@tool def package_zip(id): ...
@tool def upload(id): ...
# Después: agent llama 1 meta-tool
@tool
def run_full_case(case_id):
# Backend orquesta los 5 pasos internamente
# cada uno emite write_audit(...)
return {"package_url": "...", "audit_url": "..."}
Origen: Dun commit 895f5ee feat(orchestrate): consolidar 11 tools → run_full_case.
I-002 — Llama 3.3 70B printea tool calls como texto
Síntoma: después del turno ~3, el agente "responde" con texto que literalmente dice:
Tool call: reset_password(username="juan.perez@cotemar.com")
en lugar de invocar la tool.
Causa: Drift del modelo Llama 3.3 70B con tool calling. Documentado por IBM, mitigación recomendada: cambiar de modelo.
Fix:
- Cambiar
llm:agroq/openai/gpt-oss-120ben el YAML del agente. - Agregar REGLA #0 explícita en el prompt:
"NUNCA escribas la tool call como texto. Usá el protocolo de tool_calls."
Origen: Dun commit 84be316.
I-003 — Pydantic 422 "value is not a valid integer"
Síntoma: Una tool falla con ValidationError: value is not a valid integer aunque vos jurás que mandás un int.
Causa: El LLM stringifica todo. Manda "95727067" cuando el schema
es int. Pydantic v2 strict mode rechaza.
Fix: Coerción explícita en el schema:
from pydantic import BaseModel, field_validator
def _coerce_int(v):
if isinstance(v, str) and v.strip().isdigit():
return int(v)
return v
def _coerce_list(v):
if isinstance(v, str):
import json
try: return json.loads(v)
except: return [v]
return v
class ResetPasswordInput(BaseModel):
user_id: int
groups: list[str]
_coerce_user_id = field_validator("user_id", mode="before")(_coerce_int)
_coerce_groups = field_validator("groups", mode="before")(_coerce_list)
El template trae estos helpers en
wxo/tools/python/_coercion_helpers.py.
Origen: Dun commit 3cdf3bf.
I-004 — kid not found al agents deploy --env live
Síntoma: import del agente sale OK al draft, pero deploy --env live
falla con kid not found.
Causa: Las conexiones que el agente usa solo existen en el env draft.
La promoción a live requiere que las connections existan también en live.
Fix: Configurar connection en ambos envs:
for ENV in draft live; do
orchestrate connections configure -a mi_app --env $ENV --type team --kind key_value
orchestrate connections set-credentials -a mi_app --env $ENV -e "BASE_URL=..."
done
El deploy-wxo.sh del template ya lo hace.
Origen: Cotemar (debug session 2026-05-13).
I-005 — OpenAPI import: "No description provided"
Síntoma: orchestrate tools import -k openapi falla con No description provided for operation X.
Causa: ADK exige description por operación. FastAPI genera solo
summary cuando la función no tiene docstring.
Fix: Al exponer el spec, forzar el fallback:
# En tu endpoint /orchestrate-tools-spec.json
for path_item in spec["paths"].values():
for op in path_item.values():
if isinstance(op, dict) and "responses" in op:
op["description"] = op.get("description") or op.get("summary") or "..."
Origen: Dun backend/app/api/v1/spec.py:60-66.
I-006 — OpenAPI security no se hereda
Síntoma: Spec tiene security: global, pero ADK importa los tools
sin auth.
Causa: ADK no respeta security global. Requiere per-operation.
Fix: Re-declarar en cada op:
for path_item in spec["paths"].values():
for op in path_item.values():
if isinstance(op, dict) and "responses" in op:
op["security"] = spec.get("security", [])
Origen: Dun commit 1bc04cf.
I-007 — Embed widget queda en "Cargando agente..."
Síntoma: Tu landing page con <script src=".../webchat.js"> carga,
pero el widget muestra para siempre el spinner.
Causa: Embed Security ON en la configuración del tenant. Sin credenciales anónimas el widget no puede iniciar la sesión.
Fix manual (UI):
WxO Console → Settings → Embed Security → Off
Documentado en IBM: https://developer.watson-orchestrate.ibm.com/channels/establishing_channels
Origen: Cotemar (2 horas de debug).
I-008 — Coolify/Traefik 503 después de deploy
Síntoma: El container está corriendo (docker ps muestra healthy en
status... eventualmente unhealthy), pero el dominio público devuelve 503.
Causa: Healthcheck con wget --spider es buggy en busybox alpine —
a veces marca unhealthy aunque el endpoint responda. Traefik excluye el
backend.
Fix: Cambiar a wget -qO- o curl -sf:
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
# o
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
Origen: Cotemar (debug Coolify).
I-009 — Coolify cert "TRAEFIK DEFAULT CERT"
Síntoma: El dominio sale con cert de Traefik default en vez de Let's Encrypt.
Causa: Declaraste labels Traefik manuales en docker-compose.yml.
Coolify v4 los autogenera del panel FQDN.
Fix: Borrar todos los traefik.http.routers.* del compose. Dejar
solo:
labels:
- "traefik.enable=true"
- "traefik.docker.network=coolify"
Y configurar el FQDN desde el panel de Coolify.
Origen: Dun commit 373a336.
I-010 — Tool error ModuleNotFoundError: wxo.tools._compat
Síntoma: El tool falla en runtime con import error referenciando
_compat.
Causa: ADK importa cada tools.py standalone, sin el package padre.
Los imports relativos del estilo from .._compat import X fallan.
Fix: Inlinear el shim al inicio del archivo:
# tools/python/mi_tool.py
# === inline compat shim (NO eliminar — TRM lo necesita) ===
try:
from ibm_watsonx_orchestrate.agent_builder.tools import tool
except ImportError:
def tool(*args, **kwargs):
def deco(f): return f
return deco if not args else deco(args[0])
# ============================================================
@tool(name="mi_tool", ...)
def mi_tool(...):
...
El template ya viene así. No reorganizar los imports.
Origen: Cotemar.
I-011 — .env no se recarga con restart
Síntoma: Cambiás .env y hacés docker compose restart, pero el
container sigue con los valores viejos.
Causa: restart no relee env vars. Solo up lo hace.
Fix:
docker compose up -d --force-recreate
Origen: Dun INSTRUCCIONES.md:159-164.
I-012 — Caso atascado en executing
Síntoma: Un caso queda para siempre en estado executing. Polls
desde la UI no avanzan.
Causa: La conexión Orchestrate → backend se cayó silenciosamente durante el run. El backend nunca recibió el callback final.
Fix: Watchdog en el GET del caso:
@router.get("/cases/{case_id}")
def get_case(case_id):
case = db.get(case_id)
if case.status == "executing":
age = datetime.utcnow() - case.last_update
if age > timedelta(seconds=90):
case.status = "paused"
db.commit()
return case
Origen: Dun commit 23b815e.
I-013 — asyncio.create_task deja casos colgados
Síntoma: A veces el start_run falla silenciosamente y el caso
queda en estado inicial sin error.
Causa: asyncio.create_task(orchestrate_start(...)) no espera ni
captura excepciones.
Fix: Usar await + wrapper que captura:
async def _start_run_safe(case_id, ...):
try:
await orchestrate.start_run(...)
except Exception as e:
logger.exception("start_run failed")
await db.update_case(case_id, status="failed", error=str(e))
await _start_run_safe(case_id, ...)
Origen: Dun commit fa47f14 + abb5b73.
I-014 — Subcomando ADK cambió de nombre
Síntoma: Tu script de deploy falla porque connections set-identifier
no existe (o connections configure --url no existe).
Causa: Entre versiones del ADK los subcomandos se renombran.
Fix: Fallback en el script:
orchestrate connections set-identifier --url "$URL" -a mi_app 2>/dev/null \
|| orchestrate connections configure --url "$URL" -a mi_app 2>/dev/null \
|| echo "warning: no compatible subcommand found"
Origen: Dun deploy.sh:65-74.
I-015 — Frontend descarga el index.html en vez del archivo real
Síntoma: Click en un botón de descarga, abre el archivo y resulta ser el HTML de la SPA.
Causa: La función downloadAuthenticated confunde fetchUrl con
blobUrl (misma variable). El navegador redirige a la SPA.
Fix:
async function downloadAuthenticated(fetchUrl: string, filename: string) {
const res = await fetch(fetchUrl, { headers: authHeaders });
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob); // OJO: variable distinta
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(blobUrl);
}
Origen: Dun commit 4e7e3fd.
I-016 — Polling para de actualizar después de approve
Síntoma: UI hace polling cada 1.5s. Click en "approve" y el polling muere.
Causa: El useEffect con setInterval no re-agenda el timer
después del fetch.
Fix: Re-agendar dentro del callback del fetch:
useEffect(() => {
let timer: number;
const poll = async () => {
const data = await fetchCase(id);
setData(data);
if (!["done", "failed", "paused"].includes(data.status)) {
timer = window.setTimeout(poll, 1500);
}
};
poll();
return () => clearTimeout(timer);
}, [id]);
Origen: Dun commit 83fb85c.
Si pasaste varias horas buscando un bug y al final encontraste el fix, agregalo acá. Este archivo es el repositorio de "lecciones que no quiero volver a pagar".