feat: v1 — boilerplate WxO + web
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>
This commit is contained in:
407
docs/known-issues.md
Normal file
407
docs/known-issues.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# 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.
|
||||
|
||||
```python
|
||||
# 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:**
|
||||
1. **Cambiar `llm:` a `groq/openai/gpt-oss-120b`** en el YAML del agente.
|
||||
2. 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:
|
||||
|
||||
```python
|
||||
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`](../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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```python
|
||||
# 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:
|
||||
|
||||
```python
|
||||
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`:
|
||||
|
||||
```yaml
|
||||
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:
|
||||
|
||||
```yaml
|
||||
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:
|
||||
|
||||
```python
|
||||
# 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:**
|
||||
```bash
|
||||
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:
|
||||
|
||||
```python
|
||||
@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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```bash
|
||||
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:**
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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".
|
||||
Reference in New Issue
Block a user