feat: v1 — boilerplate WxO + web
Some checks failed
CI — Lint + Evals / lint (push) Has been cancelled
CI — Lint + Evals / smoke (push) Has been cancelled

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:
Felipe Arentsen
2026-05-16 14:59:44 +00:00
commit b089c2ef18
70 changed files with 6739 additions and 0 deletions

218
docs/DEPLOY_TO_NEW_WOX.md Normal file
View File

@@ -0,0 +1,218 @@
# Deploy to a New WxO Tenant — Cold Start Playbook
Cuando arrancás esta solución en un tenant WxO **completamente nuevo**
(otro cliente, otro environment, fresh start), seguí este playbook
literal. Es el camino más corto desde "tengo credenciales" a "demo
funcionando".
---
## Step 0 — Pre-requisitos
- [ ] IBM Cloud account del cliente con WxO instance provisionada
- [ ] API key del tenant
- [ ] Coolify (u otro host Docker) accesible
- [ ] Dominio público con DNS apuntando al host
- [ ] Acceso a Gitea/repo para clonar el código
---
## Step 1 — Provisionar el ADK localmente
```bash
python3.12 -m venv .venv-wxo
source .venv-wxo/bin/activate
pip install --upgrade "ibm-watsonx-orchestrate==2.1.0"
orchestrate --version # 2.1.x
```
Verificá que el modelo esté disponible:
```bash
orchestrate env add -n nuevo-tenant \
--iam-url https://iam.cloud.ibm.com/identity/token \
-u "$ORCHESTRATE_API_URL"
orchestrate env activate nuevo-tenant
orchestrate models list | grep gpt-oss-120b
```
Si no aparece `gpt-oss-120b`, editá los YAMLs en `wxo/agents/` y poné
`meta-llama/llama-3-3-70b-instruct` (con la advertencia de I-002).
---
## Step 2 — Secretos
Crear `.env` desde `.env.example`:
```bash
cp .env.example .env
nano .env
```
Llenar:
- `ORCHESTRATE_API_URL`
- `ORCHESTRATE_API_KEY`
- `WATSONX_API_KEY` (si vas a usar Plan A)
- `PUBLIC_HOST`
- (Coolify) `COOLIFY_API_URL`, `COOLIFY_API_TOKEN`
---
## Step 3 — Deploy del backend / mocks
Si tu solución incluye un backend propio o mocks:
```bash
# Coolify: crear app desde el git repo, configurar env vars, deploy
# O localmente:
docker compose up -d --build
```
Verificá que el `PUBLIC_HOST/health` responde 200:
```bash
curl -sf "https://$PUBLIC_HOST/health"
```
Si da 503 → Traefik exclude por healthcheck. Issue I-008.
Si da default cert → labels manuales en compose. Issue I-009.
---
## Step 4 — Deploy del stack WxO
```bash
PUBLIC_HOST=mi-cliente.fitlabs.dev ./scripts/deploy-wxo.sh
```
El script:
1. Crea las connections (draft + live)
2. Importa los tools
3. Importa las KBs
4. Importa los agentes en orden (specialists primero)
5. Hace `deploy --env live` de cada uno
6. Crea el canal webchat
Si falla a mitad de camino:
- Re-corré el script (es idempotente)
- Si persiste, ver `docs/known-issues.md`
---
## Step 5 — Capturar IDs
```bash
orchestrate agents list
```
Buscá tu orchestrator en el env `live`, capturá:
- `agent_id` (UUID)
- `environment_id` (UUID)
Ponelos en:
- `web/.../templates/index.html` (o donde esté tu landing): `agentId` y `agentEnvironmentId`
- `.env`: `WXO_AGENT_ID`
- Coolify panel: `WXO_AGENT_ID` env var
Commit + redeploy de la landing.
---
## Step 6 — Embed Security OFF (manual, UI)
**Esto es manual. No hay API.**
1. WxO Console → Settings
2. Embed Security → **Off**
3. Confirmar
Sin esto, el widget de chat queda en "Cargando..." para siempre.
Issue I-007.
---
## Step 7 — Smoke test
```bash
./evals/smoke-test.sh
```
Si pasa, validá manualmente:
```bash
# Abrir el chat
open "https://$PUBLIC_HOST/"
```
Tirale al chat tu primer scenario. Verifica que:
1. El agente responde
2. El tool se llama (mirá `docker logs <web>` para ver las trazas)
3. El resultado final aparece en la UI (kanban / control plane / lo que sea)
---
## Step 8 — Direct backend probe (diagnóstico)
Si el chat funciona pero ves errores, ejecutá:
```bash
./evals/direct-backend-probe.sh
```
Esto llama tu backend directamente con curl + el token de WxO. Sirve
para aislar:
- **200 OK con error de lógica** → el backend está bien, el problema
está en la conversación con el agente
- **401/403** → problema de auth / connection / credentials
- **502/504** → problema de red entre WxO y tu backend (firewall, DNS,
Cloud Run cold start, etc.)
---
## Step 9 — Activar Plan A (webhook → Runs API)
Si tu solución tiene auto-trigger desde un sistema externo:
1. Capturá el `WXO_AGENT_ID` (paso 5)
2. Asegurate que `WATSONX_API_KEY` está en `.env`
3. Re-deployá el backend/mocks (`docker compose up -d --force-recreate`)
4. Probá disparando un evento desde el sistema externo
5. Mirá los logs: debería ver `→ POST /v1/orchestrate/runs ... 200`
Issue I-011 si los env vars no se actualizan: `restart` no relee `.env`,
usar `--force-recreate`.
---
## Step 10 — Set up monitoring básico
- Coolify alerta por email si el container reinicia >3 veces en 5 min
- Healthcheck endpoint custom si necesitás
- Logs accesibles desde Coolify panel
- `./evals/smoke-test.sh` corriendo nightly desde CI
---
## Checklist final
- [ ] Step 0 — Pre-reqs listos
- [ ] Step 1 — ADK provisionado + modelo disponible
- [ ] Step 2 — `.env` completo
- [ ] Step 3 — Backend/mocks responden 200 en /health
- [ ] Step 4 — `./scripts/deploy-wxo.sh` sin errores
- [ ] Step 5 — IDs capturados y propagados
- [ ] Step 6 — Embed Security OFF
- [ ] Step 7 — Smoke test pasa
- [ ] Step 8 — Direct probe responde 200
- [ ] Step 9 — (si aplica) Plan A funciona
- [ ] Step 10 — Monitoring básico
Cuando los 10 estén ✅, estás listo para demo.
---
## Si algo se rompió en producción
Ver `docs/RUNBOOK.md` para recovery procedures.

35
docs/INDEX.md Normal file
View File

@@ -0,0 +1,35 @@
# Index — Doc de docs
Mapa de toda la documentación del boilerplate. Si no sabés por dónde empezar, leé en este orden.
## Para empezar (lectura obligatoria, 15 min)
1. [`../README.md`](../README.md) — Filosofía del template, qué hay acá
2. [`../INSTRUCCIONES.md`](../INSTRUCCIONES.md) — Quickstart de 5 pasos
3. [`wxo-best-practices.md`](wxo-best-practices.md) — Las 30+ reglas que el linter enforcea
## Para diseñar (al arrancar un caso nuevo)
4. [`architecture-patterns.md`](architecture-patterns.md) — Árbol de decisión del `wxo-architect`
5. [`tool-authoring-guide.md`](tool-authoring-guide.md) — Python vs OpenAPI vs MCP
6. [`eval-strategy.md`](eval-strategy.md) — Qué validar y cómo
## Para implementar
7. [`adk-2x-cheatsheet.md`](adk-2x-cheatsheet.md) — Comandos ADK, YAML schemas, gotchas
8. [`observability-pattern.md`](observability-pattern.md) — Contrato `@observable_tool` + `write_audit`
## Para desplegar
9. [`deployment-guide.md`](deployment-guide.md) — Deploy genérico parametrizable
10. [`DEPLOY_TO_NEW_WOX.md`](DEPLOY_TO_NEW_WOX.md) — Cold start playbook
## Para operar
11. [`RUNBOOK.md`](RUNBOOK.md) — URLs, monitoring, recovery, rotation
12. [`known-issues.md`](known-issues.md) — Errores con su fix
## Apéndices
- [`../skill/fit-wxo-bootstrap/SKILL.md`](../skill/fit-wxo-bootstrap/SKILL.md) — Cómo funciona la skill
- [`../.claude/agents/`](../.claude/agents/) — Los 7 subagentes Claude del template

178
docs/RUNBOOK.md Normal file
View File

@@ -0,0 +1,178 @@
# RUNBOOK — Operaciones
Cómo monitorear, recuperar, rotar y troubleshootear una solución
desplegada con este template.
## URLs y endpoints clave
| URL | Para qué |
|---|---|
| `https://$PUBLIC_HOST/` | Landing con chat embed |
| `https://$PUBLIC_HOST/web/` | App principal (UI) |
| `https://$PUBLIC_HOST/health` | Healthcheck (200 = todo OK) |
| `https://$PUBLIC_HOST/api/traces` | Vista timeline de tool calls (admin) |
| WxO Console | Settings, agents, channels |
| Coolify panel | Deploy, logs, env vars |
## Monitoring
### Healthcheck simple
```bash
curl -sf "https://$PUBLIC_HOST/health" && echo OK || echo FAIL
```
### Smoke test nightly
Configurar CI/cron que corra cada noche:
```bash
./evals/smoke-test.sh
```
Si falla, alerta por email/Slack.
### Logs en tiempo real
```bash
# Local
docker compose logs -f --tail=100
# Coolify
# Panel → app → Logs tab
```
Buscar:
- `ERROR` o `Exception` en stdout
- `recursion_limit` (issue I-001)
- `kid not found` (issue I-004)
- `422 Unprocessable Entity` (issue I-003)
## Recovery procedures
### El chat embed quedó en "Cargando..."
1. Settings → Embed Security debe estar **Off**. Issue I-007.
2. Verificar que el `agentId` y `agentEnvironmentId` del HTML
coincidan con `orchestrate agents list --env live`.
3. Browser console: buscar errores CORS o 401.
### Casos atascados en `executing` (issue I-012)
El watchdog del backend los promueve a `paused` automáticamente
después de 90s. Si no pasa:
1. Ver logs: `grep "case_id=XXXX"`
2. Si Orchestrate jamás llamó al callback, el run murió: re-trigger desde la UI
3. Si el callback llegó pero la DB no actualizó, ver issue I-013
### El agente printea tool calls como texto (issue I-002)
1. Verificar `llm:` en el YAML del agente = `groq/openai/gpt-oss-120b`
2. Verificar que REGLA #0 está en el prompt
3. Si persiste con gpt-oss-120b → reportar a IBM, es regresión del modelo
### Pydantic 422 en tool calls (issue I-003)
1. Ver el payload exacto que el LLM mandó (logs del backend)
2. Si stringificó algo que debería ser int/list → falta `@field_validator(mode="before")`
3. Agregar el validator, redeployar el backend
### Deploy WxO falla con `kid not found` (issue I-004)
```bash
# Re-configurar la connection en live
orchestrate connections configure -a <app_id> --env live --type team --kind <kind>
orchestrate connections set-credentials -a <app_id> --env live -e "BASE_URL=..."
# Re-deployar agente
orchestrate agents deploy --name <agent> --env live
```
### Coolify dio 503 después de redeploy (issue I-008)
1. `docker logs <container>` → buscar healthcheck failures
2. Cambiar `wget --spider` a `wget -qO-` en el Dockerfile
3. Rebuild
### Backend OK pero WxO no llega (issue I-008/I-009)
```bash
# Probar desde fuera
curl -sf "https://$PUBLIC_HOST/api/v1/health"
# Si 200 OK pero WxO da timeout → firewall/network
# Si 503 → Traefik/healthcheck
# Si TRAEFIK DEFAULT CERT → labels manuales, ver I-009
```
## Rotación de secretos
### API key del tenant WxO
1. WxO Console → IAM → Generate new API key
2. Actualizar `.env`: `ORCHESTRATE_API_KEY=<nuevo>`
3. `docker compose up -d --force-recreate` (NO `restart`, issue I-011)
4. `orchestrate env activate <env-name>` con el nuevo key
### Credenciales de connection
```bash
orchestrate connections set-credentials -a <app_id> --env draft -e "BASE_URL=..." -e "API_KEY=<nuevo>"
orchestrate connections set-credentials -a <app_id> --env live -e "BASE_URL=..." -e "API_KEY=<nuevo>"
```
No hay que re-deployar agentes — las connections se resuelven en runtime.
### Webhook signing secret
1. Generar nuevo secret
2. Actualizar `.env` + Coolify env vars: `WEBHOOK_SECRET=<nuevo>`
3. `--force-recreate`
4. Si el sistema externo usa el viejo secret, coordinar el switch
## Backup y restore
### Backup
- **Código + YAMLs**: vive en Gitea. Backup automático del repo.
- **Backend DB**: `docker exec <db-container> pg_dump > backup.sql` (o equivalente)
- **Audit logs**: parte del backup de la DB
- **Trazas SQLite**: `cp web/data/traces.db backup/`
- **WxO config**: los YAMLs del repo. `orchestrate agents list` te dice qué está en live.
### Restore
- **Código**: `git clone` y `./scripts/deploy-wxo.sh`
- **DB**: `pg_restore < backup.sql`
- **Traces**: si las perdés, no es crítico (son observabilidad histórica)
### Disaster recovery completo
1. Clonar repo en nuevo host
2. `.env` con credenciales del tenant (las que ya tenías guardadas)
3. `./scripts/deploy-wxo.sh` — recrea todo en WxO desde los YAMLs
4. `docker compose up -d` — levanta backend/web
5. Restore DB si necesario
6. Capturar nuevos IDs, propagar
7. Smoke test
Tiempo objetivo: <30 min.
## Troubleshooting checklist
Cuando algo se rompe y no sabés por dónde empezar:
1. [ ] `curl -sf $PUBLIC_HOST/health` — ¿el servicio responde?
2. [ ] `docker ps` — ¿los containers están healthy?
3. [ ] `docker logs <container> --tail=200` — ¿hay errores recientes?
4. [ ] `orchestrate agents list` — ¿el agente está en live?
5. [ ] `./evals/direct-backend-probe.sh` — ¿la auth funciona?
6. [ ] `./evals/smoke-test.sh` — ¿el flujo end-to-end?
7. [ ] WxO Console → Runs → últimos runs — ¿qué error tienen?
8. [ ] `docs/known-issues.md` — ¿coincide con algún I-XXX?
Si después de los 8 sigue roto, escalalo. Pero el 90% de los problemas
están en uno de los 8.
## Versión / actualización del ADK
```bash
./scripts/check-adk-version.sh
```
Si hay nueva versión:
1. **NO actualizar directamente.**
2. Crear branch `chore/adk-X.Y.Z`
3. `pip install ibm-watsonx-orchestrate==X.Y.Z`
4. Re-deployar a un env de staging
5. Correr todas las evals
6. Si pasan, PR + merge + deploy a prod
7. Actualizar el pin en `requirements.txt` + `check-adk-version.sh`
## Cuando todo lo demás falla
Ver `docs/known-issues.md` y `docs/adk-2x-cheatsheet.md`. Si tampoco
está ahí, abrir issue en el repo del template para que la próxima
persona no lo sufra.

262
docs/adk-2x-cheatsheet.md Normal file
View File

@@ -0,0 +1,262 @@
# ADK 2.x Cheatsheet
Comandos, schemas YAML, gotchas. **Versión pineada del template: `ibm-watsonx-orchestrate==2.1.0`**.
> `./scripts/check-adk-version.sh` te avisa si hay versión nueva en PyPI.
## Setup del ADK
```bash
python3.12 -m venv .venv-wxo
source .venv-wxo/bin/activate
pip install --upgrade "ibm-watsonx-orchestrate==2.1.0"
orchestrate --version # debe imprimir 2.1.x
```
## Environments
```bash
# Registrar tenant
orchestrate env add -n mi-tenant \
--iam-url https://iam.cloud.ibm.com/identity/token \
-u "$ORCHESTRATE_API_URL"
# Activar (pide API key, lo guarda en keyring)
orchestrate env activate mi-tenant
# Listar
orchestrate env list
# Borrar
orchestrate env remove -n mi-tenant
```
## Modelos disponibles
```bash
orchestrate models list # todos
orchestrate models list | grep gpt-oss # filtro
```
**Preferred para tool-calling:** `groq/openai/gpt-oss-120b`
**Fallback:** `meta-llama/llama-3-3-70b-instruct` (con caveat — ver `known-issues.md`)
## Conexiones
```bash
# Crear
orchestrate connections add -a mi_app
# Configurar tipo (key_value | api_key | bearer | oauth2)
# IMPORTANTE: hacerlo en draft Y live
orchestrate connections configure -a mi_app --env draft --type team --kind key_value
orchestrate connections configure -a mi_app --env live --type team --kind key_value
# Credenciales
orchestrate connections set-credentials -a mi_app --env draft -e "BASE_URL=https://..."
orchestrate connections set-credentials -a mi_app --env live -e "BASE_URL=https://..."
# Para API key con header custom (estilo Dun):
orchestrate connections set-credentials -a mi_app --env draft \
-e "X_API_KEY=secreto" -e "API_KEY_HEADER=X-Orchestrate-Token"
# Listar
orchestrate connections list
```
## Tools
### Python `@tool`
```bash
orchestrate tools import -k python \
-f wxo/tools/python/mi_tool.py \
-r wxo/tools/python/requirements.txt \
--app-id mi_app
```
Cada función decorada con `@tool(...)` se descubre y registra. Si tu
archivo tiene 6 funciones decoradas → 6 tools.
### OpenAPI
```bash
orchestrate tools import -k openapi \
-f wxo/tools/openapi/mi_spec.yaml \
--app-id mi_app
```
Pre-requisitos del spec:
- OpenAPI 3.0 o 3.1
- `description` per-operation (no solo `summary`)
- `security` per-operation (no global)
- `servers[0].url` apuntando al host correcto (patch en deploy time)
### MCP
```bash
orchestrate connections add -a mi_mcp_app
orchestrate connections configure -a mi_mcp_app --env draft --type team --kind mcp
orchestrate connections set-credentials -a mi_mcp_app --env draft \
-e "MCP_SERVER_URL=https://mi-mcp-server.example.com"
```
ADK descubre las tools del MCP server automáticamente.
## Knowledge bases
```bash
orchestrate knowledge-bases import -f wxo/knowledge_base/mi_kb.kb.yaml
orchestrate knowledge-bases list
```
Schema YAML mínimo:
```yaml
spec_version: v1
kind: knowledge_base
name: mi_kb
description: "..."
documents:
- "runbooks/runbook-01.txt"
- "runbooks/runbook-02.txt"
embeddings:
model: ibm/slate-125m-english-rtrvr
```
Los paths de `documents` son relativos al directorio del YAML.
## Agentes
```bash
# Import (al draft env)
orchestrate agents import -f wxo/agents/mi_agente.agent.yaml
# Deploy a live
orchestrate agents deploy --name mi_agente --env live
# Listar
orchestrate agents list
# Test con fixture
orchestrate agents test mi_agente --input-file evals/scenarios/test1.json
```
Schema YAML mínimo de un agente:
```yaml
spec_version: v1
kind: native
name: mi_agente
display_name: "Mi Agente"
description: "..."
style: react
llm: groq/openai/gpt-oss-120b
instructions: |
Eres ...
collaborators: []
tools: []
knowledge_base: []
starter_prompts:
is_default_prompts: true
prompts: []
```
## Canales
```bash
# Web chat
orchestrate channels create webchat \
--agent mi_agente \
--name "Mi Agente - Web"
# Listar
orchestrate channels list
```
El comando imprime el snippet `<script>` con `agentId` y
`agentEnvironmentId`. Anotalos.
**Paso manual obligatorio después:**
> WxO Console → Settings → Embed Security → **Off**
## Runs API (Plan A — webhook to agent)
Para que un sistema externo dispare al agente sin pasar por chat:
```bash
# 1. Conseguir IAM bearer token
curl -s -X POST "https://iam.cloud.ibm.com/identity/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ibm:params:oauth:grant-type:apikey&apikey=$ORCHESTRATE_API_KEY" \
| jq -r .access_token
# 2. POST a /v1/orchestrate/runs
curl -X POST "$ORCHESTRATE_API_URL/v1/orchestrate/runs" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "<uuid-del-agente-en-live>",
"message": "Hubo un evento X en el servicio Y..."
}'
```
Ver `cotemar-poc-n1/mocks/dynatrace/webhook.py` para implementación
completa (incluye refresh del token, error handling, retry).
## Webhooks lifecycle (run completion)
WxO puede llamar a tu backend cuando un run termina. Validá la firma:
```python
# Headers a recibir:
# X-Orchestrate-Timestamp
# X-Orchestrate-Nonce
# X-Orchestrate-Signature
# HMAC-SHA256(secret, f"{timestamp}.{nonce}.{body}")
# Tolerancia 300s sobre timestamp
# 408 si stale, 401 si firma mala
```
Ver `wxo/tools/openapi/_webhook_validator.py` template.
## Evaluaciones (CLI nativa)
```bash
# Test individual
orchestrate agents test mi_agente --input-file scenario.json
# Suite (un .json por scenario)
for f in evals/scenarios/*.json; do
orchestrate agents test mi_agente --input-file "$f"
done
```
Output: respuesta del agente + trace de tool calls.
## Cleanup
```bash
orchestrate agents remove --name mi_agente
orchestrate tools remove --name mi_tool
orchestrate knowledge-bases remove --name mi_kb
orchestrate connections remove -a mi_app
orchestrate channels delete --id <channel-id>
```
El template trae `./scripts/reset-wxo.sh` que hace todo en orden.
## Gotchas mortales (los que descubrimos pagando)
| Síntoma | Causa | Fix |
|---|---|---|
| `kid not found` al deployear a live | Connection no existe en live | `connections configure` en `draft` Y `live` |
| `No description provided` al importar OpenAPI | Falta `description` per-operation | Forzar `description = summary or "..."` |
| `recursion_limit reached` en runs | >2 tool calls en flow ReAct | Patrón meta-tool |
| Agent printea tool calls como texto | Llama 3.3 70B drift después del turno 3 | Cambiar a `gpt-oss-120b` + REGLA #0 en prompt |
| Pydantic 422 en tool input | LLM mandó `"123"` cuando schema espera `int` | `@field_validator(mode="before")` con coerción |
| `ModuleNotFoundError: wxo.tools._compat` | TRM importa el archivo standalone | Inlinear el shim en cada `tools.py` |
| Embed widget queda en "Cargando..." | Embed Security ON en WxO | Settings → Embed Security → Off |
| Coolify Traefik 503 | Healthcheck `wget --spider` falla | Usar `wget -qO-` o `curl -sf` |
| Traefik default cert en vez de Let's Encrypt | Labels Traefik manuales en compose | Solo `traefik.enable=true` |
Ver `known-issues.md` para el detalle completo de cada uno.

View File

@@ -0,0 +1,192 @@
# Architecture Patterns
Árbol de decisión que usa el subagente `wxo-architect` cuando arrancás un
caso nuevo. Te llevo pregunta por pregunta.
---
## Pregunta 1 — ¿Cuántos dominios distintos toca el caso?
Un "dominio" = una superficie funcional bien delimitada (identidad,
operaciones, RRHH, finanzas, CRM, etc.).
- **01 dominio** → 1 agente solo. Salí del árbol con la **Topología Single**.
- **25 dominios** → N specialists + 1 orchestrator. **Topología Multi-Specialist** (estilo Cotemar).
- **6+ dominios** → 2 capas: sub-orchestrators temáticos. **Topología Multi-Capa**.
---
## Pregunta 2 — ¿El flujo es lineal o ramificado?
- **Lineal y conocido** (caso entra → A → B → C → D → resultado, siempre el mismo orden): aplicá **Patrón Meta-Tool** (estilo Dun). Una sola tool `run_full_case` que el backend orquesta internamente. El agente invoca esa tool y listo.
- **Ramificado** (el agente decide qué hacer según el input): N tools individuales, agente razona. Cuidado con `recursion_limit=30` (ver `known-issues.md`).
- **Mixto**: parte lineal en meta-tool, parte ramificada en tools individuales. Es lo más común en casos reales.
---
## Pregunta 3 — ¿Cada agente necesita razonar sobre procedimientos escritos?
Para cada agente, preguntate: "¿el LLM necesita citar un runbook para
decidir bien?"
- **Sí** (procedural): este agente tiene **KB con runbooks**. Estilo
Cotemar AD specialist.
- **No** (API-driven): este agente **no tiene KB**. El system prompt
describe qué hace, las tools describen las acciones disponibles.
Estilo Dun QA Studio.
- **Mixto**: KB para el "qué" + tools para el "cómo". Solo cuando los
procedimientos son largos y variables.
---
## Pregunta 4 — ¿Qué tipo de tools va a tener cada agente?
Por cada dominio, elegí el tipo de tool:
### Opción A — OpenAPI (preferido si tenés backend propio)
**Cuándo:** tu solución incluye un backend (FastAPI, Express, lo que sea)
que ya define endpoints REST.
**Pros:**
- Una sola fuente de verdad (el spec)
- ADK importa todo en un comando
- Cambios al backend = re-import del spec, no toques YAML de tool
**Pattern del template (estilo Dun):**
- Backend expone `/orchestrate-tools-spec.json` (endpoint filtrado)
- Filtra el openapi.json a una allowlist `PUBLIC_TOOLS`
- Fuerza `description` y `security` per-operation
- Patch del `servers[0].url` en deploy time según env
Ver `wxo/tools/openapi/_backend_filter_endpoint.py` para el template.
### Opción B — Python `@tool` (preferido para mocks/PoC)
**Cuándo:** estás prototipando, mocks externos, lógica simple por tool,
sin backend que valga la pena mantener.
**Pros:**
- Rápido de escribir, una función = una tool
- Fácil de testear con pytest
- Bajo overhead
**Pattern del template (estilo Cotemar):**
- Una función `@observable_tool(name="x")` por endpoint mockeado
- Lee `BASE_URL` del environment de su connection
- `requests.post(...)` y devuelve dict
- `_compat.py` inline al inicio del archivo
Ver `wxo/tools/python/_template_tools.py`.
### Opción C — MCP (preferido si el sistema lo expone)
**Cuándo:** el sistema externo (HubSpot, Outline, una DB con MCP server,
GitHub vía MCP, etc.) ya expone un MCP server.
**Pros:**
- Trae types + permisos del sistema
- Mantenido por el vendor
- Una sola connection MCP da acceso a todas las tools del server
**Pattern del template:**
- Una `connection.yaml` de tipo MCP apuntando al server
- ADK descubre las tools y las hace disponibles al agente
- Ver `wxo/tools/mcp/_template_mcp_connection.yaml`
### Decisión
| Situación | Recomendación |
|---|---|
| Es PoC, mocks externos | Python @tool |
| Tengo backend propio (FastAPI/Express) | OpenAPI |
| Conecto a SaaS con MCP server | MCP |
| Conecto a SaaS sin MCP server | OpenAPI (escribís el spec a mano) o Python |
| Mezcla | Cada agente puede usar más de uno |
---
## Pregunta 5 — ¿Web layer: cuál stack?
Dos happy-paths probados:
### Stack A — FastAPI + HTMX + Jinja (default)
**Cuándo:** demos, PoCs, control planes, kanbans, paneles operativos
**Pros:** SSR, sin build step, polling con `hx-trigger` es trivial,
una sola persona la mantiene
**Contras:** menos interactivo, menos componibilidad
**Origen:** estilo Cotemar (UNUS Kanban + Control Plane)
### Stack B — FastAPI + React + Vite + Tailwind + zustand
**Cuándo:** app productiva con varios usuarios, mucha interacción, UI rica
**Pros:** ecosistema React, lazy loading, state management decente
**Contras:** build step, más superficie de mantenimiento
**Origen:** estilo Dun (QA Studio)
### Decisión
- **Demo / interno / PoC** → Stack A
- **Producción / multi-usuario / app rica** → Stack B
- **Híbrido** → Stack A para dashboards, Stack B para la app principal,
unidos por reverse proxy
El template trae Stack A en `web/_default_fastapi_htmx/`. Stack B viene
documentado pero no implementado (el subagente Claude `web-layer-builder`
te lo arma según el caso).
---
## Pregunta 6 — ¿Deploy: dónde corre esto?
Tres opciones soportadas:
| Target | Para qué | Ver |
|---|---|---|
| **Coolify** (FIT default) | Demos + PoCs productivos en `*.fitlabs.dev` | `docs/deployment-guide.md` |
| **Docker compose local** | Dev / testing | `docs/INSTRUCCIONES.md` |
| **K8s / Cloud Run / otro** | Producción del cliente | `docs/deployment-guide.md` § "Other targets" |
---
## Topologías resultantes
### Topología Single (1 agente)
```
[user] → [agente único] ──tools──→ [sistema externo]
└──KB──→ [opcional]
```
### Topología Multi-Specialist (Cotemar pattern)
```
┌──→ ad_specialist ──tools──→ AD
[user] → [N1] ────┼──→ ops_specialist ──tools──→ Ops
(orch) └──→ rrhh_specialist ──tools──→ HR
└─escalate→ runbook 03 (KB propia)
```
### Topología Meta-Tool (Dun pattern)
```
[user] → [agente único] ──run_full_case(id)──→ [backend orquesta:
step 1, step 2, ...
write_audit cada paso]
```
### Topología Multi-Capa (>5 dominios)
```
┌─→ [sub-orch identidad] ─→ ad, hr, vendors
[user] → [super-orch] ────┤
├─→ [sub-orch ops] ─→ dynatrace, k8s, db
└─→ [sub-orch finanzas] ─→ sap, billing
```
---
## Cuando dudes
- **¿1 agente o varios?** → Si los dominios son disjuntos, varios. Si todo es del mismo dominio, uno.
- **¿KB o no?** → Si hay procedimiento escrito que el LLM debe seguir literal, sí. Si no, no.
- **¿OpenAPI o Python?** → ¿Tenés backend? OpenAPI. ¿Mocks? Python.
- **¿Meta-tool o tools sueltas?** → ¿El flujo es lineal y conocido? Meta-tool. ¿El agente decide? Sueltas.
Cuando dudes, **arrancá con menos**. Es más fácil splittear un agente en
dos que mergear dos en uno.

149
docs/deployment-guide.md Normal file
View File

@@ -0,0 +1,149 @@
# Deployment Guide
Cómo desplegar una solución generada con este template. Cubre Coolify
(default FIT) + alternativas.
## Resumen
```bash
# 1. Tener el .env con las credenciales del tenant WxO y del host público
# 2. Tener un Coolify (o un docker host) corriendo
# 3. ./scripts/deploy-wxo.sh ← despliega agentes/tools/KBs/connections al tenant
# 4. docker compose up -d ← despliega la capa web + mocks (si los hay)
# 5. WxO UI → Embed Security OFF (manual, una vez por tenant)
# 6. ./evals/smoke-test.sh ← valida end-to-end
```
## Variables de entorno requeridas
Ver `.env.example` para la lista completa. Las críticas:
```bash
# Tenant WxO
ORCHESTRATE_API_URL=https://api.us-south.watson-orchestrate.cloud.ibm.com/instances/<uuid>
ORCHESTRATE_API_KEY=<tu-api-key>
# Para Plan A (webhook a Runs API). Opcional.
WATSONX_API_KEY=<api-key-ibm-cloud>
IBM_CLOUD_IAM_URL=https://iam.cloud.ibm.com/identity/token
WXO_AGENT_ID=<uuid-del-orchestrator-en-live>
# Host público donde corre tu stack
PUBLIC_HOST=mi-cliente.fitlabs.dev
```
## Coolify (FIT default)
### Crear el stack
1. **Coolify panel** → New Resource → Docker Compose
2. Connect to git repo (Gitea)
3. Build pack: docker compose, path `./docker-compose.yml`
4. **Domain**: tu `PUBLIC_HOST` en el servicio que expone la landing/web
5. **Env vars**: copiar todos los valores del `.env` al panel de Coolify
6. **Deploy**
### Si Traefik da 503
Casi siempre es healthcheck. Verificá:
- El healthcheck usa `wget -qO-` o `curl -sf` (no `--spider`) → issue I-008
- El endpoint `/health` del servicio responde 200
- En logs: `docker logs <container>` busca errores de startup
### Si sale "TRAEFIK DEFAULT CERT"
Tenés labels Traefik manuales en el compose. Borralos. Issue I-009.
### Redeploy via API
```bash
curl -X POST "$COOLIFY_API_URL/applications/$COOLIFY_APP_UUID/deploy?force=true" \
-H "Authorization: Bearer $COOLIFY_API_TOKEN"
```
## Docker Compose local
Para development. Trae el override `docker-compose.local.yml` que agrega
ports + bridge network.
```bash
cp .env.example .env
LANDING_PORT=18900 docker compose \
-f docker-compose.yml \
-f docker-compose.local.yml \
up -d --build
```
URLs:
- Landing: `http://localhost:18900/`
- Web layer: `http://localhost:18900/web/`
- Cualquier mock: `http://localhost:18900/<mock-name>/`
## Otros targets (K8s, Cloud Run, etc.)
El template es Docker-compose-first, pero los servicios son containers
estándar. Para migrar:
1. **Kubernetes**: convertir cada service del compose en `Deployment` +
`Service`. Usar un Ingress controller para el routing (estilo Traefik).
`kompose convert` te da una primera versión.
2. **Cloud Run**: cada service va por separado. Las connections entre
ellos por URLs HTTP públicas o VPC connector.
3. **ECS Fargate**: task definition por service. ALB para el routing.
El **WxO deploy (`./scripts/deploy-wxo.sh`) es independiente del target**.
Lo que cambia es solo el host público que el ADK ve en las connections.
## Deploy del WxO stack
`./scripts/deploy-wxo.sh` es idempotente. Hace:
1. Connections en **draft y live** (issue I-004)
2. Tools import (Python + OpenAPI según haya)
3. KBs import
4. Agents import (specialists primero, orchestrator último)
5. Agents deploy a live
6. Channel creation
Lo podés re-correr todas las veces que quieras.
### Variables que afecta el script
| Var | Default | Para qué |
|---|---|---|
| `PUBLIC_HOST` | `mi-cliente.fitlabs.dev` | Base URL de las connections |
| `WXO_ENV_NAME` | `default` | Cuál env del ADK usar |
| `SKIP_TOOLS` | `false` | Saltar la fase de tools (útil si solo cambiaste agentes) |
| `SKIP_KB` | `false` | Saltar KBs (idem) |
## Capturar IDs después del deploy
```bash
orchestrate agents list
# Buscá: mesa_n1 (o como se llame tu orchestrator)
# y su row con env=live
# Capturá: agent_id + env_id
```
Esos dos UUIDs van a:
- `web/.../index.html``agentId` + `agentEnvironmentId` del widget de chat
- `.env``WXO_AGENT_ID` (para Plan A webhook)
- Coolify env vars
## Paso manual obligatorio — Embed Security OFF
> WxO Console → Settings → Embed Security → **Off**
Sin esto el embed se queda en "Cargando agente...". Issue I-007.
## Smoke test
```bash
./evals/smoke-test.sh
```
Hace login → crea un caso → ejecuta → polea → assert. Si pasa, el deploy
está OK.
## Si algo falla
1. `./evals/direct-backend-probe.sh` — aísla auth/connection de logic
2. `./scripts/check-adk-version.sh` — confirma que el ADK pinneado todavía es válido
3. `docs/known-issues.md` — los 16 errores conocidos con su fix
4. `docs/RUNBOOK.md` — operaciones de recovery

247
docs/eval-strategy.md Normal file
View File

@@ -0,0 +1,247 @@
# Eval Strategy
Cómo validar que tu solución hace lo que debe. **Cuatro capas**, cada una
respondiendo una pregunta distinta.
---
## Capa 1 — Native (ADK)
**Pregunta:** ¿El agente responde algo razonable al input?
**Herramienta:** `orchestrate agents test`
```bash
orchestrate agents test mi_agente --input-file evals/scenarios/test1.json
```
Output:
- La respuesta del agente
- La traza de tool calls
- El timing
**Cobertura:**
- Happy path
- Casos límite (input ambiguo, datos faltantes)
- Casos de error (sistema externo caído, datos inválidos)
**Limitación:** no compara contra expected. Solo te muestra qué pasó.
**Fixture format** (`evals/scenarios/test1.json`):
```json
{
"messages": [
{ "role": "user", "content": "Necesito resetear la contraseña de juan.perez@cotemar.com" }
]
}
```
---
## Capa 2 — Agent behavior (custom)
**Pregunta:** ¿El agente llamó las tools correctas en el orden correcto?
**Herramienta:** `evals/runner.py` que consume trazas observables.
**Fixture format** (`evals/scenarios/scenario_reset.yaml`):
```yaml
name: reset_password_happy_path
input: "Necesito resetear la contraseña de juan.perez@cotemar.com"
expect:
agent_response_contains: ["TKT-"]
tool_calls_in_order:
- tool: lookup_user
inputs.username: "juan.perez@cotemar.com"
- tool: reset_password
- tool: create_ticket
inputs.status: "RESOLVED"
no_tool_calls:
- escalate_to_n2
```
**Cómo corre:**
1. Manda el input al agente vía WxO Runs API
2. Espera la respuesta
3. Lee las trazas emitidas durante el run (capa observability)
4. Compara contra `expect`
5. Pass/fail
**Por qué importa:** un agente puede dar la respuesta correcta llamando
las tools incorrectas (ej: dijo `RESOLVED` sin llamar `reset_password`).
Esto detecta esos casos.
---
## Capa 3 — Runbook compliance (custom)
**Pregunta:** ¿El estado final del sistema coincide con lo que el runbook
dice que debe pasar?
**Herramienta:** `evals/runner.py` con asserts sobre la DB del backend.
**Fixture format** (`evals/scenarios/scenario_reset.yaml` — sección
`final_state`):
```yaml
final_state:
query: "SELECT * FROM tickets WHERE created_at > $START_TIME"
expect_count: 1
expect_fields:
status: "RESOLVED"
category: "password_reset"
assigned_group: "Capital Humano"
extra.runbook: "01"
```
**Cómo corre:**
1. Captura timestamp inicial
2. Ejecuta el scenario (manda input al agente)
3. Query a la DB para ver qué se creó/actualizó
4. Asserts contra `final_state`
**Por qué importa:** la traza puede decir "todo bien" pero el ticket
final puede tener el grupo equivocado, falta de metadata, etc.
---
## Capa 4 — Web layer (custom, opcional)
**Pregunta:** ¿La UI muestra correctamente el resultado?
**Herramienta:** Playwright o `requests` + parsing HTML.
**Cuándo aplica:** si la solución incluye un control plane / kanban /
dashboard que el usuario consume directamente.
**Fixture format** (`evals/scenarios/scenario_reset.yaml` — sección `ui`):
```yaml
ui:
visit: "/insights"
expect_text:
- "Resueltos: 1"
- "Runbook 01"
visit: "/"
expect_selector: ".ticket-card--RESOLVED"
expect_count_selector:
selector: ".ticket-card"
count: ">=1"
```
---
## Runner
```bash
./evals/eval-agents.sh
```
Hace en orden:
1. `lint_wxo_yaml.py` — best practices estáticas (issue check antes de runtime)
2. Native eval por cada `evals/scenarios/*.json`
3. Behavior eval por cada `evals/scenarios/*.yaml`
4. Final state eval (parte del mismo YAML)
5. UI eval si existe sección `ui`
Reporta:
```
[lint] PASS
[native] PASS (5/5)
[behavior] PASS (4/5) — scenario "edge_case_inactive_user" FAILED
[final_state] PASS (4/5)
[ui] SKIP (no fixtures)
```
Exit code 0 si todo pasa, 1 si alguno falla.
---
## Smoke test (CI nightly)
`evals/smoke-test.sh` es el subset mínimo que debe pasar **siempre**:
1. Health endpoint responde 200
2. Un caso simple end-to-end (login → create → execute → poll → assert)
3. El audit log tiene las entries esperadas
4. La UI muestra el resultado
Si falla, algo de la infraestructura está mal (deploy, DNS, healthcheck,
auth, etc.). NO mira el agente en profundidad — para eso están las
capas 2/3.
```bash
./evals/smoke-test.sh
```
---
## Direct backend probe (diagnóstico)
`evals/direct-backend-probe.sh` golpea el backend directamente con
curl + el token de WxO. Para aislar problemas:
```bash
$ ./evals/direct-backend-probe.sh
[1/3] Healthcheck OK (200)
[2/3] Direct tool call OK (200) — got expected "case not found"
[3/3] Auth test OK (200 con token, 401 sin)
✓ Backend OK from WxO side. Si la solución falla, es el agente o el LLM.
```
Si los 3 pasan pero el agente igual falla, el problema está en:
- El prompt del agente
- El modelo (issue I-002)
- El recursion_limit (issue I-001)
- Los schemas (issue I-003)
---
## Estrategia recomendada por fase del proyecto
### Fase 1 — Desarrollo
- **Capa 1** (native) frecuente, manualmente
- **Lint** antes de cada commit (pre-commit hook)
### Fase 2 — Estabilización
- Agregar **Capa 2** (behavior) para cada scenario
- Agregar **Capa 3** (final_state) para cada scenario
- CI corre las 3 en cada PR
### Fase 3 — Demo / handoff
- Agregar **Capa 4** (UI) para los 2-3 paths críticos
- **Smoke test nightly** en CI
### Fase 4 — Producción
- Smoke test cada 15 min (cron)
- Direct backend probe en healthcheck
- Behavior eval suite semanal
---
## Archivos a crear por scenario
```
evals/scenarios/
├── scenario_reset_password.yaml ← Capas 2, 3, 4 unified
├── scenario_reset_password.input.json ← Capa 1 (input para `agents test`)
├── scenario_restart_service.yaml
├── scenario_restart_service.input.json
└── ...
```
El subagente `eval-author` (en `.claude/agents/eval-author.md`) genera
estos pares automáticamente cuando le das un runbook o un agente nuevo.
---
## Cuando una eval falla
1. Mirá `evals/last_run.log` — qué fixture, qué assert
2. Si es **Capa 1**: el agente respondió mal o no respondió. Ver prompt.
3. Si es **Capa 2**: el agente respondió bien pero llamó las tools mal.
Ver instructions del agente.
4. Si es **Capa 3**: tools se llamaron pero el resultado final está
incorrecto. Ver el handler del backend.
5. Si es **Capa 4**: backend OK pero UI no muestra. Ver el view / HTMX template.
Repará, corré la eval específica (`./evals/eval-agents.sh -s
scenario_x`), commit.

407
docs/known-issues.md Normal file
View 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".

View File

@@ -0,0 +1,178 @@
# Observability Pattern
Cada tool wrapper en este template emite trazas estructuradas por defecto.
La capa web las consume, las muestra en una vista timeline, y las evals las
usan para verificar que el agente llamó las tools correctas en el orden
correcto.
## El contrato
Toda tool, sin importar el lenguaje, devuelve este envelope:
```json
{
"result": { /* el resultado real que el LLM consume */ },
"trace": {
"trace_id": "ad-reset-7f3a",
"tool": "reset_password",
"domain": "ad",
"started_at": "2026-05-16T14:23:01.123Z",
"duration_ms": 187,
"inputs": { "username": "juan.perez@cotemar.com" },
"side_effects": [
{ "type": "http_call", "method": "POST", "url": "...", "status": 200, "duration_ms": 142 }
],
"observed_state_before": { "can_reset": true, "user_active": true },
"observed_state_after": { "password_rotated": true, "expires_at": "..." },
"agent_caller": "ad_specialist_cotemar",
"correlation_id": "case-abcd-1234"
}
}
```
El LLM ve solo `result`. La traza va al sink configurado y no contamina
el razonamiento del agente.
## Decorator Python (`@observable_tool`)
Reemplaza al `@tool` directo de la ADK:
```python
from wxo.tools.python._observable_tool import observable_tool
@observable_tool(name="reset_password", domain="ad")
def reset_password(username: str) -> dict:
# tu lógica normal
return {"temp_password": "xxx", "expires_in": "24h"}
```
El decorator:
1. Genera un `trace_id` único
2. Captura `started_at` y mide `duration_ms`
3. Serializa `inputs`
4. Intercepta llamadas HTTP (si usa `requests` patched) y las anota en `side_effects`
5. Llama tu función
6. Captura `result`
7. Emite la traza al sink configurado vía env `TRACE_SINK`
8. Devuelve `{result, trace}` envuelto
La función decorada sigue siendo un `@tool` válido para la ADK — el
decorator delega.
## Sinks soportados
Tres modos según el caso, controlados por env var `TRACE_SINK`:
### `TRACE_SINK=sqlite` (default dev)
Trazas se escriben a `traces.db` SQLite en el container del web layer.
Útil para dev y CI.
### `TRACE_SINK=http`
POST a `$TRACE_SINK_URL` (default `http://web:8000/api/traces`).
Pattern Cotemar: el web layer recibe trazas live y las muestra en una
vista timeline filterable.
### `TRACE_SINK=otlp`
OpenTelemetry OTLP gRPC a `$OTEL_EXPORTER_OTLP_ENDPOINT`. Producción.
Compatible con Jaeger, Dynatrace, Grafana Tempo, etc.
## Backend integrado (estilo Dun) — `write_audit`
Cuando la tool no es un wrapper Python sino un endpoint del backend
(patrón Dun con OpenAPI import), el equivalente es `write_audit`:
```python
# backend/app/audit.py
def write_audit(case_id: str, event: str, payload: dict):
db.execute(
"INSERT INTO audit_logs (case_id, event, payload, ts) VALUES (?, ?, ?, ?)",
(case_id, event, json.dumps(payload), datetime.utcnow())
)
# backend/app/api/v1/orchestrate_tools.py
@router.post("/reset-password")
def reset_password(input: ResetPasswordInput, case_id: str):
write_audit(case_id, "tool.reset_password.started", {"input": input.dict()})
result = ad_client.reset(input.username)
write_audit(case_id, "tool.reset_password.completed", {"result": result})
return result
```
El backend reconstruye el timeline desde `audit_logs`, no desde trazas
volátiles de Orchestrate.
## Schema unificado para multi-lenguaje
Si tu wrapper es Node/Java/Go, el decorator equivalente debe emitir el
**mismo envelope JSON**. Los templates están en:
```
wxo/tools/_observability/
├── python/observable_tool.py
├── node/observable-tool.ts (TODO en v1, escribilo si lo necesitás)
├── java/ObservableTool.java (TODO en v1)
└── README.md ← el contrato schema
```
## Vista timeline en el web layer
El `web/_default_fastapi_htmx/` viene con:
- `POST /api/traces` — endpoint que recibe trazas
- Tabla `traces` en SQLite con índices en `(agent_caller, started_at)` y `(trace_id)`
- `GET /traces` — vista HTMX con timeline
- Componente que polea `GET /traces/recent?since=...` cada 2s
Captura visual:
```
14:23:01.123 ad_specialist reset_password(juan.perez@cotemar.com)
├─ HTTP POST ad/reset-password 142ms 200
└─ result: { temp_password: "xxx" } 187ms total
14:23:02.456 ad_specialist create_ticket(...)
└─ HTTP POST unus/api/tickets 89ms 201
└─ result: { ticket_id: "TKT-A1B2" } 103ms total
```
## Evals que consumen trazas
Las evals de behavior verifican comportamiento intermedio, no solo el
resultado final:
```yaml
# evals/scenarios/scenario_reset_password.yaml
input: "Necesito resetear la contraseña de juan.perez@cotemar.com"
expect:
agent_response_contains: ["TKT-"]
tool_calls_in_order:
- tool: lookup_user
inputs.username: "juan.perez@cotemar.com"
- tool: reset_password
- tool: create_ticket
inputs.status: "RESOLVED"
no_tool_calls:
- escalate_to_n2
```
El runner lee las trazas emitidas durante el run y matchea contra
`expect`. Si el agente llamó las tools en el orden equivocado, falla
aunque el ticket final esté bien.
## Regla del linter
`evals/lint_wxo_yaml.py` falla si encuentra un `@tool(...)` sin
`@observable_tool(...)` en cualquier archivo bajo `wxo/tools/python/`.
No hay excepción. Si necesitás bypass para debug, marcalo:
```python
@observable_tool(name="x", domain="y", _bypass_tracing=True) # explícito
def x(): ...
```
## Costo
El decorator agrega ~2-5ms de overhead por call. Para 99% de los casos
es invisible. Si tenés un tool ultra-hot que llama 1000 veces por minuto,
configurá `TRACE_SINK=sqlite` con buffer batch o desactiválo con
`_bypass_tracing=True` (con audit a mano).

View File

@@ -0,0 +1,314 @@
# Tool Authoring Guide
WxO acepta tools de tres formas. Acá te explico cuándo usar cuál y cómo.
## Decisión rápida
| Situación | Tipo |
|---|---|
| PoC / demo / mocks | **Python `@tool`** |
| Tu backend ya expone REST (FastAPI/Express/etc.) | **OpenAPI** |
| SaaS de terceros con MCP server | **MCP** |
| SaaS de terceros sin MCP | **OpenAPI** (escribís el spec) o **Python** wrapper |
---
## Tipo 1 — Python `@tool` (estilo Cotemar)
### Cuándo
- Estás prototipando rápido
- Los sistemas externos son mocks separados
- La lógica del wrapper es simple
- Querés tener cada tool en su propia función testeable
### Template
`wxo/tools/python/_template_tools.py`:
```python
# === 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])
# ============================================================
import os
import requests
from pydantic import BaseModel, field_validator
from wxo.tools.python._observable_tool import observable_tool
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list
BASE_URL = os.environ.get("BASE_URL", "")
class ResetPasswordInput(BaseModel):
username: str
notify: bool = True
@observable_tool(name="reset_password", domain="ad")
def reset_password(username: str, notify: bool = True) -> dict:
"""Reset AD password and return temp credentials."""
resp = requests.post(
f"{BASE_URL}/users/{username}/reset-password",
json={"notify": notify},
timeout=10
)
resp.raise_for_status()
return resp.json()
```
### Import
```bash
orchestrate tools import -k python \
-f wxo/tools/python/ad_tools.py \
-r wxo/tools/python/requirements.txt \
--app-id ad_demo
```
### Gotchas
- **No usar imports relativos** (`from .._compat import ...`). Inlinear el shim. (Issue I-010)
- **Coerción de tipos siempre.** El LLM stringifica todo. (Issue I-003)
- **`BASE_URL` desde env**, nunca hardcoded.
---
## Tipo 2 — OpenAPI (estilo Dun)
### Cuándo
- Tu backend ya define endpoints REST con tipos
- Querés single-source-of-truth (el spec)
- Tenés FastAPI/Express/etc. y solo necesitás exponer un subset a WxO
### Template — endpoint público filtrado
`wxo/tools/openapi/_backend_filter_endpoint.py`:
```python
from copy import deepcopy
from fastapi import FastAPI
# Allowlist de operaciones que WxO puede ver
PUBLIC_TOOLS = {
"POST /api/v1/cases/run",
"GET /api/v1/cases/{case_id}",
"POST /api/v1/cases/{case_id}/approve",
}
def build_public_spec(app: FastAPI) -> dict:
"""Filtra el openapi.json de tu app a la allowlist PUBLIC_TOOLS,
fuerza description per-operation, y resuelve $ref transitivamente."""
full = app.openapi()
spec = {
"openapi": full["openapi"],
"info": {**full["info"], "title": full["info"]["title"] + " (orchestrate)"},
"servers": full.get("servers", [{"url": "https://CHANGEME/api/v1"}]),
"paths": {},
"components": {"schemas": {}},
"security": full.get("security", []),
}
# Filtrar paths
for path, item in full["paths"].items():
for method, op in item.items():
if method.upper() in ("GET", "POST", "PATCH", "PUT", "DELETE"):
key = f"{method.upper()} {path}"
if key in PUBLIC_TOOLS:
spec["paths"].setdefault(path, {})[method] = op
# description fallback (issue I-005)
op["description"] = op.get("description") or op.get("summary") or f"{method} {path}"
# security per-operation (issue I-006)
op["security"] = spec["security"]
# Resolver $ref transitivamente
needed_schemas = set()
def walk(o):
if isinstance(o, dict):
for k, v in o.items():
if k == "$ref" and isinstance(v, str) and v.startswith("#/components/schemas/"):
needed_schemas.add(v.split("/")[-1])
else:
walk(v)
elif isinstance(o, list):
for x in o: walk(x)
walk(spec["paths"])
all_schemas = full.get("components", {}).get("schemas", {})
# Resolver hasta closure
pending = list(needed_schemas)
while pending:
s = pending.pop()
if s not in all_schemas: continue
spec["components"]["schemas"][s] = all_schemas[s]
# Encontrar refs nuevos dentro
before = set(needed_schemas)
walk(all_schemas[s])
for new in needed_schemas - before:
pending.append(new)
return spec
# Endpoint que sirve el spec filtrado
@app.get("/api/v1/orchestrate-tools-spec.json")
def public_spec():
return build_public_spec(app)
```
### Patch del `servers[0].url` en deploy time
`scripts/deploy-wxo.sh` baja el spec del backend live y reemplaza el server URL:
```bash
curl -s "$BACKEND_URL/api/v1/orchestrate-tools-spec.json" \
| jq --arg url "$BACKEND_URL/api/v1" '.servers = [{"url": $url}]' \
> /tmp/spec.json
orchestrate tools import -k openapi -f /tmp/spec.json --app-id mi_backend
```
### Schema Pydantic con coerción
En tu backend, cada endpoint input model lleva field_validator:
```python
from pydantic import BaseModel, field_validator
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list
class RunCaseInput(BaseModel):
case_id: str
target_companyid: int
profile_ids: list[str]
_coerce_companyid = field_validator("target_companyid", mode="before")(_coerce_int)
_coerce_profiles = field_validator("profile_ids", mode="before")(_coerce_list)
```
### Observabilidad con `write_audit`
En cada handler:
```python
from app.audit import write_audit
@router.post("/cases/run")
def run_case(input: RunCaseInput, x_orchestrate_token: str = Header(...)):
write_audit(input.case_id, "tool.run_case.started", input.dict())
result = do_the_work(input)
write_audit(input.case_id, "tool.run_case.completed", result)
return result
```
---
## Tipo 3 — MCP
### Cuándo
- El SaaS ya expone un MCP server (HubSpot, Outline, GitHub vía MCP, etc.)
- Querés que ADK descubra las tools automáticamente
- Necesitás permisos del sistema externo respetados
### Template
`wxo/tools/mcp/_template_mcp_connection.yaml`:
```yaml
spec_version: v1
kind: connection
name: hubspot_mcp
display_name: "HubSpot via MCP"
schema_version: "1.0"
auth_type: mcp
identifier: hubspot_mcp_conn
preference:
- environment: draft
schema_id: mcp
- environment: live
schema_id: mcp
```
### Setup
```bash
orchestrate connections add -a hubspot_mcp
for ENV in draft live; do
orchestrate connections configure -a hubspot_mcp --env $ENV --type team --kind mcp
orchestrate connections set-credentials -a hubspot_mcp --env $ENV \
-e "MCP_SERVER_URL=https://hubspot-mcp.example.com" \
-e "MCP_TOKEN=$HUBSPOT_TOKEN"
done
```
ADK descubre las tools del server y las hace disponibles al agente. En el
YAML del agente listás las que necesita:
```yaml
tools:
- hubspot_mcp/get_contact
- hubspot_mcp/create_deal
```
### Caveats
- La latencia depende del MCP server. Algunos son lentos.
- Los nombres de tools vienen del MCP server, no los podés renombrar.
- Coordiná con el equipo del MCP server qué tools va a exponer.
---
## Híbrido
Un agente puede usar las tres formas al mismo tiempo:
```yaml
tools:
# MCP (descubiertas del server)
- hubspot_mcp/get_contact
- hubspot_mcp/update_deal
# OpenAPI (importadas del backend propio)
- run_full_case
- get_case_status
# Python (wrappers a mocks o sistemas pequeños)
- lookup_internal_user
```
Lo único que comparten es el contrato observable: cada call emite la
misma traza JSON.
---
## Patrón "meta-tool" (issue I-001)
Si tu flujo es lineal (caso → A → B → C → D), colapsá los 4 tools en uno:
```yaml
# Mal — el agente puede explotar con recursion_limit
tools:
- get_company
- search_credits
- build_profile
- package_zip
- upload
```
```yaml
# Bien — agente invoca 1 tool, backend orquesta el resto
tools:
- run_full_case
```
El backend ejecuta los 4 pasos internamente y emite `write_audit` por cada
uno. La UI reconstruye el timeline igual. El agente es feliz y no muere
por recursion.
---
## Checklist antes de mergear un tool nuevo
- [ ] ¿Usa `@observable_tool`, no `@tool` directo?
- [ ] ¿Tiene `description` (docstring) clara que el LLM va a leer?
- [ ] ¿El input model tiene `@field_validator(mode="before")` para todo int/list?
- [ ] ¿Lee `BASE_URL` del env, no hardcoded?
- [ ] ¿Si es OpenAPI, tiene `description` y `security` per-operation?
- [ ] ¿El agente que lo usa tiene <10 tools después de agregarlo?
- [ ] ¿Hay un scenario de eval que cubra la happy path?
- [ ] ¿`./evals/lint_wxo_yaml.py` pasa?

253
docs/wxo-best-practices.md Normal file
View File

@@ -0,0 +1,253 @@
# WxO Best Practices
Destiladas de Cotemar (`cotemar-poc-n1`) y Dun (`dun-casos-prueba`). Cada regla
fue paga con dolor — el linter en `evals/lint_wxo_yaml.py` enforcea las
críticas, el resto es disciplina humana.
## 🏗 Arquitectura de agentes
### A1 — Máximo 10 tools por agente
LLM se confunde con catálogos grandes. Más de 10 → degrada la precisión de
selección de tool. Si necesitás más, partí en specialists. **(enforced)**
### A2 — Especialistas por dominio, nada de "todistas"
Un agente que sabe AD + Ops + RRHH falla más que tres que saben cada uno lo
suyo. Aunque sean específicos chicos, separá. **(enforced — orchestrator no puede tener tools de remediación)**
### A3 — El orchestrator nunca remedia
Su único poder es clasificar, delegar y crear/leer tickets. Si necesitás
`reset_password`, `restart_service`, `create_user` — eso vive en un
specialist. **(enforced)**
### A4 — Specialists se importan ANTES que el orchestrator
El orchestrator declara a los specialists como `collaborators`. Si no
existen aún en el tenant, el import del orchestrator falla. El
`deploy-wxo.sh` ya respeta el orden, no lo cambies.
### A5 — Una KB por agente (least privilege) cuando hay KB
El especialista AD no debe poder leer el runbook de Ops. Si todos
comparten una KB, el LLM cita el runbook equivocado y razona desde la
fuente equivocada.
### A6 — KB es opcional
Agentes API-driven o MCP-driven no necesitan KB. **No declares KB vacía
solo "por completitud"** — eso confunde el linter. Si no la necesitás,
omitila. **(enforced — agente sin KB ni tools falla)**
### A7 — Patrón "meta-tool" para flujos lineales
**Crítico.** langgraph en WxO tiene `recursion_limit=30` hardcoded. Cada
turno ReAct quema ~10 frames → más de 2 tool calls explota. Si tu flujo es
**lineal y conocido** (caso A → B → C → D), colapsá los 4 tools en uno
solo (`run_full_case`) que el backend orquesta internamente. El agente
solo invoca esa meta-tool. La observabilidad de los substeps se preserva
con `write_audit` (ver §O1).
## 🧠 Prompting
### P1 — Hardcodear el modelo, documentar el fallback
`llm: groq/openai/gpt-oss-120b` es el preferred para tool-calling.
Fallback: `meta-llama/llama-3-3-70b-instruct` PERO con la advertencia de
que después del turno ~3 emite tool calls como texto plano (ver
`known-issues.md`).
### P2 — REGLA #0 explícita en el prompt
> "NUNCA escribas la tool call como texto. Usá el protocolo de tool_calls."
Esto **es necesario** porque el LLM (especialmente llama) tiene drift y
empieza a printear tool calls. Codificar el anti-pattern en el prompt
reduce la frecuencia.
### P3 — Instrucciones con escenarios concretos
No "sos un agente útil que ayuda con tickets". Sí: ejemplos enumerados de
input → razonamiento → output esperado. Mínimo 3 ejemplos por agente. Ver
`cotemar-poc-n1/wxo/agents/mesa_n1_cotemar.agent.yaml` como referencia.
### P4 — Tabla de escalamiento explícita en el prompt
Si el agente puede escalar, la decisión "qué razón → qué grupo → qué
persona" tiene que estar tabulada en el prompt, no implícita. El LLM no
inventa, sigue tablas.
### P5 — `style: react` por defecto
Para agentes con tools. Si no hace tool calling, podés usar `style: chat`,
pero es raro. **(enforced — si un agente declara tools pero no `react`, warning)**
## 🛠 Tools
### T1 — `@observable_tool` siempre, nunca `@tool` directo
El decorator captura inputs/outputs/latency/side_effects y los emite
como traza estructurada. Sin esto perdés visibilidad y las evals no pueden
verificar comportamiento intermedio. **(enforced)**
### T2 — Coerción de tipos en todo schema Pydantic
LLMs estringifican ints y arrays (mandan `"95727067"` cuando el schema
pide `int`). Pydantic 422 → agent falla. Toda input model lleva:
```python
from pydantic import BaseModel, field_validator
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list
class MyInput(BaseModel):
company_id: int
groups: list[str]
_coerce_company_id = field_validator("company_id", mode="before")(_coerce_int)
_coerce_groups = field_validator("groups", mode="before")(_coerce_list)
```
**(enforced)**
### T3 — `_compat.py` inline en cada archivo de tools
La ADK importa cada `tools.py` standalone. Si tu archivo hace
`from .._compat import ...`, el TRM falla con `ModuleNotFoundError`.
El shim de compat se inlinea al inicio de cada archivo. El template ya
lo trae. **(enforced)**
### T4 — Toda tool lee `BASE_URL` del environment de su connection
Nunca hardcodear la URL del sistema externo. La connection inyecta
`BASE_URL`. Cambio de tenant = cambio de credencial, no de código.
### T5 — Una connection = un dominio = un `app_id`
3 conexiones (unus, dynatrace, ad) en Cotemar = 3 dominios. Mezclar
varios dominios en una connection rompe el aislamiento de credenciales.
### T6 — Conexiones en BOTH `draft` Y `live`
Si solo configurás `draft`, el `agents deploy --env live` falla con
`kid not found`. El `deploy-wxo.sh` hace los dos pases siempre.
### T7 — Tipos de tool: elegir el correcto
- **OpenAPI** (preferido si tenés backend): un solo spec, ADK importa todo, version-controlled
- **Python `@tool`** (preferido para mocks): rápido para prototipar, fácil de testear
- **MCP** (preferido si el sistema externo lo expone): trae types + permisos
- Ver `tool-authoring-guide.md` para el árbol de decisión.
### T8 — OpenAPI: `description` per-operation OBLIGATORIO
ADK falla con "No description provided" si solo hay `summary`. Si tu
backend usa FastAPI sin docstrings, exponer el spec así:
```python
op["description"] = op.get("summary") or "Endpoint XYZ"
```
**(enforced — el linter lee el spec y verifica)**
### T9 — OpenAPI: `security` per-operation, NO global
ADK no hereda el `security` global. Re-declaralo en cada operación.
### T10 — Filtrar el spec OpenAPI que exponés a WxO
No mandes el openapi.json completo de tu backend. Filtralo a una
allowlist `PUBLIC_TOOLS` y resolvé `$ref` transitivamente para que solo
se expongan los schemas usados. Ver `wxo/tools/openapi/_backend_filter_endpoint.py`.
## 🔌 Conexiones
### C1 — `key_value` para mocks sin auth, `api_key` para producción
Mocks demo: `auth_type: key_value` con un solo campo `BASE_URL`. Producción:
`auth_type: api_key` con header **custom** (no `Authorization`).
### C2 — Header custom, no Bearer
Usar `Authorization: Bearer …` choca con el IAM token que Orchestrate ya
inyecta para llamar al tool. Usar `X-Orchestrate-Token` o similar.
### C3 — `set-credentials` en cada deploy
Tu `deploy-wxo.sh` invoca `connections set-credentials` siempre, no solo
al crear. Rotación de secretos = re-deploy del script.
### C4 — Fallback de subcomandos ADK
Diferentes versiones de la ADK usan `set-identifier --url` vs
`configure --url`. El script intenta los dos y warnea si ninguno existe.
## 📚 Knowledge Bases
### KB1 — Runbooks como `.txt` con secciones fijas
- **Trigger** (cuándo aplica)
- **Precondiciones** (qué chequeo antes)
- **Pasos** (1, 2, 3 — verbo en imperativo)
- **Éxito** (qué confirma que salió bien)
- **Fallo** (qué hacer si falla cada paso)
- **Escalamiento** (a quién/cómo si excede autoridad)
### KB2 — Embeddings: `ibm/slate-125m-english-rtrvr`
Default sano para español + inglés mezclados. Si tu corpus es solo
español, evaluá modelos multilingual de IBM.
## 🚢 Deploy / Coolify
### D1 — Healthcheck con `wget -qO-` o `curl -sf`
`wget --spider` es buggy en busybox → marca container unhealthy → Traefik
da 503. **(enforced en lint del docker-compose)**
### D2 — NO declarar labels Traefik manuales en docker-compose
Coolify v4 los autogenera del panel FQDN. Si los declarás vos, sale
"TRAEFIK DEFAULT CERT" en lugar de Let's Encrypt. Solo:
```yaml
labels:
- "traefik.enable=true"
- "traefik.docker.network=coolify"
```
### D3 — Network split: `internal: true` para servicios sin internet
Solo el web/landing va al network `coolify`. Backend, DB, workers → red
interna. Mejor aislamiento + cero exposición accidental.
### D4 — `--force-recreate` después de cambiar `.env`
`restart` no relee variables de entorno. Si cambiás `.env`, hacé
`docker compose up -d --force-recreate`.
## 🔍 Observabilidad
### O1 — `write_audit("tool.<name>", payload)` desde el backend
Cada handler escribe un audit log con el `case_id` o `correlation_id`. La
UI reconstruye el timeline desde acá, no desde trazas de Orchestrate
(que son volátiles y no queryable).
### O2 — Outputs persistidos para granular retry
`ExecutionStep.output_payload` guarda `content_b64`, `zip_b64`,
`package_url`. Si el caso falla en el paso 4, reanudás desde 3 sin
regenerar 1-2.
### O3 — Webhook receiver con HMAC-SHA256
Si recibís webhooks de WxO (Plan A o run lifecycle), validá firma:
`X-Orchestrate-Timestamp` + `Nonce` + `Signature`, skew tolerance 300s,
408 si stale, 401 si firma mala.
### O4 — Watchdog para estados atascados
Si un caso queda en `executing` >90s, el siguiente `GET` lo auto-promueve
a `paused` para que el usuario reintente. Sin esto, conexiones caídas
dejan basura.
## 🧪 Evals
### E1 — Cuatro capas de eval
1. **Native**`orchestrate agents test <agent> --input-file scenario.json` (fixture-based)
2. **Agent behavior** — verifica delegation/escalation correcta sobre dataset
3. **Runbook compliance** — verifica que el ticket final tenga campos esperados
4. **Web layer** — Playwright o requests asserts sobre la UI
Ver `eval-strategy.md` para detalles.
### E2 — Fixtures JSON versionados
Bajo `evals/scenarios/*.json`, ejecutables con `orchestrate agents test`.
Cada scenario cubre un caso (happy path, edge case, failure).
### E3 — Smoke test en bash
`evals/smoke-test.sh` cubre login → create → execute → poll → assert.
Útil para CI y para reproducir bugs reportados.
### E4 — Direct backend probe
`evals/direct-backend-probe.sh` golpea el backend directamente con curl +
el token para aislar "¿es problema del agente o de la auth?". 200 con
error de lógica → agent issue. 401 → connection/credentials.
## 🚦 Versionado del ADK
### V1 — Pin a `ibm-watsonx-orchestrate==2.1.0`
Cambios entre minor versions han roto schemas. `requirements.txt` pinea
exact version.
### V2 — `check-adk-version.sh` avisa de versiones nuevas
Compara el pin con el último en PyPI y warneaa. **NO actualices sin
probar todas las evals primero**.
---
Para ver dónde se aplica cada regla, leé el código del linter:
[`../evals/lint_wxo_yaml.py`](../evals/lint_wxo_yaml.py).