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:
218
docs/DEPLOY_TO_NEW_WOX.md
Normal file
218
docs/DEPLOY_TO_NEW_WOX.md
Normal 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
35
docs/INDEX.md
Normal 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
178
docs/RUNBOOK.md
Normal 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
262
docs/adk-2x-cheatsheet.md
Normal 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.
|
||||
192
docs/architecture-patterns.md
Normal file
192
docs/architecture-patterns.md
Normal 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.).
|
||||
|
||||
- **0–1 dominio** → 1 agente solo. Salí del árbol con la **Topología Single**.
|
||||
- **2–5 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
149
docs/deployment-guide.md
Normal 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
247
docs/eval-strategy.md
Normal 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
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".
|
||||
178
docs/observability-pattern.md
Normal file
178
docs/observability-pattern.md
Normal 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).
|
||||
314
docs/tool-authoring-guide.md
Normal file
314
docs/tool-authoring-guide.md
Normal 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
253
docs/wxo-best-practices.md
Normal 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).
|
||||
Reference in New Issue
Block a user