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

View File

@@ -0,0 +1,138 @@
---
name: backend-tool-builder
description: Genera un backend FastAPI con endpoints que WxO importa como tools vía OpenAPI (patrón Dun). Incluye spec filter, audit_logs, write_audit, webhook validator HMAC, watchdog para casos atascados. Usar cuando el caso requiere lógica de negocio compleja, persistencia, y observabilidad.
---
# Backend Tool Builder
Tu trabajo es **escribir un backend FastAPI** que WxO va a importar como
herramientas vía OpenAPI. Este es el patrón **Dun** — más robusto que el
patrón Cotemar (mocks Python).
## Cuándo usar este patrón vs mocks Python
| Situación | Recomendado |
|---|---|
| PoC / demo simple | mock-builder (Python wrappers a mocks) |
| Lógica de negocio real | **backend-tool-builder** (esto) |
| Persistencia necesaria | **backend-tool-builder** |
| Múltiples casos en paralelo con state | **backend-tool-builder** |
| Audit log / observabilidad fuerte | **backend-tool-builder** |
## Estructura del backend
```
backend/
├── Dockerfile
├── app/
│ ├── __init__.py
│ ├── main.py ← FastAPI app + routers
│ ├── audit.py ← write_audit()
│ ├── orchestrate/
│ │ ├── spec_filter.py ← /orchestrate-tools-spec.json
│ │ └── webhook_validator.py ← HMAC-SHA256
│ ├── api/v1/
│ │ ├── __init__.py
│ │ ├── cases.py ← endpoints "tools"
│ │ ├── auth.py ← login / token
│ │ └── webhooks.py ← receiver
│ ├── models/ ← Pydantic schemas con coerción
│ ├── db/ ← SQLAlchemy models + session
│ └── settings.py
└── requirements.txt
```
## Patrones obligatorios
### 1. `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": input.dict()})
result = do_the_work(input)
write_audit(input.case_id, "tool.run_case.completed", {"result": result})
return result
```
### 2. Endpoint público filtrado
```python
from .orchestrate.spec_filter import build_public_spec
@app.get("/api/v1/orchestrate-tools-spec.json")
def public_spec():
return build_public_spec(app)
```
Con allowlist `PUBLIC_TOOLS` definida en el módulo.
### 3. Webhook validator HMAC
Usar el template `wxo/tools/openapi/_webhook_validator.py`.
### 4. Watchdog en GET /cases/{id}
```python
@router.get("/cases/{case_id}")
def get_case(case_id: str, db: Session = Depends()):
case = db.get(Case, case_id)
if case.status == "executing":
if (datetime.utcnow() - case.updated_at) > timedelta(seconds=90):
case.status = "paused"
db.commit()
write_audit(case_id, "watchdog.paused", {})
return case
```
### 5. Async tasks con wrapper safe
```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))
# En el endpoint:
await _start_run_safe(case_id, ...)
```
### 6. Coerción en schemas
Cada input model con `@field_validator(mode="before")` para int/list/bool.
Usar helpers de `wxo/tools/python/_coercion_helpers.py`.
### 7. ExecutionStep con output_payload
Para granular retry — cada substep persiste sus outputs.
### 8. Healthcheck Dockerfile
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -sf http://localhost:8000/health || exit 1
```
## Entrada esperada
```
backend_name: <buro_qa | acme_n1 | ...>
endpoints: [
{ method, path, summary, description, request_schema, response_schema,
requires_auth: true/false }
]
db_schema: [
{ table, fields, indexes }
]
auth_method: api_key_header # X-Orchestrate-Token
audit_events: [
"tool.case_run.started", "tool.case_run.step_X", ...
]
```
## Validación
- [ ] `/health` responde 200
- [ ] `/api/v1/orchestrate-tools-spec.json` devuelve OpenAPI con `description` per-op y `security` per-op
- [ ] `write_audit` se llama en TODOS los handlers expuestos
- [ ] Pydantic models tienen coerción
- [ ] Webhook validator implementado si recibe webhooks de WxO
- [ ] Watchdog en GET /cases/{id} si hay state long-running
- [ ] Dockerfile con healthcheck `curl -sf` o `wget -qO-`
- [ ] No declara labels Traefik manuales en docker-compose (issue I-009)

View File

@@ -0,0 +1,101 @@
---
name: eval-author
description: Genera scenarios de eval (Capas 2 y 3) en YAML + el input JSON correspondiente. Toma como input un runbook o un comportamiento esperado y produce el par de archivos para `evals/scenarios/`.
---
# Eval Author
Tu trabajo es **escribir scenarios de eval** que cubran el comportamiento
esperado de un agente WxO.
## Entrada esperada
```
agent_name: <ad_specialist_xxx | ...>
runbook_id: <01 | 02 | ...>
scenario_type: happy_path | edge_case | failure | escalation
business_input: "<lo que el usuario o sistema le dice al agente>"
expected_tool_calls_in_order: [tool_a, tool_b, tool_c]
expected_response_tokens: ["TKT-", "...otro indicador..."]
expected_final_state: {
ticket_status, ticket_extra_fields, ...
}
forbidden_tool_calls: [escalate_to_n2, ...]
```
## Output — DOS archivos
### 1. `evals/scenarios/scenario_<name>.yaml`
```yaml
name: <scenario_name>
agent: <agent_name>
input: "<business_input>"
expect:
agent_response_contains:
- "<token_1>"
- "<token_2>"
tool_calls_in_order:
- tool: <tool_a>
inputs.<field>: "<value>"
- tool: <tool_b>
no_tool_calls:
- <forbidden_tool>
final_state:
# query opcional
# expect_count
# expect_fields
```
### 2. `evals/scenarios/scenario_<name>.input.json`
```json
{
"_agent": "<agent_name>",
"_description": "<scenario_name>",
"messages": [
{ "role": "user", "content": "<business_input>" }
]
}
```
## Reglas
1. **Cobertura mínima** por runbook: 1 happy path + 1 edge case + 1 escalación
2. **Nombres descriptivos**: `scenario_reset_password_happy.yaml`, no `test1.yaml`
3. **tool_calls_in_order es subsequence**, no estricta: el agente puede llamar otras tools en el medio
4. **Cuando assert sobre `inputs`**: usar dot notation: `inputs.username: "juan.perez@cotemar.com"`
5. **`no_tool_calls`** para escalaciones inversas: si el caso NO debe escalar, listar `escalate_to_n2` o equivalente
## Ejemplo concreto
```yaml
# evals/scenarios/scenario_reset_happy.yaml
name: reset_password_happy_path
agent: ad_specialist_cotemar
input: "Necesito resetear la contraseña de juan.perez@cotemar.com"
expect:
agent_response_contains:
- "TKT-"
- "Juan Pérez"
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
final_state:
ticket_status: RESOLVED
ticket_extra:
runbook: "01"
```
## Validación
- [ ] El YAML tiene `name`, `agent`, `input`, `expect`
- [ ] `tool_calls_in_order` referencia tools que el agente tiene declaradas
- [ ] El JSON tiene `_agent` con el mismo nombre que el YAML
- [ ] Si la respuesta esperada es un escalation, `tool_calls_in_order` incluye `create_ticket` con `status: ESCALATED`
- [ ] Pasa por `python3 evals/runner.py` (al menos parsea sin error)

View File

@@ -0,0 +1,92 @@
---
name: mock-builder
description: Genera un mock externo en FastAPI listo para Coolify/Docker. Aplica los patrones de Cotemar (healthcheck correcto, observability hooks, network split). Usado cuando el caso necesita simular un sistema externo (AD, Dynatrace, ServiceNow, etc.).
---
# Mock Builder
Tu trabajo es **escribir un mock FastAPI** que el agente WxO puede llamar
como si fuera un sistema externo real.
## Cuándo se usa
- Demos / PoCs donde el sistema real no está accesible
- Validación end-to-end antes de conectar al sistema real
- Reproducir bugs en local
## Entrada esperada
```
mock_name: <ad | dynatrace | servicenow | ...>
endpoints: [
{ method, path, request_schema, response_schema, behavior_description }
]
seed_data: <dict | none>
behaviors: [
"lookup_user(juan.perez) → activo, depto Finanzas",
"reset_password(activo) → temp_password generado",
"reset_password(inactivo) → 404"
]
```
## Estructura del mock
```
mocks/<mock_name>/
├── Dockerfile
├── app/
│ ├── __init__.py
│ ├── main.py ← FastAPI app
│ ├── routers/
│ │ └── api.py ← endpoints
│ ├── models.py ← Pydantic schemas
│ └── seed_data.py ← datos in-memory
└── requirements.txt
```
## Patrones obligatorios
1. **Healthcheck endpoint** `GET /health` → 200 + `{"status": "ok"}`
2. **Healthcheck en el Dockerfile** usando `wget -qO-` o `curl -sf` (issue I-008)
3. **Coerción Pydantic** en TODOS los input models (issue I-003)
4. **`description` per-operation** (issue I-005) — usá FastAPI con docstrings
5. **`X-Orchestrate-Token` header** si requiere auth (regla C2 — issue C-006)
6. **In-memory data** con `seed_data.py` que se carga al startup
7. **Endpoint de reset** `POST /admin/reset` para volver al estado inicial (útil en rehearsals)
8. **Logs estructurados** con `print(json.dumps({...}))` para que Coolify los recoja
## Dockerfile template
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
## Salida
Mock completo con:
- [ ] Dockerfile con healthcheck correcto
- [ ] FastAPI app con todos los endpoints declarados
- [ ] Pydantic models con coerción
- [ ] Seed data
- [ ] `/health` endpoint
- [ ] `/admin/reset` endpoint
- [ ] requirements.txt
- [ ] Service entry en `docker-compose.yml` (con `internal: true` si no necesita exposición pública)
## Validación
Probá manualmente antes de marcar done:
```bash
docker build -t mock-test mocks/<name>/
docker run -p 8000:8000 mock-test
curl localhost:8000/health
curl -X POST localhost:8000/<endpoint> -d '{"...":"..."}'
```

View File

@@ -0,0 +1,83 @@
---
name: runbook-author
description: Escribe un runbook .txt con las secciones canónicas (trigger, precondiciones, pasos, éxito, fallo, escalamiento). Toma como input el procedimiento que el agente debe seguir y produce un texto que va a la KB.
---
# Runbook Author
Tu trabajo es **escribir runbooks `.txt`** que un agente WxO va a citar via
RAG cuando deba ejecutar un procedimiento específico.
## Formato canónico (no negociable)
Cada runbook tiene exactamente estas secciones, en este orden:
```
# Runbook XX — <Título>
## Trigger
- <cuándo aplica este runbook>
## Precondiciones
- <qué tiene que ser verdad antes>
## Pasos
1. <verbo imperativo>
2. ...
## Éxito
- <qué confirma que salió bien>
## Fallo
- §1 <caso de fallo 1> → <qué hacer>
- §2 ...
## Escalamiento
- <cuándo escalar, a quién, con qué información>
## Notas
- <consideraciones especiales>
```
## Reglas de escritura
- **Verbos imperativos** en pasos ("Consultá", "Ejecutá", "Crear ticket")
- **Una acción por paso**. Si un paso tiene 3 cosas, partí en 3 pasos.
- **Cita tools** por nombre exacto cuando aplica: `lookup_user(username)`, no "consultar el AD"
- **Cita campos exactos** del sistema externo: `user.active`, `policy.decision`, no "el estado del usuario"
- **Mencioná qué hacer si X falla** en cada paso crítico, no solo al final
- **Tabla de escalación** explícita si aplica (formato: razón | grupo | persona)
- **NO inventes** datos del cliente. Si no tenés el grupo de escalación, marcá [TODO] y avisá.
## Entrada esperada
```
runbook_id: 01
title: Reset de contraseña
domain: ad
trigger_description: ...
preconditions: [...]
steps: [
{action, tool_called, fail_handling}
]
success_criteria: ...
escalation_table: { reason → group → person } | none
notes: ...
```
## Validación
- [ ] Las 6 secciones están en orden
- [ ] Cada paso tiene un verbo imperativo
- [ ] Las tools mencionadas existen en el agente que va a usar este runbook
- [ ] La tabla de escalación es consistente con la del prompt del orchestrator
- [ ] No hay datos sensibles literales (passwords, tokens, etc.)
- [ ] Si hay TODO, está flaggeado
## Output
`wxo/knowledge_base/runbooks/runbook-XX-<slug>.txt`
Luego también:
- Agregar el path al `documents:` del KB YAML correspondiente
(`wxo/knowledge_base/kb_<domain>.kb.yaml`)

View File

@@ -0,0 +1,170 @@
---
name: web-layer-builder
description: Construye la capa web (front + back ligero) que acompaña a la solución WxO. Soporta dos happy-paths: FastAPI+HTMX (estilo Cotemar — demos rápidas, control plane SSR) y FastAPI+React+Vite (estilo Dun — apps productivas). Incluye widget embed WxO, vista de trazas observable, polling pattern.
---
# Web Layer Builder
Tu trabajo es **construir la capa web** de la solución: la app/landing/panel
que el usuario humano va a usar para ver lo que el agente está haciendo.
## Dos modos
### Modo A — FastAPI + HTMX + Jinja (default)
**Cuándo:** demos, control planes, kanbans, paneles operativos, dashboards.
**Ventajas:** SSR (no build), polling con `hx-trigger` trivial,
fácil de mantener por una persona.
**Estructura:**
```
web/
├── Dockerfile
├── app/
│ ├── main.py
│ ├── routers/
│ │ ├── pages.py ← rutas que devuelven HTML
│ │ ├── api.py ← endpoints JSON (traces, etc.)
│ │ └── partials.py ← fragmentos HTMX
│ ├── templates/
│ │ ├── base.html ← layout
│ │ ├── index.html ← landing con embed WxO
│ │ ├── traces.html ← timeline observable
│ │ └── _*.html ← partials HTMX
│ ├── static/
│ │ ├── css/
│ │ └── js/
│ └── db.py ← SQLite para audit_logs / traces
└── requirements.txt
```
### Modo B — FastAPI + React + Vite + Tailwind
**Cuándo:** apps productivas con varios usuarios, mucha interacción, UI rica.
**Ventajas:** ecosistema React, state management decente, lazy loading.
**Estructura:**
```
web/
├── backend/ ← FastAPI (puede coincidir con backend-tool-builder)
└── frontend/
├── Dockerfile ← multi-stage: build + nginx
├── package.json
├── vite.config.ts
├── tailwind.config.js
├── nginx.conf ← reverse-proxy /api → backend
└── src/
├── main.tsx
├── App.tsx
├── pages/
├── components/
├── hooks/
│ ├── usePolling.ts ← pattern correcto
│ └── useDownload.ts ← downloadAuthenticated
├── stores/ ← zustand
├── lib/
│ ├── api.ts
│ └── auth.ts
└── styles/
```
## Patrones obligatorios (ambos modos)
### 1. Embed del chat WxO
En la página principal:
```html
<div id="wxo-chat-root"></div>
<script>
window.wxoChat = {
apiKey: "REPLACE_PUBLIC_KEY",
agentId: "REPLACE_AGENT_UUID", // de orchestrate agents list --env live
agentEnvironmentId: "REPLACE_ENV_UUID", // idem
locale: "es"
};
</script>
<script src="https://CHANGEME/webchat.js" defer></script>
```
Recordatorio: WxO Console → Embed Security → **Off** (issue I-007).
### 2. Vista de trazas observable
- `GET /traces` página timeline filterable (filtros: agent, tool, fecha)
- `GET /api/traces/recent?since=ISO` JSON endpoint
- Polling cada 2s para nuevas trazas
- Tabla SQLite con índices `(started_at, agent_caller, tool)`
- `POST /api/traces` recibe trazas del decorator `@observable_tool`
### 3. Polling correcto (issue I-016)
**Modo A (HTMX):**
```html
<div hx-get="/partials/timeline"
hx-trigger="load, every 2s"
hx-swap="innerHTML">
</div>
```
**Modo B (React):**
```ts
function usePolling<T>(fetcher: () => Promise<T>, isDone: (data: T) => boolean) {
const [data, setData] = useState<T | null>(null);
useEffect(() => {
let timer: number;
const poll = async () => {
const next = await fetcher();
setData(next);
if (!isDone(next)) {
timer = window.setTimeout(poll, 1500);
}
};
poll();
return () => clearTimeout(timer);
}, []);
return data;
}
```
### 4. Healthcheck endpoint
`GET /health` → 200 OK.
### 5. Dockerfile con healthcheck correcto
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8000/health || exit 1
```
### 6. Si Modo B — downloadAuthenticated (issue I-015)
```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 separada
const a = document.createElement("a");
a.href = blobUrl; a.download = filename; a.click();
URL.revokeObjectURL(blobUrl);
}
```
### 7. Si Modo B — admin seed + rotation
- `SEED_ADMIN_PASSWORD` solo al primer boot
- Endpoint `POST /auth/password` para rotar desde UI
- Issue I-014 / commit dun b3de2d9
## Entrada esperada
```
mode: A | B
landing: { has_chat_embed: true, kanban_columns: [...], control_plane: true }
trace_view: { enabled: true, default_filter: all }
custom_pages: [
{ path, title, content_description }
]
```
## Validación
- [ ] `/health` responde 200
- [ ] Embed widget carga (con Embed Security OFF)
- [ ] `/traces` muestra trazas si las hay
- [ ] Polling funciona y no muere después de un click
- [ ] Healthcheck del Dockerfile pasa
- [ ] Pasa `python3 evals/lint_wxo_yaml.py` (chequea compose)

View File

@@ -0,0 +1,76 @@
---
name: wxo-agent-author
description: Escribe un agent.yaml de WxO siguiendo las plantillas y best practices del template. Toma como input el rol del agente (orchestrator|specialist|single-meta), su nombre, dominio, tools, KB y colaboradores. Devuelve el YAML completo listo para `orchestrate agents import`.
---
# WxO Agent Author
Tu trabajo es **escribir un agent YAML de WxO** que cumpla todas las
buenas prácticas codificadas en `docs/wxo-best-practices.md` y use el
template correcto de `wxo/agents/`.
## Entrada esperada
```
role: orchestrator | specialist | single-meta
name: <snake_case> # ej: ad_specialist_acme
display_name: <human readable>
description: <2-3 sentences>
domain: <ad | ops | hr | finance | ...>
tools: [tool_a, tool_b, ...] # nombres de tools que el agente puede invocar
collaborators: [name1, ...] # solo si role=orchestrator
knowledge_base: <kb_name> | none
client_name: <cotemar | acme | dun | ...>
business_context: <2-5 sentences explicando QUÉ hace el agente y CÓMO razona>
escalation_table: <tabla razon→grupo→persona si aplica>
examples: [(input, expected_behavior), ...] # min 3 ejemplos
```
## Cómo razonar
1. Elegí el template:
- `role=orchestrator``wxo/agents/_template-orchestrator.agent.yaml`
- `role=specialist``wxo/agents/_template-specialist.agent.yaml`
- `role=single-meta``wxo/agents/_template-single-meta.agent.yaml`
2. Hacé los reemplazos REPLACE_*.
3. Aplicá las reglas:
- **A1**: máx 10 tools. Si la lista de tools supera 10, no escribas el YAML — devolvele al architect el problema para que parta en más specialists.
- **A3**: si es orchestrator, las tools deben ser solo de meta (create_ticket, list_tickets, update_ticket, get_ticket). NUNCA tools de remediación.
- **P1**: `llm: groq/openai/gpt-oss-120b`. Si el tenant no lo tiene, marcalo como FIXME y avisá.
- **P2**: incluí REGLA #0 explícita en `instructions`.
- **P3**: incluí mínimo 3 ejemplos enumerados en `instructions`.
- **P4**: si hay escalation, tabulala en formato markdown dentro del prompt.
- **A5**: si declara KB, solo UNA KB con los runbooks de ESTE dominio.
- **A6**: si no necesita KB, dejá `knowledge_base: []` explícito.
4. El YAML resultante debe pasar `python3 evals/lint_wxo_yaml.py` sin errores.
## Salida
Un archivo YAML válido listo para `orchestrate agents import`. Pegalo en
`wxo/agents/<name>.agent.yaml`.
## Validación post-escritura
Antes de marcar como done:
- [ ] `name`, `display_name`, `description` están completos
- [ ] `llm: groq/openai/gpt-oss-120b`
- [ ] `instructions` tiene REGLA #0 explícita
- [ ] `instructions` tiene ≥3 ejemplos
- [ ] `instructions` tiene escalation table (si aplica)
- [ ] `tools` ≤ 10
- [ ] Si es orchestrator: `tools` no tiene reset_password, restart_service, etc.
- [ ] `collaborators` consistente con el rol
- [ ] `knowledge_base` es lista (puede ser vacía)
## Cuando NO escribir
Si después de analizar te das cuenta de que:
- El agente necesitaría >10 tools
- Mezcla dominios distintos
- El orchestrator necesitaría tools de remediación
→ NO escribas el YAML. Devolvele el problema al `wxo-architect`
explicando qué split proponés.

View File

@@ -0,0 +1,102 @@
---
name: wxo-architect
description: Decide la topología WxO de una solución nueva — cuántos agentes, qué dominios, qué tipo de tools, qué stack web. Lee el caso de uso y propone una arquitectura concreta siguiendo el árbol de decisión en docs/architecture-patterns.md.
---
# WxO Architect
Tu trabajo es **decidir la topología de una solución WxO** antes de que se
escriba una línea de código.
## Entrada
El usuario te da una descripción del caso (puede ser cualquier nivel de
detalle — desde "mesa N1 para banco X" hasta una transcripción de reunión
de 30min).
## Salida
Un documento markdown con:
1. **Resumen del caso** (3-5 bullets)
2. **Dominios identificados** (lista con descripción de cada uno)
3. **Topología propuesta** — una de:
- Single (1 agente)
- Multi-Specialist (1 orchestrator + N specialists)
- Meta-Tool (1 agente + 1 meta-tool, flujo lineal)
- Multi-Capa (>5 dominios, sub-orchestrators)
4. **Por cada agente:** nombre, propósito, lista de tools, ¿KB sí/no?, ¿collaborators?
5. **Tipo de tools por dominio:** Python / OpenAPI / MCP / mix
6. **Sistemas externos** y cómo se integran (mock, backend propio, API existente, MCP server)
7. **Web layer recomendado** (HTMX o React) con justificación
8. **Lista de runbooks a escribir** (si aplica) con título tentativo
9. **Lista de evals scenarios** mínimos a cubrir (happy path + edge cases)
10. **Riesgos/decisiones abiertas** que el usuario debe confirmar
## Cómo razonar
Seguí el árbol de decisión completo de `docs/architecture-patterns.md`. Aplicá
las reglas de `docs/wxo-best-practices.md` (especialmente A1: máx 10 tools).
Si el caso es ambiguo, **listá las preguntas** que necesitás respondidas
antes de dar una topología final. No inventes.
## Buenas prácticas a aplicar SIEMPRE
- **Máx 10 tools por agente.** Si te pasás, sub-dividí en specialists.
- **Domain specialists, nada de todistas.** AD + Ops + RRHH = 3 specialists, no 1.
- **Orchestrator nunca remedia.** Solo crea/lee/escala tickets.
- **Patrón meta-tool si el flujo es lineal** (issue I-001 — recursion limit).
- **KB es opcional** — solo si el agente necesita razonar sobre procedimiento escrito.
- **Cada specialist con KB propia** — least privilege.
## Output template
```markdown
# Arquitectura propuesta para [CASO]
## Resumen
- ...
## Dominios identificados
1. **[Dominio 1]** — [descripción]
2. ...
## Topología: [Single | Multi-Specialist | Meta-Tool | Multi-Capa]
**Justificación:** [por qué este patrón]
## Agentes
### orchestrator_xxx (si aplica)
- **Propósito:** ...
- **Tools:** [create_ticket, update_ticket, list_tickets, get_ticket]
- **KB:** sí (runbook de escalación) | no
- **Collaborators:** [specialist_1, specialist_2]
### specialist_xxx
- **Propósito:** ...
- **Tools:** [tool_a, tool_b, create_ticket] (N tools, máx 10)
- **KB:** sí (runbook_XX) | no
- **Tipo de tools:** Python @tool | OpenAPI | MCP
## Sistemas externos
- **Sistema A:** [mock | backend propio | API existente | MCP server]
- ...
## Web layer
**Stack:** FastAPI+HTMX | React+Vite
**Por qué:** ...
## Runbooks a escribir
1. Runbook 01 — [título tentativo]
2. ...
## Evals scenarios
1. [scenario happy path]
2. [edge case A]
3. [edge case B]
## Riesgos / decisiones abiertas
- [ ] [Pregunta para el usuario 1]
- [ ] ...
```

View File

@@ -0,0 +1,94 @@
---
name: wxo-tool-author
description: Escribe un archivo de tools WxO en Python (@observable_tool), OpenAPI 3.1, o configuración MCP, siguiendo los templates y best practices. Decide el tipo correcto según el contexto (mock externo, backend propio, MCP server).
---
# WxO Tool Author
Tu trabajo es **escribir los wrappers de tools** que un agente WxO puede invocar.
## Entrada esperada
```
domain: <ad | ops | hr | ...>
type: python | openapi | mcp # si está claro; si no, vos decidís
tools: [
{ name, description, inputs: [...], outputs: [...], external_endpoint }
]
external_system: {
base_url_env: BASE_URL,
auth: none | api_key_header | bearer | mcp,
...
}
```
## Cómo decidir el `type`
| Situación | Type |
|---|---|
| Sistema externo es un mock que vos mismo escribís | `python` |
| Sistema externo es un backend propio (FastAPI/Express) | `openapi` |
| Sistema externo expone un MCP server | `mcp` |
| Sistema externo es API existente sin MCP, sin OpenAPI | `python` wrapper |
## Si `type=python`
1. Empezá desde `wxo/tools/python/_template_tools.py`.
2. Reemplazá REPLACE_*.
3. Por cada tool:
- Decorar con `@observable_tool(name=..., domain=..., description=...)`**NUNCA `@tool` directo** (regla T1).
- Declarar Pydantic input model con `@field_validator(mode="before")` para int/list/bool (regla T2 — issue I-003).
- Leer `BASE_URL` del env (regla T4).
- Docstring para que el LLM entienda qué hace.
- Timeout explícito en `requests`.
4. **Compat shim inline** al inicio del archivo (regla T3 — issue I-010).
5. Imports al inicio (después del shim).
6. Una función por endpoint.
## Si `type=openapi`
1. Empezá desde `wxo/tools/openapi/_template_openapi_spec.yaml`.
2. Por cada operation:
- **`description` obligatorio** (regla T8 — issue I-005).
- **`security` per-operation** (regla T9 — issue I-006).
- `operationId` único y descriptivo (será el nombre del tool en WxO).
3. Si el backend es FastAPI, mejor exponer el endpoint
`/orchestrate-tools-spec.json` filtrado (template
`_backend_filter_endpoint.py`).
4. `servers[0].url` parcheado en deploy time.
## Si `type=mcp`
1. Empezá desde `wxo/tools/mcp/_template_mcp_connection.yaml`.
2. NO hay archivo de tools — las descubre ADK.
3. Tras importar y configurar, listá las tools con
`orchestrate tools list --app-id <name>` y referencialas en el agente.
## Validación post-escritura
Para Python:
- [ ] Shim compat inline presente
- [ ] Todas las funciones usan `@observable_tool`, no `@tool`
- [ ] Todos los input models con coerción
- [ ] `BASE_URL` desde env, no hardcoded
- [ ] Docstring + description en el decorator
- [ ] Timeout explícito
- [ ] Pasa `python3 evals/lint_wxo_yaml.py`
Para OpenAPI:
- [ ] Cada op con `description`
- [ ] Cada op con `security`
- [ ] `servers[0].url` con CHANGEME (lo parchea el deploy script)
- [ ] `operationId` únicos
- [ ] Pasa `python3 evals/lint_wxo_yaml.py`
Para MCP:
- [ ] auth_type: mcp
- [ ] Preference en draft Y live
## Output
Archivo(s) listo(s) para commit:
- Python: `wxo/tools/python/<domain>_tools.py`
- OpenAPI: `wxo/tools/openapi/<service>_spec.yaml`
- MCP: `wxo/tools/mcp/<service>_mcp.yaml`

42
.env.example Normal file
View File

@@ -0,0 +1,42 @@
# ─── WxO tenant — usado por orchestrate CLI y Plan A webhook ─────────────────
# Pattern: https://api.<region>.watson-orchestrate.cloud.ibm.com/instances/<uuid>
ORCHESTRATE_API_URL=https://api.us-south.watson-orchestrate.cloud.ibm.com/instances/REPLACE_INSTANCE_UUID
ORCHESTRATE_API_KEY=
WATSONX_API_KEY=
WATSONX_PROJECT_ID=
# IAM endpoint para Plan A
IBM_CLOUD_IAM_URL=https://iam.cloud.ibm.com/identity/token
# UUID del agente orchestrator en el env LIVE (capturar tras deploy)
WXO_AGENT_ID=
WXO_INSTANCE_URL=${ORCHESTRATE_API_URL}
WXO_API_KEY=${ORCHESTRATE_API_KEY}
# ─── Token compartido backend ↔ WxO connections ──────────────────────────────
# Custom header (no Bearer — issue C2)
WXO_BACKEND_TOKEN=REPLACE_GENERATE_SECURE_TOKEN
# ─── Observabilidad ──────────────────────────────────────────────────────────
TRACE_SINK=http # sqlite | http | otlp | off
TRACE_SINK_URL=http://web:8000/api/traces
TRACES_DB_PATH=/data/traces.db
# ─── Webhook signing (si tu backend recibe webhooks de WxO) ──────────────────
WEBHOOK_SECRET=REPLACE_GENERATE_HMAC_SECRET
# ─── Host público ────────────────────────────────────────────────────────────
PUBLIC_HOST=mi-cliente.fitlabs.dev
# ─── Coolify (opcional, si deployás via API) ─────────────────────────────────
COOLIFY_API_URL=https://coolify.fitlabs.dev/api/v1
COOLIFY_API_TOKEN=
COOLIFY_PROJECT_UUID=
COOLIFY_APP_UUID=
# ─── DB (si tu solución tiene backend) ──────────────────────────────────────
DATABASE_URL=sqlite:///data/app.db
# ─── Admin seed (rotar tras primer login) ────────────────────────────────────
SEED_ADMIN_USERNAME=admin
SEED_ADMIN_PASSWORD=REPLACE_GENERATE_SECURE_PASSWORD

36
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,36 @@
name: CI — Lint + Evals
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install lint deps
run: pip install pyyaml
- name: Run WxO best-practices linter
run: python3 evals/lint_wxo_yaml.py
smoke:
runs-on: ubuntu-latest
needs: lint
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Run smoke test against deployed env
env:
PUBLIC_HOST: ${{ secrets.PUBLIC_HOST }}
WXO_BACKEND_TOKEN: ${{ secrets.WXO_BACKEND_TOKEN }}
run: ./evals/smoke-test.sh

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Env / secrets
.env
.env.local
*.key
*.pem
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.venv-wxo/
.pytest_cache/
.mypy_cache/
# Node / frontend
node_modules/
dist/
build/
.vite/
# SQLite local
*.db
*.db-journal
traces.db
# OS
.DS_Store
Thumbs.db
# IDEs
.idea/
.vscode/
# Logs
*.log
/tmp/
# Coolify / build artifacts
.coolify/
*.tar.gz

81
INSTRUCCIONES.md Normal file
View File

@@ -0,0 +1,81 @@
# Instrucciones — Quickstart en 5 pasos
## 1. Clonar y limpiar
```bash
git clone https://gitea.fitlabs.dev/farentsen/fit-boilerplate-wox.git mi-cliente
cd mi-cliente
rm -rf .git && git init
git add . && git commit -m "chore: bootstrap from fit-boilerplate-wox"
```
## 2. Configurar entorno
```bash
cp .env.example .env
# Editar .env con:
# ORCHESTRATE_API_URL — URL del tenant WxO
# ORCHESTRATE_API_KEY — API key del tenant
# WATSONX_API_KEY — (opcional) si usás Plan A webhook
# PUBLIC_HOST — dominio público donde corre tu stack
```
## 3. Registrar el environment WxO
```bash
python3.12 -m venv .venv-wxo && source .venv-wxo/bin/activate
pip install --upgrade "ibm-watsonx-orchestrate==2.1.0"
orchestrate env add -n mi-tenant \
--iam-url https://iam.cloud.ibm.com/identity/token \
-u "$ORCHESTRATE_API_URL"
orchestrate env activate mi-tenant
```
Verificar modelo:
```bash
orchestrate models list | grep gpt-oss-120b # preferido para tool calling
```
Si no está, editá los YAMLs de los agentes generados y poné `meta-llama/llama-3-3-70b-instruct` (con la advertencia de `docs/known-issues.md` sobre el bug de tool-call-as-text).
## 4. Generar tu solución con la skill
Instalá el skill primero (una sola vez por máquina):
```bash
cp -r skill/fit-wxo-bootstrap ~/.claude/skills/
```
En una sesión de Claude Code, ejecutá:
> "Quiero arrancar una solución WxO para [tu caso de uso]"
La skill te pregunta:
1. ¿Qué cliente/caso de uso?
2. ¿Cuántos dominios identificás?
3. ¿Tu backend ya existe o lo hacemos como mock?
4. ¿Qué stack web (default HTMX o React)?
Luego dispara subagentes Claude en paralelo que escriben todos los YAMLs, tools, runbooks, evals y la app web.
## 5. Validar y desplegar
```bash
./scripts/check-adk-version.sh # avisa si hay versión nueva del ADK
python3 evals/lint_wxo_yaml.py # falla si rompiste alguna best practice
./scripts/deploy-wxo.sh # idempotente: conexiones + tools + KBs + agents + canal
./evals/smoke-test.sh # end-to-end smoke
```
**Paso manual obligatorio** después del deploy:
> WxO Console → Settings → Embed Security → **Off**
Sin esto, el embed widget en tu landing page se queda en "Cargando agente…" para siempre.
## Siguientes pasos
- `docs/architecture-patterns.md` — para decisiones de diseño
- `docs/wxo-best-practices.md` — las reglas que el linter enforcea
- `docs/known-issues.md` — si algo falla, mirá acá primero

112
README.md Normal file
View File

@@ -0,0 +1,112 @@
# fit-boilerplate-wox
**Boilerplate FactorIT** para construir soluciones agénticas sobre **IBM watsonx Orchestrate (ADK 2.x)** con una capa web encima.
Este repositorio es el punto de partida canónico para cualquier solución estilo `cotemar-poc-n1` o `dun-casos-prueba`: trae estructura, plantillas, scripts, documentación y subagentes Claude pre-configurados para que vos arranques con un cliente nuevo en **minutos, no días**.
---
## ¿Qué hay acá?
| Pieza | Para qué sirve |
|---|---|
| `wxo/` | Plantillas de agentes, conexiones, tools (Python/OpenAPI/MCP) y KBs |
| `web/_default_fastapi_htmx/` | Capa web por defecto (FastAPI + HTMX + Jinja). Swap por React/Next/etc. si el caso lo pide |
| `mocks/_example_mock/` | Ejemplo de mock externo (FastAPI con healthcheck correcto para Coolify/Traefik) |
| `scripts/` | Deploy idempotente, undeploy, evals, smoke test, scaffolding de specialists |
| `evals/` | Linter de buenas prácticas + scenarios + smoke tests + runner |
| `docs/` | Cheatsheet ADK 2.x, best practices, deployment guide, runbook, known issues |
| `.claude/agents/` | 7 subagentes Claude (architect, agent-author, tool-author, etc.) |
| `.gitea/workflows/ci.yml` | CI con lint + evals |
---
## Quick start
```bash
# 1. Clonar
git clone https://gitea.fitlabs.dev/farentsen/fit-boilerplate-wox.git mi-cliente
cd mi-cliente
rm -rf .git && git init
# 2. Conversar con Claude usando el skill fit-wxo-bootstrap
# (instalalo primero en ~/.claude/skills/ — ver más abajo)
# El skill te pregunta qué construís, decide la topología, y dispara
# subagentes Claude en paralelo para escribir agentes/tools/runbooks/evals.
# 3. Revisar lo que generó la skill, ajustar, y desplegar:
./scripts/check-adk-version.sh
./scripts/deploy-wxo.sh
```
---
## Skill `fit-wxo-bootstrap`
Bajo `skill/fit-wxo-bootstrap/` hay un skill bundle listo para instalar:
```bash
cp -r skill/fit-wxo-bootstrap ~/.claude/skills/
```
Reinicia Claude Code y el skill queda disponible. Invocalo con:
> "Quiero arrancar una solución WxO nueva para [cliente/caso de uso]"
La skill te lleva por las decisiones de arquitectura y delega la escritura a subagentes Claude que corren en paralelo.
---
## Filosofía del template
1. **WxO es el motor multi-agéntico fijo. Todo lo demás es swap-able.**
El stack web (FastAPI+HTMX, Next, Remix, lo que sea) puede cambiar según el caso. WxO no.
2. **Buenas prácticas se enforcean, no se sugieren.**
`evals/lint_wxo_yaml.py` falla el CI si:
- Algún agente tiene >10 tools
- El orchestrator declara tools de remediación (`reset_password`, `restart_service`, etc.)
- Un agente con `style: react` no tiene KB ni tools (sin propósito)
- Una tool no usa el decorator `@observable_tool`
- Falta el `_compat.py` inline
- Algún `docker-compose.yml` usa `wget --spider`
- Algún OpenAPI no tiene `description` per-operation
- Algún schema Pydantic de tool no tiene `@field_validator(mode="before")`
3. **Cada tool es observable por defecto.**
El decorator `@observable_tool` emite trazas estructuradas (inputs, outputs, latency, side effects, observed state). El web layer las consume y las muestra en una vista timeline. Las evals las usan para validar que el agente llamó las tools correctas en el orden correcto.
4. **Cuatro tipos de agente, no uno.** Procedural (con runbook KB), API-driven (sin KB, todos OpenAPI), MCP-driven (sin KB, herramientas vía MCP server), híbrido.
5. **Dos modos de tool wrapper.**
- **Mock externo** (estilo cotemar): Python `@tool` que llama HTTP a un mock separado
- **Backend integrado** (estilo dun): tu propio backend FastAPI expone un OpenAPI filtrado que WxO importa directamente. Más rápido para producción real.
---
## Documentación
| Doc | Propósito |
|---|---|
| [`docs/INDEX.md`](docs/INDEX.md) | Doc de docs — empezá acá |
| [`docs/adk-2x-cheatsheet.md`](docs/adk-2x-cheatsheet.md) | Comandos ADK 2.x + YAML schemas + gotchas |
| [`docs/wxo-best-practices.md`](docs/wxo-best-practices.md) | Las 30+ reglas codificadas, con el "por qué" |
| [`docs/architecture-patterns.md`](docs/architecture-patterns.md) | Árbol de decisión: ¿1 agente o N? ¿KB o no? ¿OpenAPI o Python? |
| [`docs/observability-pattern.md`](docs/observability-pattern.md) | El contrato `@observable_tool` + `write_audit` |
| [`docs/tool-authoring-guide.md`](docs/tool-authoring-guide.md) | Python vs OpenAPI vs MCP — cuándo usar cuál |
| [`docs/deployment-guide.md`](docs/deployment-guide.md) | Deploy genérico (parametrizable a cualquier tenant) |
| [`docs/DEPLOY_TO_NEW_WOX.md`](docs/DEPLOY_TO_NEW_WOX.md) | Cold start playbook (provision → secrets → backend → wox → smoke test) |
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) | Operación: URLs, monitoring, recovery, rotation, troubleshooting |
| [`docs/known-issues.md`](docs/known-issues.md) | Errores reales con su fix (recursion_limit, llama bug, Coolify Traefik, etc.) |
| [`docs/eval-strategy.md`](docs/eval-strategy.md) | 4 capas de eval: nativa, agente, runbook, web |
| [`INSTRUCCIONES.md`](INSTRUCCIONES.md) | Quickstart de 5 pasos |
---
## Origen
Destilado de dos PoCs reales de FIT México:
- [`cotemar-poc-n1`](https://gitea.fitlabs.dev/farentsen/cotemar-poc-mesa-n1) — Mesa N1 multi-agente (1 orquestador + 3 specialists) con runbooks + KBs + mocks
- [`dun-casos-prueba`](https://gitea.fitlabs.dev/farentsen/dun-casos-prueba) — QA Studio con OpenAPI integrado + audit_logs + webhook HMAC + meta-tool
Cada lección aprendida en esos dos repos vive acá como código, plantilla o validador de CI.

18
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,18 @@
# Overrides para dev local. Uso:
# docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
services:
web:
ports:
- "${WEB_PORT:-18000}:8000"
networks:
- default # bridge default para acceso localhost
# example_mock:
# ports:
# - "18001:8000"
networks:
default:
coolify:
external: false

93
docker-compose.yml Normal file
View File

@@ -0,0 +1,93 @@
# ─────────────────────────────────────────────────────────────────────────────
# Stack base del boilerplate. Coolify-ready.
#
# REGLAS DE ORO:
# - Healthchecks usan `wget -qO-` o `curl -sf`, NUNCA `wget --spider` (issue I-008)
# - SIN labels Traefik manuales — Coolify v4 los autogenera del panel FQDN (issue I-009)
# - Servicios sin internet → `internal: true` en su network (regla D3)
# - `image:` tag explícito para que Coolify haga el proxy-network attach
# ─────────────────────────────────────────────────────────────────────────────
services:
web:
build:
context: .
dockerfile: web/_default_fastapi_htmx/Dockerfile
image: 'wox-web:latest'
environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
WXO_INSTANCE_URL: ${WXO_INSTANCE_URL:-}
WXO_API_KEY: ${WXO_API_KEY:-}
WXO_AGENT_ID: ${WXO_AGENT_ID:-}
IBM_CLOUD_IAM_URL: ${IBM_CLOUD_IAM_URL:-https://iam.cloud.ibm.com/identity/token}
TRACE_SINK: ${TRACE_SINK:-sqlite}
TRACES_DB_PATH: /data/traces.db
volumes:
- web_data:/data
restart: unless-stopped
expose: ["8000"]
labels:
# Coolify Traefik — solo el toggle + network. NUNCA routers manuales.
- "traefik.enable=true"
- "traefik.docker.network=coolify"
networks:
- coolify
- internal
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
# ─── (Ejemplo) mock externo — descomentá y duplicá según necesites ─────────
# example_mock:
# build:
# context: .
# dockerfile: mocks/_example_mock/Dockerfile
# image: 'wox-example-mock:latest'
# environment:
# BASE_URL: http://example_mock:8000
# expose: ["8000"]
# restart: unless-stopped
# networks:
# - internal
# healthcheck:
# test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
# interval: 30s
# timeout: 5s
# retries: 3
# ─── (Ejemplo) backend propio FastAPI ──────────────────────────────────────
# backend:
# build:
# context: .
# dockerfile: backend/Dockerfile
# image: 'wox-backend:latest'
# environment:
# DATABASE_URL: ${DATABASE_URL:-sqlite:///data/app.db}
# WXO_BACKEND_TOKEN: ${WXO_BACKEND_TOKEN}
# WEBHOOK_SECRET: ${WEBHOOK_SECRET}
# volumes:
# - backend_data:/data
# expose: ["8000"]
# restart: unless-stopped
# networks:
# - coolify # si necesita ser accesible para WxO
# - internal
# healthcheck:
# test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
# interval: 30s
# timeout: 5s
# retries: 3
volumes:
web_data:
# backend_data:
networks:
coolify:
external: true
internal:
driver: bridge
internal: false # cambiar a true en producción si no necesitan acceso a internet

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).

65
evals/direct-backend-probe.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Diagnóstico: ¿el backend responde correctamente a llamadas estilo WxO?
# Aísla problemas de auth/conectividad de problemas de lógica del agente.
set -euo pipefail
PUBLIC_HOST="${PUBLIC_HOST:-mi-cliente.fitlabs.dev}"
BASE="https://${PUBLIC_HOST}"
TOKEN="${WXO_BACKEND_TOKEN:-}"
echo "──────────────────────────────────────────────────────"
echo " Direct backend probe — ${BASE}"
echo "──────────────────────────────────────────────────────"
# [1/3] Health
echo "[1/3] Healthcheck"
CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BASE}/health" || echo "000")
if [ "$CODE" = "200" ]; then
echo " ✓ 200 OK"
else
echo "$CODE — el backend no responde. ¿Container up? ¿Healthcheck OK?"
exit 1
fi
# [2/3] Direct tool call — con token, expecting controlled failure (case not found)
echo "[2/3] Direct tool call (con token, case inexistente)"
RESP=$(curl -s -X POST "${BASE}/api/v1/cases/run" \
-H "Content-Type: application/json" \
-H "X-Orchestrate-Token: ${TOKEN}" \
-d '{"case_id":"probe-nonexistent-uuid","target_id":1}')
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${BASE}/api/v1/cases/run" \
-H "Content-Type: application/json" \
-H "X-Orchestrate-Token: ${TOKEN}" \
-d '{"case_id":"probe-nonexistent-uuid","target_id":1}')
if [ "$CODE" = "200" ]; then
echo " ✓ 200 con error de lógica: $RESP"
echo " → Backend responde OK. Si el agente falla, es el agente o el LLM."
elif [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; then
echo "$CODE — auth fallida. Verificar:"
echo " - El token (WXO_BACKEND_TOKEN) es correcto"
echo " - La connection en WxO tiene la API key correcta"
echo " - El header es X-Orchestrate-Token (no Authorization)"
exit 1
elif [ "$CODE" = "502" ] || [ "$CODE" = "504" ]; then
echo "$CODE — gateway. Backend caído o muy lento."
exit 1
else
echo "$CODE inesperado: $RESP"
fi
# [3/3] Sin token, debe fallar 401
echo "[3/3] Sin token (debe rechazar)"
CODE_NOAUTH=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${BASE}/api/v1/cases/run" \
-H "Content-Type: application/json" \
-d '{"case_id":"probe","target_id":1}')
if [ "$CODE_NOAUTH" = "401" ] || [ "$CODE_NOAUTH" = "403" ]; then
echo " ✓ rechaza correctamente sin token ($CODE_NOAUTH)"
else
echo " ⚠ acepta sin token ($CODE_NOAUTH) — posible problema de seguridad"
fi
echo ""
echo "✓ Probe completed. Backend está sano desde el punto de vista de WxO."

72
evals/eval-agents.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Runner completo de evals. Corre las 4 capas en orden.
# Exit 0 si todo pasa, 1 si alguna falla.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
FAILED=0
# ─── Capa 0: Lint ───────────────────────────────────────────────────────────
echo "[lint] best practices"
if python3 evals/lint_wxo_yaml.py; then
echo " ✓ lint PASS"
else
echo " ✗ lint FAIL"
FAILED=$((FAILED + 1))
fi
echo ""
# ─── Capa 1: Native (ADK) ───────────────────────────────────────────────────
echo "[native] orchestrate agents test"
NATIVE_PASS=0; NATIVE_FAIL=0
for f in evals/scenarios/*.input.json; do
[ -f "$f" ] || continue
# Derivar nombre del agente (convención: scenario_X_agentName.input.json o leer del JSON)
agent=$(python3 -c "import json,sys; d=json.load(open('$f')); print(d.get('_agent',''))" 2>/dev/null)
if [ -z "$agent" ]; then
echo "$f sin '_agent' field, skip"
continue
fi
if orchestrate agents test "$agent" --input-file "$f" > /tmp/eval_native_$$.log 2>&1; then
NATIVE_PASS=$((NATIVE_PASS + 1))
else
NATIVE_FAIL=$((NATIVE_FAIL + 1))
echo "$f failed (see /tmp/eval_native_$$.log)"
fi
done
echo " native: $NATIVE_PASS pass / $NATIVE_FAIL fail"
[ "$NATIVE_FAIL" -gt 0 ] && FAILED=$((FAILED + 1))
echo ""
# ─── Capa 2 + 3: Behavior + Final state (runner.py) ─────────────────────────
echo "[behavior+final] custom runner"
if python3 evals/runner.py; then
echo " ✓ runner PASS"
else
echo " ✗ runner FAIL"
FAILED=$((FAILED + 1))
fi
echo ""
# ─── Capa 4: Smoke test ─────────────────────────────────────────────────────
echo "[smoke] end-to-end"
if ./evals/smoke-test.sh > /tmp/eval_smoke_$$.log 2>&1; then
echo " ✓ smoke PASS"
else
echo " ✗ smoke FAIL (see /tmp/eval_smoke_$$.log)"
FAILED=$((FAILED + 1))
fi
echo ""
# ─── Resumen ────────────────────────────────────────────────────────────────
echo "════════════════════════════════════════"
if [ "$FAILED" -eq 0 ]; then
echo " ✓ ALL EVALS PASS"
exit 0
else
echo "$FAILED layer(s) FAILED"
exit 1
fi

196
evals/lint_wxo_yaml.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Linter de buenas prácticas WxO.
Falla con exit code 1 si encuentra violaciones. Usado en CI antes de
deploy. Las reglas están en docs/wxo-best-practices.md.
Reglas enforced:
- A1: máx 10 tools por agente
- A3: orchestrator no tiene tools de remediación
- A6: agente con style=react debe tener KB o tools
- T1: toda función @tool debe estar bajo @observable_tool
- T3: tools.py debe inlinear el _compat shim
- D1: docker-compose no usa `wget --spider`
- I-005: OpenAPI tiene description per-operation
"""
from __future__ import annotations
import re
import sys
import textwrap
from pathlib import Path
try:
import yaml
except ImportError:
print("✗ pyyaml required: pip install pyyaml", file=sys.stderr)
sys.exit(2)
ROOT = Path(__file__).resolve().parent.parent
# Tools típicas de remediación que un orchestrator NO debe tener.
REMEDIATION_TOOLS = {
"reset_password", "restart_service", "create_user", "grant_access",
"assign_groups", "rotate_logs", "delete_user", "revoke_access",
"kill_process", "redeploy", "rollback",
}
errors: list[str] = []
warnings: list[str] = []
def err(msg: str) -> None:
errors.append(msg)
print(f"{msg}")
def warn(msg: str) -> None:
warnings.append(msg)
print(f"{msg}")
def ok(msg: str) -> None:
print(f"{msg}")
# ─── Agents ──────────────────────────────────────────────────────────────────
def lint_agents() -> None:
agent_dir = ROOT / "wxo" / "agents"
for f in sorted(agent_dir.glob("*.agent.yaml")):
if f.name.startswith("_"):
continue
try:
data = yaml.safe_load(f.read_text())
except yaml.YAMLError as e:
err(f"{f}: invalid YAML — {e}")
continue
name = data.get("name", f.stem)
tools = data.get("tools") or []
kb = data.get("knowledge_base") or []
style = data.get("style", "")
collaborators = data.get("collaborators") or []
instructions = data.get("instructions") or ""
# A1: máx 10 tools
if len(tools) > 10:
err(f"{f.name}: agente '{name}' tiene {len(tools)} tools (regla A1: máx 10)")
# A3: orchestrator sin tools de remediación
is_orchestrator = bool(collaborators) or "orchestrator" in name or "n1" in name
if is_orchestrator:
bad = [t for t in tools if t in REMEDIATION_TOOLS]
if bad:
err(f"{f.name}: orchestrator '{name}' declara tools de remediación: {bad} (regla A3)")
# A6: style=react sin KB ni tools = agente sin propósito
if style == "react" and not tools and not kb:
err(f"{f.name}: agente '{name}' tiene style=react pero ni tools ni KB (regla A6)")
# P1: LLM pinneado a gpt-oss-120b (warning si no)
llm = data.get("llm", "")
if "gpt-oss-120b" not in llm and "llama" in llm:
warn(f"{f.name}: agente '{name}' usa llama. Issue I-002 — preferible gpt-oss-120b.")
# P2: REGLA #0 presente en instructions
if "REGLA #0" not in instructions and "NUNCA escribas la tool call" not in instructions:
warn(f"{f.name}: agente '{name}' no menciona REGLA #0 en instructions (regla P2)")
# ok message
if len(errors) == 0:
ok(f"agent {name} (tools={len(tools)}, kb={len(kb)}, style={style})")
# ─── Tools (Python) ──────────────────────────────────────────────────────────
def lint_python_tools() -> None:
tools_dir = ROOT / "wxo" / "tools" / "python"
for f in sorted(tools_dir.glob("*.py")):
if f.name.startswith("_"):
continue # skip helpers
src = f.read_text()
# T3: compat shim presente
if "inline compat shim" not in src and "from ibm_watsonx_orchestrate.agent_builder.tools import tool" not in src:
err(f"{f.name}: falta compat shim inline (regla T3, issue I-010)")
# T1: @tool sin @observable_tool
# Buscar @tool( no precedido por observable_tool
for line_no, line in enumerate(src.splitlines(), start=1):
line_stripped = line.strip()
if re.match(r"^@tool\b", line_stripped):
# ¿el archivo lo usa solo como import alias _adk_tool? OK.
# Si no, es violación.
err(f"{f.name}:{line_no}: usa @tool directo, debe ser @observable_tool (regla T1)")
ok(f"tools {f.name}")
# ─── OpenAPI ─────────────────────────────────────────────────────────────────
def lint_openapi() -> None:
openapi_dir = ROOT / "wxo" / "tools" / "openapi"
for f in sorted(list(openapi_dir.glob("*.yaml")) + list(openapi_dir.glob("*.json"))):
if f.name.startswith("_"):
continue
try:
data = yaml.safe_load(f.read_text())
except yaml.YAMLError as e:
err(f"{f.name}: invalid YAML/JSON — {e}")
continue
paths = data.get("paths", {})
for path, item in paths.items():
for method, op in item.items():
if not isinstance(op, dict) or "responses" not in op:
continue
# I-005: description per-op
if not op.get("description"):
err(f"{f.name}: {method.upper()} {path} sin description (issue I-005)")
# I-006: security per-op
if op.get("security") is None and data.get("security") is not None:
warn(f"{f.name}: {method.upper()} {path} no declara security per-op (issue I-006)")
ok(f"openapi {f.name}")
# ─── docker-compose ──────────────────────────────────────────────────────────
def lint_compose() -> None:
for f in [ROOT / "docker-compose.yml", ROOT / "docker-compose.local.yml"]:
if not f.exists():
continue
src = f.read_text()
# D1: no `wget --spider`
if "wget --spider" in src or "wget -S --spider" in src:
err(f"{f.name}: usa `wget --spider`, cambiá por `wget -qO-` o `curl -sf` (issue I-008)")
# I-009: labels Traefik manuales
if re.search(r"traefik\.http\.routers\.", src):
warn(f"{f.name}: declara labels Traefik manuales — Coolify v4 los autogenera (issue I-009)")
ok(f"compose {f.name}")
# ─── Main ────────────────────────────────────────────────────────────────────
def main() -> int:
print("─── Linting agents ───")
lint_agents()
print("\n─── Linting Python tools ───")
lint_python_tools()
print("\n─── Linting OpenAPI ───")
lint_openapi()
print("\n─── Linting docker-compose ───")
lint_compose()
print("\n" + "" * 60)
if errors:
print(f"{len(errors)} ERROR(es), {len(warnings)} warning(s)")
return 1
print(f"✓ Linting OK ({len(warnings)} warning(s))")
return 0
if __name__ == "__main__":
sys.exit(main())

141
evals/runner.py Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Eval runner — Capas 2 (behavior) y 3 (final state).
Lee `evals/scenarios/*.yaml`, ejecuta cada scenario contra el agente
correspondiente, y verifica las trazas observables + estado final.
Formato del scenario YAML (ver docs/eval-strategy.md):
name: reset_password_happy_path
agent: ad_specialist_cotemar
input: "Necesito resetear..."
expect:
agent_response_contains: ["TKT-"]
tool_calls_in_order:
- tool: lookup_user
- tool: reset_password
- tool: create_ticket
no_tool_calls:
- escalate_to_n2
final_state:
# ... asserts contra DB / API
"""
from __future__ import annotations
import json
import os
import sys
import sqlite3
import time
from pathlib import Path
try:
import yaml
except ImportError:
print("✗ pyyaml required: pip install pyyaml", file=sys.stderr)
sys.exit(2)
ROOT = Path(__file__).resolve().parent.parent
SCENARIOS_DIR = ROOT / "evals" / "scenarios"
TRACES_DB = os.environ.get("TRACES_DB_PATH", "/tmp/traces.db")
def run_scenario(spec: dict) -> tuple[bool, list[str]]:
"""Returns (passed, errors)."""
errors: list[str] = []
name = spec.get("name", "?")
agent = spec.get("agent")
input_text = spec.get("input")
expect = spec.get("expect", {})
if not agent or not input_text:
return False, ["scenario sin 'agent' o 'input'"]
started = int(time.time() * 1000)
# 1) Mandar el input al agente (usando orchestrate CLI)
fixture = {"messages": [{"role": "user", "content": input_text}]}
fixture_path = Path("/tmp") / f"_eval_{name}.json"
fixture_path.write_text(json.dumps(fixture))
import subprocess
proc = subprocess.run(
["orchestrate", "agents", "test", agent, "--input-file", str(fixture_path)],
capture_output=True, text=True, timeout=120,
)
output = proc.stdout + "\n" + proc.stderr
# 2) Verificar response (Capa 2)
for needle in expect.get("agent_response_contains", []):
if needle not in output:
errors.append(f"agent_response_contains: '{needle}' not in output")
# 3) Verificar tool calls vía trazas (Capa 2)
traces = _read_traces_since(started)
tool_names = [t["tool"] for t in traces]
expected_order = [t.get("tool") for t in expect.get("tool_calls_in_order", [])]
if expected_order:
if not _is_subsequence(expected_order, tool_names):
errors.append(f"tool_calls_in_order: expected {expected_order}, got {tool_names}")
for bad_tool in expect.get("no_tool_calls", []):
if bad_tool in tool_names:
errors.append(f"no_tool_calls: {bad_tool} should NOT have been called")
# 4) Final state (Capa 3) — placeholder, depende del backend
# TODO en v2: parametrizable según backend del proyecto
final_state = spec.get("final_state")
if final_state:
# Stub — implementación específica del proyecto
pass
return (len(errors) == 0), errors
def _is_subsequence(needle: list[str], haystack: list[str]) -> bool:
"""¿`needle` es subsequence de `haystack`? (order preserved, gaps OK)"""
it = iter(haystack)
return all(x in it for x in needle)
def _read_traces_since(ts_ms: int) -> list[dict]:
if not Path(TRACES_DB).exists():
return []
conn = sqlite3.connect(TRACES_DB)
rows = conn.execute(
"SELECT payload FROM traces WHERE started_at >= datetime(?, 'unixepoch')",
(ts_ms / 1000,)
).fetchall()
conn.close()
return [json.loads(r[0]) for r in rows]
def main() -> int:
scenarios = sorted(SCENARIOS_DIR.glob("*.yaml"))
if not scenarios:
print("(no scenarios in evals/scenarios/*.yaml — skipping)")
return 0
pass_count = 0
fail_count = 0
for f in scenarios:
if f.name.startswith("_"):
continue
spec = yaml.safe_load(f.read_text())
name = spec.get("name", f.stem)
ok, errors = run_scenario(spec)
if ok:
print(f"{name}")
pass_count += 1
else:
print(f"{name}")
for e in errors:
print(f" - {e}")
fail_count += 1
print(f"\n Total: {pass_count} pass / {fail_count} fail")
return 0 if fail_count == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,10 @@
{
"_agent": "REPLACE_agent_name",
"_description": "REPLACE — qué prueba este scenario",
"messages": [
{
"role": "user",
"content": "REPLACE — qué le dice el usuario al agente"
}
]
}

View File

@@ -0,0 +1,48 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — Eval scenario
#
# Cada archivo en este directorio es un test case. El runner los procesa todos.
# Asegurate de tener también el archivo input JSON correspondiente:
# evals/scenarios/<name>.input.json
#
# Documentación: docs/eval-strategy.md
# ─────────────────────────────────────────────────────────────────────────────
# Nombre único del scenario (para reportes).
name: REPLACE_scenario_name
# Agente al que mandar el input.
agent: REPLACE_agent_name
# Texto del usuario.
input: "REPLACE — qué le dice el usuario al agente"
# Capa 2 — Behavior eval
expect:
# El response final del agente debe contener estos strings (case sensitive)
agent_response_contains:
- "REPLACE_token_1"
# Las tools deben llamarse en este orden (puede haber tools intermedias)
tool_calls_in_order:
- tool: REPLACE_tool_1
# inputs: opcional, asserts sobre los inputs
- tool: REPLACE_tool_2
# Estas tools NO deben aparecer en las trazas
no_tool_calls:
- REPLACE_tool_que_no_deberia_llamarse
# Capa 3 — Final state eval (opcional)
final_state:
# query: "SELECT * FROM tickets WHERE created_at > $START_TIME"
# expect_count: 1
# expect_fields:
# status: "RESOLVED"
# extra.runbook: "01"
# Capa 4 — UI eval (opcional)
# ui:
# visit: "/insights"
# expect_text:
# - "Resueltos: 1"

55
evals/smoke-test.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Smoke test mínimo: end-to-end del flujo principal.
#
# Sirve como:
# - Validación post-deploy
# - Health check nightly (cron en CI)
# - Reproducible bug report
#
# Pasos:
# 1. Healthcheck del backend público
# 2. Llamar al endpoint principal con un input válido
# 3. Pollear hasta done
# 4. Assert sobre el estado final
set -euo pipefail
PUBLIC_HOST="${PUBLIC_HOST:-mi-cliente.fitlabs.dev}"
BASE="https://${PUBLIC_HOST}"
echo "→ [1/4] Healthcheck"
curl -sf "${BASE}/health" > /dev/null && echo " ✓ 200 OK" || { echo " ✗ FAIL"; exit 1; }
echo "→ [2/4] Create case (smoke input)"
# REPLACE: ajustar al endpoint y payload de tu solución
RESULT=$(curl -sf -X POST "${BASE}/api/v1/cases/run" \
-H "Content-Type: application/json" \
-H "X-Orchestrate-Token: ${WXO_BACKEND_TOKEN:-smoke-test-token}" \
-d '{"case_id":"smoke-001","target_id":1}' \
2>&1) || { echo " ✗ FAIL: $RESULT"; exit 1; }
echo " ✓ accepted: $RESULT"
echo "→ [3/4] Poll until done (max 60s)"
for i in $(seq 1 60); do
STATUS=$(curl -sf "${BASE}/api/v1/cases/smoke-001" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null || echo "?")
[ "$STATUS" = "done" ] && { echo " ✓ done after ${i}s"; break; }
[ "$STATUS" = "failed" ] && { echo " ✗ failed"; exit 1; }
sleep 1
done
[ "$STATUS" = "done" ] || { echo " ✗ timeout (last status: $STATUS)"; exit 1; }
echo "→ [4/4] Assert final state"
# REPLACE: ajustar al assert de tu solución
RESULT=$(curl -sf "${BASE}/api/v1/cases/smoke-001")
echo "$RESULT" | python3 -c "
import sys, json
data = json.load(sys.stdin)
assert data['status'] == 'done', f'status={data[\"status\"]}'
assert data.get('package_url'), 'no package_url'
print(' ✓ assertions OK')
"
echo ""
echo "════════════════════════════════════════"
echo " ✓ Smoke test passed"
echo "════════════════════════════════════════"

View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY mocks/_example_mock/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY mocks/_example_mock/app/ ./app/
EXPOSE 8000
# Healthcheck con `wget -qO-` (issue I-008)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

View File

@@ -0,0 +1,135 @@
"""Mock de ejemplo. Replicá esta estructura para tu mock real.
Patrones aplicados:
- /health endpoint (Coolify/Traefik)
- /admin/reset endpoint (para rehearsals)
- Pydantic con coerción de tipos (issue I-003)
- Custom auth header X-Orchestrate-Token (regla C2)
- Logs estructurados como JSON
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
from typing import Optional
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, field_validator
# ─── Helpers de coerción (issue I-003) ──────────────────────────────────────
def _coerce_int(v):
if isinstance(v, str) and v.strip().lstrip("-").isdigit():
return int(v)
return v
def _coerce_list(v):
if isinstance(v, str):
try:
return json.loads(v)
except Exception:
return [v]
return v
# ─── Seed data ──────────────────────────────────────────────────────────────
SEED = {
"users": [
{"username": "juan.perez", "email": "juan.perez@example.com", "active": True, "department": "Finanzas"},
{"username": "maria.lopez", "email": "maria.lopez@example.com", "active": True, "department": "Operaciones"},
{"username": "ana.torres", "email": "ana.torres@example.com", "active": False, "department": "RRHH"},
],
}
state: dict = {}
def _reset():
global state
state = {"users": [u.copy() for u in SEED["users"]]}
_reset()
# ─── Models ─────────────────────────────────────────────────────────────────
class LookupInput(BaseModel):
username: str
class ExecuteInput(BaseModel):
target_id: int
options: list[str] = []
notify: bool = True
_coerce_id = field_validator("target_id", mode="before")(_coerce_int)
_coerce_opts = field_validator("options", mode="before")(_coerce_list)
# ─── App ────────────────────────────────────────────────────────────────────
app = FastAPI(title="Example Mock")
def _log(event: str, **kwargs):
"""Structured log for Coolify aggregation."""
payload = {"ts": datetime.now(timezone.utc).isoformat(), "event": event, **kwargs}
print(json.dumps(payload), file=sys.stdout, flush=True)
def _check_token(token: Optional[str]):
expected = os.environ.get("MOCK_API_TOKEN")
if expected and token != expected:
raise HTTPException(status_code=401, detail="bad token")
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/admin/reset")
def admin_reset():
"""Para rehearsals: vuelve al seed."""
_reset()
_log("admin.reset")
return {"reset": True}
@app.post(
"/users/lookup",
description="Busca un usuario por username. Devuelve datos del usuario."
)
def lookup_user(
payload: LookupInput,
x_orchestrate_token: str | None = Header(default=None),
):
"""Lookup de usuario — para que el agente decida si actuar."""
_check_token(x_orchestrate_token)
user = next((u for u in state["users"] if u["username"] == payload.username), None)
if not user:
raise HTTPException(status_code=404, detail="user not found")
_log("tool.lookup_user", username=payload.username, found=True)
return user
@app.post(
"/execute",
description="Ejecuta una acción sobre un target_id. Devuelve resultado."
)
def execute_action(
payload: ExecuteInput,
x_orchestrate_token: str | None = Header(default=None),
):
_check_token(x_orchestrate_token)
_log("tool.execute", target_id=payload.target_id, options=payload.options)
return {
"ok": True,
"target_id": payload.target_id,
"completed_at": datetime.now(timezone.utc).isoformat(),
}

View File

@@ -0,0 +1,3 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
pydantic>=2.5.0

43
scripts/check-adk-version.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Compara la versión pineada (2.1.0) con la última disponible en PyPI.
# Warnea si hay nueva pero NO actualiza automáticamente — Felipe debe correr
# las evals antes de bumpear.
set -euo pipefail
PINNED="2.1.0"
PACKAGE="ibm-watsonx-orchestrate"
echo "→ Checking ADK version..."
echo " Pinned: $PINNED"
INSTALLED=$(pip show "$PACKAGE" 2>/dev/null | grep -i '^version:' | awk '{print $2}' || echo "not-installed")
echo " Installed: $INSTALLED"
LATEST=$(curl -s "https://pypi.org/pypi/$PACKAGE/json" | python3 -c "import sys, json; print(json.load(sys.stdin)['info']['version'])" 2>/dev/null || echo "unknown")
echo " Latest on PyPI: $LATEST"
if [ "$INSTALLED" != "$PINNED" ]; then
echo ""
echo "⚠ Installed ($INSTALLED) does NOT match pinned ($PINNED)."
echo " Run: pip install '${PACKAGE}==${PINNED}'"
fi
if [ "$LATEST" != "$PINNED" ] && [ "$LATEST" != "unknown" ]; then
echo ""
echo " Nueva versión disponible en PyPI: $LATEST (pin: $PINNED)"
echo " ANTES de actualizar:"
echo " 1. Crear branch chore/adk-$LATEST"
echo " 2. pip install '${PACKAGE}==${LATEST}'"
echo " 3. ./evals/eval-agents.sh ← TODAS deben pasar"
echo " 4. Si pasan, bumpear el pin en wxo/tools/python/requirements.txt y este script"
fi
echo ""
echo "→ Verificando modelo preferido (gpt-oss-120b)..."
if orchestrate models list 2>/dev/null | grep -q "gpt-oss-120b"; then
echo " ✓ gpt-oss-120b disponible"
else
echo " ⚠ gpt-oss-120b NO encontrado en este tenant"
echo " Fallback documentado: meta-llama/llama-3-3-70b-instruct (con caveat I-002)"
fi

176
scripts/deploy-wxo.sh Executable file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Deploy completo de la solución WxO. Idempotente, re-runnable.
#
# Hace en orden:
# 1. Connections (draft Y live — issue I-004)
# 2. Tools (Python + OpenAPI según haya)
# 3. KBs
# 4. Agents (specialists primero — issue A4)
# 5. Deploy a live de todos los agents
# 6. Channel webchat (si hay landing)
#
# Variables de control:
# PUBLIC_HOST — host público donde corre tu stack
# WXO_ENV_NAME — env del ADK a usar (default: el activo)
# SKIP_TOOLS — saltar tools
# SKIP_KB — saltar KBs
# SKIP_CHANNEL — saltar canal webchat
# AGENTS_GLOB — patrón para detectar agents (default: wxo/agents/*.agent.yaml)
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# ─── Config (con defaults razonables) ───────────────────────────────────────
PUBLIC_HOST="${PUBLIC_HOST:-mi-cliente.fitlabs.dev}"
PUBLIC_BASE="https://${PUBLIC_HOST}"
SKIP_TOOLS="${SKIP_TOOLS:-false}"
SKIP_KB="${SKIP_KB:-false}"
SKIP_CHANNEL="${SKIP_CHANNEL:-false}"
AGENTS_GLOB="${AGENTS_GLOB:-wxo/agents/*.agent.yaml}"
echo "════════════════════════════════════════════════════════════════"
echo " WxO deploy — $PUBLIC_BASE"
echo " env activo : $(orchestrate env list 2>/dev/null | grep ACTIVE || echo '???')"
echo "════════════════════════════════════════════════════════════════"
# ─── 1. CONNECTIONS ─────────────────────────────────────────────────────────
# Auto-detect: cada wxo/connections/*.yaml define un app_id.
# Mapping del BASE_URL se hace por convención:
# nombre del archivo (sin extensión) → /<basename>
# Override por archivo en `scripts/deploy-wxo.conf`.
echo "→ [1/6] Connections"
for f in wxo/connections/*.yaml; do
[ -f "$f" ] || continue
app_id=$(grep -E '^name:' "$f" | head -1 | awk '{print $2}')
[ -z "$app_id" ] && { echo " skip $f (no name)"; continue; }
# Convención: BASE_URL = $PUBLIC_BASE/<basename_sin_connection>
basename="${f##*/}"
basename="${basename%_connection.yaml}"
basename="${basename%.yaml}"
base_url="${PUBLIC_BASE}/${basename}"
echo "$app_id$base_url"
orchestrate connections add -a "$app_id" 2>/dev/null || true
for ENV in draft live; do
# Detectar kind (key_value | api_key | mcp) del YAML
kind=$(grep -E '^auth_type:' "$f" | awk '{print $2}')
[ -z "$kind" ] && kind="key_value"
orchestrate connections configure -a "$app_id" --env "$ENV" --type team --kind "$kind" 2>/dev/null || true
orchestrate connections set-credentials -a "$app_id" --env "$ENV" -e "BASE_URL=${base_url}" 2>/dev/null || true
done
done
# ─── 2. TOOLS PYTHON ────────────────────────────────────────────────────────
if [ "$SKIP_TOOLS" != "true" ]; then
echo "→ [2/6] Tools (Python)"
if [ -f wxo/tools/python/requirements.txt ]; then
for f in wxo/tools/python/*.py; do
[ -f "$f" ] || continue
# Saltar los archivos de soporte (empiezan con _)
base="${f##*/}"
case "$base" in _*) continue ;; esac
# Derivar app_id del nombre (convención: <app>_tools.py → app_demo o app)
app_id="${base%_tools.py}_demo"
echo "$f → app-id $app_id"
orchestrate tools import -k python \
-f "$f" \
-r wxo/tools/python/requirements.txt \
--app-id "$app_id" || echo " (already imported, skipping)"
done
fi
# ─── 3. TOOLS OPENAPI ─────────────────────────────────────────────────────
echo "→ [3/6] Tools (OpenAPI)"
for f in wxo/tools/openapi/*.yaml wxo/tools/openapi/*.json; do
[ -f "$f" ] || continue
base="${f##*/}"
case "$base" in _*) continue ;; esac
# Derivar app_id del nombre
app_id="${base%.yaml}"
app_id="${app_id%.json}"
echo "$f → app-id $app_id"
orchestrate tools import -k openapi -f "$f" --app-id "$app_id" || echo " (already imported, skipping)"
done
fi
# ─── 4. KNOWLEDGE BASES ─────────────────────────────────────────────────────
if [ "$SKIP_KB" != "true" ]; then
echo "→ [4/6] Knowledge bases"
for f in wxo/knowledge_base/*.kb.yaml; do
[ -f "$f" ] || continue
base="${f##*/}"
case "$base" in _*) continue ;; esac
echo "$f"
orchestrate knowledge-bases import -f "$f" || echo " (already imported, skipping)"
done
fi
# ─── 5. AGENTS ──────────────────────────────────────────────────────────────
# IMPORTANTE: specialists antes que orchestrators (issue A4).
# Convención: si el archivo se llama *orchestrator* o *n1_* va último.
echo "→ [5/6] Agents"
SPECIALIST_FILES=()
ORCHESTRATOR_FILES=()
for f in $AGENTS_GLOB; do
[ -f "$f" ] || continue
base="${f##*/}"
case "$base" in _*) continue ;; esac
case "$base" in
*orchestrator*|*n1_*) ORCHESTRATOR_FILES+=("$f") ;;
*) SPECIALIST_FILES+=("$f") ;;
esac
done
for f in "${SPECIALIST_FILES[@]}"; do
echo " • specialist $f"
orchestrate agents import -f "$f"
done
for f in "${ORCHESTRATOR_FILES[@]}"; do
echo " • orchestrator $f"
orchestrate agents import -f "$f"
done
# Deploy a live de todos
echo "→ [5b/6] Deploying agents to LIVE"
for f in "${SPECIALIST_FILES[@]}" "${ORCHESTRATOR_FILES[@]}"; do
name=$(grep -E '^name:' "$f" | head -1 | awk '{print $2}')
[ -z "$name" ] && continue
echo "$name → live"
orchestrate agents deploy --name "$name" --env live || echo " (deploy failed, see logs)"
done
# ─── 6. CHANNEL WEBCHAT ─────────────────────────────────────────────────────
if [ "$SKIP_CHANNEL" != "true" ] && [ ${#ORCHESTRATOR_FILES[@]} -gt 0 ]; then
echo "→ [6/6] Channel webchat (bound to orchestrator)"
for f in "${ORCHESTRATOR_FILES[@]}"; do
name=$(grep -E '^name:' "$f" | head -1 | awk '{print $2}')
[ -z "$name" ] && continue
orchestrate channels create webchat \
--agent "$name" \
--name "${name} - Web" 2>/dev/null || echo " (channel for $name already exists)"
done
fi
# ─── DONE ───────────────────────────────────────────────────────────────────
echo "════════════════════════════════════════════════════════════════"
echo " ✓ Deploy completado."
echo ""
echo " Próximos pasos manuales:"
echo " 1. orchestrate agents list → capturar agentId y agentEnvironmentId del orchestrator"
echo " 2. Pegarlos en tu landing HTML y env vars"
echo " 3. WxO Console → Settings → Embed Security → OFF (sin esto, embed falla)"
echo " 4. ./evals/smoke-test.sh → validar end-to-end"
echo "════════════════════════════════════════════════════════════════"

63
scripts/new-specialist.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Scaffolding de un nuevo specialist: copia los templates y rellena REPLACE_*.
# Uso:
# ./scripts/new-specialist.sh <name> <domain>
# Ejemplo:
# ./scripts/new-specialist.sh ad_specialist_acme identidad
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: $0 <specialist-name> <domain>"
echo "Example: $0 ad_specialist_acme identidad"
exit 1
fi
NAME="$1"
DOMAIN="$2"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
AGENT_FILE="$ROOT/wxo/agents/${NAME}.agent.yaml"
TOOLS_FILE="$ROOT/wxo/tools/python/${DOMAIN}_tools.py"
KB_FILE="$ROOT/wxo/knowledge_base/kb_${DOMAIN}.kb.yaml"
CONN_FILE="$ROOT/wxo/connections/${DOMAIN}_connection.yaml"
# Copiar agent template y reemplazar
if [ ! -f "$AGENT_FILE" ]; then
cp "$ROOT/wxo/agents/_template-specialist.agent.yaml" "$AGENT_FILE"
sed -i.bak "s/REPLACE_specialist_name/${NAME}/g; s/REPLACE_DOMINIO/${DOMAIN}/g; s/REPLACE_domain/${DOMAIN}/g; s/REPLACE_kb_name/kb_${DOMAIN}/g" "$AGENT_FILE"
rm "${AGENT_FILE}.bak"
echo "✓ Created: $AGENT_FILE"
else
echo "⚠ Already exists: $AGENT_FILE — skipping"
fi
if [ ! -f "$TOOLS_FILE" ]; then
cp "$ROOT/wxo/tools/python/_template_tools.py" "$TOOLS_FILE"
sed -i.bak "s/REPLACE_domain/${DOMAIN}/g" "$TOOLS_FILE"
rm "${TOOLS_FILE}.bak"
echo "✓ Created: $TOOLS_FILE"
fi
if [ ! -f "$KB_FILE" ]; then
cp "$ROOT/wxo/knowledge_base/_template.kb.yaml" "$KB_FILE"
sed -i.bak "s/REPLACE_kb_name/kb_${DOMAIN}/g; s/REPLACE_DOMINIO/${DOMAIN}/g" "$KB_FILE"
rm "${KB_FILE}.bak"
echo "✓ Created: $KB_FILE (acordate de agregar los runbooks)"
fi
if [ ! -f "$CONN_FILE" ]; then
cp "$ROOT/wxo/connections/_template-keyvalue.yaml" "$CONN_FILE"
sed -i.bak "s/REPLACE_app_name/${DOMAIN}_demo/g; s/REPLACE_SISTEMA/${DOMAIN}/g" "$CONN_FILE"
rm "${CONN_FILE}.bak"
echo "✓ Created: $CONN_FILE"
fi
echo ""
echo "Siguientes pasos:"
echo " 1. Editar $AGENT_FILE — rellenar REPLACE_* restantes"
echo " 2. Editar $TOOLS_FILE — implementar las tools de ${DOMAIN}"
echo " 3. Crear runbook bajo wxo/knowledge_base/runbooks/ y referenciarlo en $KB_FILE"
echo " 4. Si ${DOMAIN} no es un mock, considerá usar _template-apikey-header.yaml en lugar de keyvalue"
echo " 5. Si ${NAME} debe ser collaborator de un orchestrator, agregalo allí"
echo " 6. ./scripts/deploy-wxo.sh"

56
scripts/reset-wxo.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Borra TODO lo desplegado en el tenant WxO activo: agents, KBs, tools, connections, channels.
# Para volver a empezar from scratch.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "════════════════════════════════════════════════════════════════"
echo " WxO reset — env: $(orchestrate env list 2>/dev/null | grep ACTIVE || echo '???')"
echo " ESTO BORRA TODO. Esperando 5 segundos para que canceles con Ctrl+C..."
sleep 5
# Channels
echo "→ Deleting channels"
orchestrate channels list 2>/dev/null | tail -n +2 | awk '{print $1}' | while read -r ch; do
[ -z "$ch" ] && continue
orchestrate channels delete --id "$ch" 2>/dev/null || true
done
# Agents
echo "→ Removing agents"
for f in wxo/agents/*.agent.yaml; do
[ -f "$f" ] || continue
name=$(grep -E '^name:' "$f" | head -1 | awk '{print $2}')
[ -z "$name" ] && continue
orchestrate agents remove --name "$name" 2>/dev/null || true
done
# KBs
echo "→ Removing knowledge bases"
for f in wxo/knowledge_base/*.kb.yaml; do
[ -f "$f" ] || continue
name=$(grep -E '^name:' "$f" | head -1 | awk '{print $2}')
[ -z "$name" ] && continue
orchestrate knowledge-bases remove --name "$name" 2>/dev/null || true
done
# Tools
echo "→ Removing tools (Python + OpenAPI)"
orchestrate tools list 2>/dev/null | tail -n +2 | awk '{print $1}' | while read -r t; do
[ -z "$t" ] && continue
orchestrate tools remove --name "$t" 2>/dev/null || true
done
# Connections
echo "→ Removing connections"
for f in wxo/connections/*.yaml; do
[ -f "$f" ] || continue
name=$(grep -E '^name:' "$f" | head -1 | awk '{print $2}')
[ -z "$name" ] && continue
orchestrate connections remove -a "$name" 2>/dev/null || true
done
echo "✓ Reset completo. Podés re-correr ./scripts/deploy-wxo.sh"

33
scripts/undeploy-wxo.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Undeploy quirúrgico: borra solo lo que está en este repo, no toda la tenant.
# Más conservador que reset-wxo.sh.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "→ Removing agents declared in this repo"
for f in wxo/agents/*.agent.yaml; do
[ -f "$f" ] || continue
base="${f##*/}"
case "$base" in _*) continue ;; esac
name=$(grep -E '^name:' "$f" | head -1 | awk '{print $2}')
[ -z "$name" ] && continue
echo "$name"
orchestrate agents remove --name "$name" 2>/dev/null || true
done
echo "→ Removing KBs declared in this repo"
for f in wxo/knowledge_base/*.kb.yaml; do
[ -f "$f" ] || continue
base="${f##*/}"
case "$base" in _*) continue ;; esac
name=$(grep -E '^name:' "$f" | head -1 | awk '{print $2}')
[ -z "$name" ] && continue
echo "$name"
orchestrate knowledge-bases remove --name "$name" 2>/dev/null || true
done
echo "→ Skipping tools and connections (use reset-wxo.sh for full clean)"
echo "✓ Done"

View File

@@ -0,0 +1,168 @@
---
name: fit-wxo-bootstrap
description: Genera una solución completa basada en fit-boilerplate-wox para un cliente o caso de uso nuevo. Conversa para entender el caso, decide la topología WxO (cuántos agentes, qué dominios, qué tipo de tools, qué stack web), y dispara subagentes Claude en paralelo que escriben agentes, tools, runbooks, mocks, evals y la capa web. Triggers — usá este skill cuando el usuario diga "quiero arrancar una solución WxO nueva", "armá un proyecto desde el boilerplate", "necesito generar agentes para [cliente]", "bootstrap WxO", "fit-boilerplate", o cualquier variación de bootstrappear una solución agéntica sobre watsonx Orchestrate.
---
# fit-wxo-bootstrap
Skill conversacional para arrancar una nueva solución agéntica sobre
**watsonx Orchestrate (ADK 2.x)** usando `fit-boilerplate-wox` como base.
Es el complemento del boilerplate: el repo trae las plantillas y subagentes,
y esta skill orquesta la conversación con el usuario y el lanzamiento
paralelo de subagentes para construir la solución.
## Cuándo se invoca
- "Quiero arrancar una solución WxO nueva para [cliente]"
- "Bootstrap el boilerplate para [caso de uso]"
- "Armá los agentes para [descripción]"
- "Necesito generar el esqueleto WxO para [cliente]"
## Flujo de la skill
### Fase 1 — Entender el caso
Pregunto al usuario:
1. **¿Qué cliente / caso de uso?**
- Si es un cliente FIT existente, sugerirle usar las skills hermanas
`prep-reunion` o `consulta-fichas` para traer contexto.
- Si es un caso de uso ya conocido, capturar el contexto en una nota.
2. **¿Tenés contexto adicional para compartir?** (transcripción, email,
licitación, dibujo de arquitectura, etc.)
- Si sí, leerlo y resumirlo.
- Si no, seguir.
3. **Dominios identificados** — listar los que YO veo y pedir confirmación.
### Fase 2 — Decidir topología (con `wxo-architect`)
Llamar al subagente `wxo-architect` (de `.claude/agents/wxo-architect.md`
del boilerplate) pasándole la descripción del caso. Recibir su propuesta
de:
- Topología (Single / Multi-Specialist / Meta-Tool / Multi-Capa)
- Agentes con nombre + tools + KB sí/no + collaborators
- Tipo de tools por dominio (Python / OpenAPI / MCP)
- Stack web sugerido
- Runbooks a escribir
- Evals scenarios mínimos
**Mostrarle al usuario la propuesta completa** y pedir confirmación o
ajustes. Iterar hasta que apruebe.
### Fase 3 — Clonar el boilerplate
```bash
git clone https://gitea.fitlabs.dev/farentsen/fit-boilerplate-wox.git <cliente>
cd <cliente>
rm -rf .git && git init
```
Personalizar:
- `README.md` con el nombre del cliente / caso
- `.env.example` con los placeholders correctos
- Borrar las plantillas REPLACE_* que NO se van a usar
### Fase 4 — Dispatch paralelo de subagentes
Lanzar EN UN SOLO MENSAJE (paralelo) los subagentes que correspondan
según la topología aprobada:
- **1 instancia de `wxo-agent-author`** por cada agente (orchestrator + N specialists)
- **1 instancia de `wxo-tool-author`** por cada dominio (con type Python/OpenAPI/MCP según corresponda)
- **1 instancia de `runbook-author`** por cada runbook
- **1 instancia de `mock-builder`** por cada sistema externo a mockear
- **1 instancia de `backend-tool-builder`** si la solución tiene backend propio
- **1 instancia de `eval-author`** por cada scenario de eval planificado
- **1 instancia de `web-layer-builder`** para la capa web (modo A o B según se decidió)
Cada subagente devuelve el archivo correspondiente. Mientras corren,
informar al usuario el progreso.
### Fase 5 — Validación
Una vez que todos terminaron:
1. Correr `python3 evals/lint_wxo_yaml.py` — debe pasar
2. Correr `./scripts/check-adk-version.sh`
3. Revisar visualmente la estructura final con `tree -L 3`
4. Mostrar al usuario lo que se generó
### Fase 6 — Recomendaciones finales
Invocar `claude-code-setup:claude-automation-recommender` sobre el repo
recién generado para sugerir hooks y agentes extra específicos del proyecto.
Si hay cambios significativos, invocar `code-review:code-review` antes
del commit inicial.
### Fase 7 — Commit inicial + instrucciones de deploy
```bash
git add . && git commit -m "feat: bootstrap from fit-boilerplate-wox
Cliente: <X>
Topología: <Y>
Agentes: <lista>
Generado con fit-wxo-bootstrap skill."
```
Mostrar al usuario los **pasos manuales restantes**:
1. `cp .env.example .env` y rellenar
2. `python3.12 -m venv .venv-wxo && source .venv-wxo/bin/activate`
3. `pip install ibm-watsonx-orchestrate==2.1.0`
4. `orchestrate env add ... && orchestrate env activate ...`
5. `./scripts/deploy-wxo.sh`
6. Capturar IDs del orchestrator (live env)
7. Pegar IDs en `web/.../index.html` + `.env`
8. WxO Console → Embed Security OFF
9. `./evals/smoke-test.sh`
## Buenas prácticas que la skill enforce
Heredadas de `docs/wxo-best-practices.md` del boilerplate:
1. **Máx 10 tools por agente.** Si la propuesta del `wxo-architect`
tiene un agente con >10 tools, parar y pedir split.
2. **Domain specialists, no todistas.** Si veo agentes que mezclan
dominios, separar.
3. **Orchestrator nunca remedia.** Tools del orchestrator: solo ticketing
+ escalación.
4. **KB es opcional.** No forzar KB en agentes API-driven.
5. **Conexiones en draft Y live.**
6. **OpenAPI con description + security per-op.**
7. **Tools observables por default** (`@observable_tool`, no `@tool` directo).
8. **Coerción Pydantic** en todo input model.
## Skills hermanas que esta skill puede invocar
- `prep-reunion` — si el cliente ya existe en Outline/HubSpot
- `consulta-fichas` — para traer experiencia previa con tecnologías similares
- `fit-analisis-inicial` — si vale la pena armar un deck cliente-facing antes de codear
- `claude-code-setup:claude-automation-recommender` — sugerir automations al final
- `code-review:code-review` — review del esqueleto generado
- `feature-dev:feature-dev` — para agregar features después del bootstrap
## Templates incluidos en la skill
Ver `templates/` en este directorio:
- `templates/conversation-guide.md` — ejemplo de cómo guiar la conversación
- `templates/architect-prompt-template.md` — qué pasarle al subagente architect
- `templates/parallel-dispatch.md` — cómo lanzar todos los subagentes en paralelo
## Cuando NO usar esta skill
- Si el usuario quiere modificar una solución existente → usar `feature-dev`
- Si el usuario quiere debuggear un problema → usar `general-purpose` + `docs/known-issues.md`
- Si el usuario quiere review de código → usar `code-review`
## Output esperado
Al terminar, el usuario tiene:
- Un repo local `<cliente>/` listo para commitear a Gitea
- Todos los YAMLs, tools, runbooks, mocks, evals, web generados
- Pasos manuales de deploy claramente listados
- Un agente orquestador + N especialistas listos para `orchestrate ... import`
- Un linter que ya pasa

View File

@@ -0,0 +1,56 @@
# Prompt template — invocar el subagente `wxo-architect`
Cuando llamás al subagente `wxo-architect` (definido en `.claude/agents/wxo-architect.md`
del boilerplate clonado), usá este formato:
```
Sos el wxo-architect. Necesito que decidas la topología WxO para esta solución:
CASO DE USO:
[descripción completa de lo que el usuario te contó]
CLIENTE: [nombre]
CONTEXTO ADICIONAL:
[transcripción, RFP, email, etc. — pegar literal]
DOMINIOS IDENTIFICADOS POR EL USUARIO:
1. [dominio 1]
2. [dominio 2]
...
SISTEMAS EXTERNOS:
- [sistema A]: [tipo: mock | backend propio | SaaS con MCP | API existente]
- [sistema B]: ...
STACK WEB ELEGIDO: [FastAPI+HTMX | FastAPI+React]
OUTPUT:
Devolveme un documento markdown con todas las secciones del template de tu agente:
1. Resumen del caso
2. Dominios
3. Topología propuesta (Single | Multi-Specialist | Meta-Tool | Multi-Capa)
4. Agentes detallados (nombre, propósito, tools, KB, collaborators)
5. Tipo de tools por dominio
6. Sistemas externos y cómo se integran
7. Web layer
8. Runbooks a escribir
9. Evals scenarios mínimos
10. Riesgos / decisiones abiertas
APLICÁ ESTRICTAMENTE las reglas de docs/wxo-best-practices.md:
- Máx 10 tools por agente
- Domain specialists, no todistas
- Orchestrator nunca remedia
- KB opcional
- Patrón meta-tool si el flujo es lineal
```
## Iteración
Cuando el `wxo-architect` devuelva su propuesta:
1. Leelo entero
2. Mostrale al usuario
3. Pediles confirmación O ajustes
4. Si hay ajustes, volvés a invocar `wxo-architect` con los cambios
5. Cuando el usuario aprueba, pasamos a la Fase 4 (dispatch paralelo)

View File

@@ -0,0 +1,73 @@
# Guía conversacional
Cómo guiar la conversación con el usuario en la Fase 1 de la skill.
## Apertura
> "Genial, vamos a arrancar una solución WxO nueva. Primero necesito
> entender el caso. ¿Para qué cliente o caso de uso es?"
## Preguntas en cascada
### 1. Cliente / Caso
- **Si menciona un cliente FIT existente:**
> "¿Querés que traiga contexto de la ficha del cliente en Outline y
> deals activos de HubSpot? Puedo invocar la skill `consulta-fichas`."
- **Si menciona un caso de uso técnico ("mesa N1", "QA testing", etc.):**
> "Ok, ¿es similar a alguno que hayamos hecho? (Cotemar = mesa N1, Dun
> = QA copilot). Si tenés transcripción, email, RFP o diagrama, mandalo."
- **Si es vago:**
> "Para diseñar la arquitectura necesito saber: (a) qué le pide el usuario
> al agente, (b) qué sistemas externos necesita tocar, (c) qué procedimiento
> debe seguir. Si tenés un caso concreto, contámelo paso a paso."
### 2. Dominios
Después de oír el caso, decir lo que vos VES:
> "Por lo que me contás identifico estos dominios:
> 1. **[X]** — porque mencionaste [evidencia]
> 2. **[Y]** — porque [evidencia]
>
> ¿Falta alguno? ¿Alguno sobra?"
### 3. Sistemas externos
> "Qué sistemas tocaría cada dominio?
> - ¿Son sistemas reales (AD, ServiceNow, SAP, Dynatrace) que ya existen?
> - ¿O vamos a mockear todo para un PoC?
> - ¿Hay backend propio que ya tenemos que extender?"
### 4. Stack web
> "Para la capa web hay dos happy-paths:
> - **FastAPI + HTMX (SSR)** — perfecto para demos, control planes, paneles operativos
> - **FastAPI + React + Vite** — para apps productivas con varios usuarios
>
> ¿Cuál te encaja?"
### 5. Confirmación
Antes de empezar a generar:
> "Resumiendo:
> - Cliente: X
> - Topología: 1 orchestrator + N specialists
> - Agentes: [lista con nombres]
> - Tools: [Python para mocks, OpenAPI para backend Y]
> - Web: HTMX
> - Runbooks: [lista]
>
> ¿Avanzo con esto? ¿Querés cambiar algo?"
## Cuándo NO seguir
Detenerse y pedir más contexto si:
- No queda claro qué le pide el usuario al agente
- No queda claro qué sistemas externos hay
- La topología propuesta tiene un agente con >10 tools (split)
- El orchestrator necesitaría tools de remediación (rediseñar)
- Hay >5 dominios y el usuario no quiere multi-capa (advertir consecuencias)

View File

@@ -0,0 +1,171 @@
# Dispatch paralelo de subagentes
Cuando la topología está aprobada (Fase 4), lanzá TODOS los subagentes
en paralelo (un único mensaje con múltiples `Agent` tool calls).
## Patrón
```
Voy a lanzar N subagentes en paralelo para construir toda la solución.
Cada uno escribe su pieza. Esto va a tomar 2-5 minutos.
```
Luego invocá EN UN SOLO MENSAJE algo así:
```python
# Pseudocódigo — el agente principal hace múltiples llamadas Agent() en paralelo
# Por cada agente WxO en la topología:
for agent in topology.agents:
Agent(
description=f"Write WxO agent YAML for {agent.name}",
subagent_type="wxo-agent-author",
prompt=f"""
Escribí el agent.yaml de WxO para:
role: {agent.role}
name: {agent.name}
display_name: "{agent.display_name}"
description: {agent.description}
domain: {agent.domain}
tools: {agent.tools}
collaborators: {agent.collaborators}
knowledge_base: {agent.kb or "none"}
client_name: {client}
business_context: {agent.business_context}
escalation_table: {agent.escalation_table or "none"}
examples: {agent.examples}
Guardalo en wxo/agents/{agent.name}.agent.yaml
Aplicá todas las reglas de docs/wxo-best-practices.md.
"""
)
# Por cada dominio (tools):
for domain in topology.domains:
Agent(
description=f"Write tool wrappers for {domain.name}",
subagent_type="wxo-tool-author",
prompt=f"""
Escribí los tool wrappers para el dominio {domain.name}.
type: {domain.tool_type}
tools: {domain.tools}
external_system: {domain.external_system}
Aplicá:
- @observable_tool (regla T1)
- Coerción Pydantic (issue I-003)
- BASE_URL del env (regla T4)
- Compat shim inline si es Python (issue I-010)
"""
)
# Por cada runbook:
for rb in topology.runbooks:
Agent(
description=f"Write runbook {rb.id}",
subagent_type="runbook-author",
prompt=f"""
Escribí el runbook {rb.id}{rb.title}.
domain: {rb.domain}
trigger_description: {rb.trigger}
preconditions: {rb.preconditions}
steps: {rb.steps}
success_criteria: {rb.success}
escalation_table: {rb.escalation or "none"}
Guardalo en wxo/knowledge_base/runbooks/runbook-{rb.id}-{rb.slug}.txt
"""
)
# Por cada sistema externo a mockear:
for sys in topology.mocks:
Agent(
description=f"Build mock for {sys.name}",
subagent_type="mock-builder",
prompt=f"""
Construí el mock FastAPI para {sys.name}.
endpoints: {sys.endpoints}
seed_data: {sys.seed_data}
behaviors: {sys.behaviors}
"""
)
# Por cada scenario de eval:
for scn in topology.evals:
Agent(
description=f"Write eval scenario {scn.name}",
subagent_type="eval-author",
prompt=f"""
Escribí el eval scenario para {scn.agent}.
scenario_type: {scn.type}
runbook_id: {scn.runbook_id}
business_input: "{scn.input}"
expected_tool_calls_in_order: {scn.tool_calls}
expected_response_tokens: {scn.response_tokens}
expected_final_state: {scn.final_state}
forbidden_tool_calls: {scn.forbidden or []}
"""
)
# Web layer:
Agent(
description="Build web layer",
subagent_type="web-layer-builder",
prompt=f"""
Construí la capa web para {client}.
mode: {topology.web_stack} # A o B
landing: {topology.landing}
trace_view: {topology.trace_view}
"""
)
# Si hay backend propio:
if topology.has_backend:
Agent(
description="Build FastAPI backend with OpenAPI tools",
subagent_type="backend-tool-builder",
prompt=f"""
Construí el backend FastAPI para {client}.
endpoints: {topology.backend.endpoints}
db_schema: {topology.backend.schema}
audit_events: {topology.backend.audit_events}
"""
)
```
## Mientras corren
Mostrar al usuario:
```
🚀 Lanzados 12 subagentes en paralelo:
✓ wxo-agent-author (orchestrator)
✓ wxo-agent-author (specialist_ad)
✓ wxo-agent-author (specialist_ops)
✓ wxo-tool-author (ad)
✓ wxo-tool-author (ops)
✓ runbook-author (RB-01)
✓ runbook-author (RB-02)
✓ mock-builder (ad_mock)
✓ mock-builder (ops_mock)
✓ eval-author (scenario_reset)
✓ eval-author (scenario_restart)
✓ web-layer-builder
Esperando resultados...
```
## Cuando todos terminan
1. Listar archivos generados (`find <cliente>/ -newer .git -type f`)
2. Correr lint:
```bash
python3 evals/lint_wxo_yaml.py
```
3. Si hay errores, decidir si re-ejecutar el subagente que falló o pedir
ajustes manuales
4. Mostrar al usuario un `tree -L 3` de lo que quedó
5. Pasar a Fase 6 (recomendaciones)

View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY web/_default_fastapi_htmx/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY web/_default_fastapi_htmx/app/ ./app/
EXPOSE 8000
# Healthcheck con `wget -qO-` — NO `--spider` (issue I-008)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,141 @@
"""Web layer default — FastAPI + Jinja2 + HTMX.
Estructura mínima viable para demos / control planes. Incluye:
- / landing con embed WxO
- /traces timeline observable de tool calls
- /api/traces endpoint que recibe trazas del decorator @observable_tool
- /health healthcheck para Coolify/Traefik
Reemplazá lo que necesites o usá esto como base para extender.
"""
from __future__ import annotations
import json
import os
import sqlite3
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static"
TRACES_DB = os.environ.get("TRACES_DB_PATH", "/data/traces.db")
# WxO embed config (capturar tras deploy)
WXO_AGENT_ID = os.environ.get("WXO_AGENT_ID", "")
WXO_INSTANCE_URL = os.environ.get("WXO_INSTANCE_URL", "")
def _init_db():
"""Crea la tabla de trazas si no existe."""
Path(TRACES_DB).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(TRACES_DB)
conn.execute("""
CREATE TABLE IF NOT EXISTS traces (
trace_id TEXT PRIMARY KEY,
tool TEXT,
domain TEXT,
agent_caller TEXT,
correlation_id TEXT,
started_at TEXT,
duration_ms INTEGER,
payload TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS ix_started ON traces (started_at)")
conn.execute("CREATE INDEX IF NOT EXISTS ix_agent ON traces (agent_caller)")
conn.commit()
conn.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
_init_db()
yield
app = FastAPI(title="Boilerplate Web Layer", lifespan=lifespan)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# ─── Routes ─────────────────────────────────────────────────────────────────
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def landing(request: Request):
return templates.TemplateResponse("index.html", {
"request": request,
"wxo_agent_id": WXO_AGENT_ID,
"wxo_instance_url": WXO_INSTANCE_URL,
})
@app.get("/traces", response_class=HTMLResponse)
def traces_page(request: Request):
return templates.TemplateResponse("traces.html", {"request": request})
@app.get("/api/traces/recent")
def api_traces_recent(since: str | None = None, limit: int = 100):
"""JSON endpoint — devuelve las últimas N trazas, opcionalmente desde un ISO timestamp."""
conn = sqlite3.connect(TRACES_DB)
if since:
rows = conn.execute(
"SELECT payload FROM traces WHERE started_at > ? ORDER BY started_at DESC LIMIT ?",
(since, limit)
).fetchall()
else:
rows = conn.execute(
"SELECT payload FROM traces ORDER BY started_at DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return {"traces": [json.loads(r[0]) for r in rows]}
@app.post("/api/traces")
async def api_traces_post(request: Request):
"""Endpoint que recibe trazas del decorator @observable_tool con TRACE_SINK=http."""
trace: dict[str, Any] = await request.json()
conn = sqlite3.connect(TRACES_DB)
conn.execute(
"INSERT OR REPLACE INTO traces VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
trace.get("trace_id"), trace.get("tool"), trace.get("domain"),
trace.get("agent_caller"), trace.get("correlation_id"),
trace.get("started_at"), trace.get("duration_ms"),
json.dumps(trace),
),
)
conn.commit()
conn.close()
return {"ok": True}
@app.get("/partials/timeline", response_class=HTMLResponse)
def partial_timeline(request: Request):
"""HTMX partial — la tabla de trazas para refrescar via hx-trigger."""
conn = sqlite3.connect(TRACES_DB)
rows = conn.execute(
"SELECT payload FROM traces ORDER BY started_at DESC LIMIT 50"
).fetchall()
conn.close()
traces = [json.loads(r[0]) for r in rows]
return templates.TemplateResponse("_timeline_rows.html", {
"request": request, "traces": traces,
})

View File

@@ -0,0 +1,36 @@
{% if traces %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Agent</th>
<th>Tool</th>
<th>Domain</th>
<th>Duration</th>
<th>Status</th>
<th>Trace ID</th>
</tr>
</thead>
<tbody>
{% for t in traces %}
<tr>
<td><code>{{ t.started_at[:19] if t.started_at else "—" }}</code></td>
<td>{{ t.agent_caller or "—" }}</td>
<td><code>{{ t.tool }}</code></td>
<td>{{ t.domain or "—" }}</td>
<td>{{ t.duration_ms }} ms</td>
<td>
{% if t.error %}
<span class="badge badge--err">error</span>
{% else %}
<span class="badge badge--ok">ok</span>
{% endif %}
</td>
<td><code>{{ t.trace_id }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: #6b7280; padding: 20px; text-align: center;">No hay trazas aún. Ejecutá algo en el agente.</p>
{% endif %}

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}fit-boilerplate-wox{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #f7f8fa; color: #1a1d24; }
header { background: #0f172a; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 24px; }
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
header nav a { color: #cbd5e1; text-decoration: none; font-size: 14px; margin-right: 16px; }
header nav a:hover { color: white; }
main { max-width: 1200px; margin: 0 auto; padding: 24px; }
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; font-size: 13px; }
th { background: #f3f4f6; font-weight: 600; color: #374151; }
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; }
.badge--ok { background: #dcfce7; color: #166534; }
.badge--err { background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
<header>
<h1>fit-boilerplate-wox</h1>
<nav>
<a href="/">Landing</a>
<a href="/traces">Traces</a>
<a href="/health">Health</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Landing — fit-boilerplate-wox{% endblock %}
{% block content %}
<div class="card">
<h2>Boilerplate WxO + Web</h2>
<p>Esta es la landing por defecto del template. Reemplazala con tu UI específica.</p>
<ul>
<li><strong>WxO Agent ID:</strong> <code>{{ wxo_agent_id or "(no configurado)" }}</code></li>
<li><strong>WxO Instance:</strong> <code>{{ wxo_instance_url or "(no configurado)" }}</code></li>
</ul>
</div>
<div class="card">
<h3>Chat con el agente</h3>
{% if wxo_agent_id %}
<div id="wxo-chat-root"></div>
<script>
// REPLACE: pegá el snippet completo que devuelve `orchestrate channels create webchat`
// window.wxoChat = {
// apiKey: "...",
// agentId: "{{ wxo_agent_id }}",
// agentEnvironmentId: "REPLACE_ENV_UUID",
// locale: "es"
// };
</script>
<!-- <script src="https://CHANGEME/webchat.js" defer></script> -->
<p style="color: #6b7280; font-size: 13px;">
⚠ Acordate de activar <strong>Embed Security = Off</strong> en la consola WxO (issue I-007).
</p>
{% else %}
<p style="color: #6b7280;">El embed se activa cuando completes <code>WXO_AGENT_ID</code> en <code>.env</code>.</p>
{% endif %}
</div>
<div class="card">
<h3>Ver trazas de tool calls</h3>
<p><a href="/traces">→ /traces (timeline observable)</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Traces — fit-boilerplate-wox{% endblock %}
{% block content %}
<div class="card">
<h2>Tool call traces</h2>
<p style="color: #6b7280;">Auto-refresh cada 2 segundos. Las trazas vienen del decorator <code>@observable_tool</code>.</p>
</div>
<div class="card" hx-get="/partials/timeline" hx-trigger="load, every 2s" hx-swap="innerHTML">
<p>Cargando…</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,7 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
jinja2>=3.1.0
python-multipart>=0.0.6
sqlalchemy>=2.0.0
pydantic>=2.5.0
httpx>=0.26.0

View File

@@ -0,0 +1,111 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — ORCHESTRATOR
#
# Usar cuando hay 2+ dominios y querés un agente que clasifique + delegue.
# El orquestador NUNCA ejecuta tools de remediación — solo crea/lee tickets
# y delega a specialists. Esto es enforced por evals/lint_wxo_yaml.py.
# ─────────────────────────────────────────────────────────────────────────────
spec_version: v1
kind: native
name: REPLACE_orchestrator_name # ej: mesa_n1_cotemar
display_name: "REPLACE Display Name"
description: |
REPLACE — descripción de qué hace este orquestador.
# style "react" = ReAct (Reason+Act). Default para agentes con tools.
style: react
# LLM preferido para tool-calling. Issue I-002: NO usar llama-3-3-70b
# salvo que estés forzado, y siempre con REGLA #0 explícita.
llm: groq/openai/gpt-oss-120b
instructions: |
Eres el Agente Orquestador de REPLACE_DOMINIO para REPLACE_CLIENTE.
IDIOMA: español neutro (o el que corresponda al cliente).
REGLA #0 (CRÍTICA): NUNCA escribas la tool call como texto.
Usá el protocolo de tool_calls del LLM. Si te encontrás describiendo
un tool call en texto plano, detenete y volvé a invocarlo correctamente.
AGENTES ESPECIALISTAS QUE PUEDES INVOCAR:
- REPLACE_specialist_1 — REPLACE descripción dominio 1
- REPLACE_specialist_2 — REPLACE descripción dominio 2
- REPLACE_specialist_3 — REPLACE descripción dominio 3
FLUJO DE TRABAJO:
1. CLASIFICAR el input. Identificar dominio.
2. DELEGAR al especialista correspondiente:
- REPLACE tipo de caso 1 → REPLACE_specialist_1
- REPLACE tipo de caso 2 → REPLACE_specialist_2
- REPLACE tipo de caso 3 → REPLACE_specialist_3
- Cualquier otro → manejar directamente (escalación).
3. ESPERAR el reporte del especialista (ticket_id + resultado).
4. SI el especialista devuelve fallo → aplicar runbook de escalación.
5. RESPONDER al usuario con resumen final.
CUÁNDO MANEJAR DIRECTAMENTE (escalación):
- REPLACE criterios de escalación
- Usuario pide humano explícitamente.
TABLA DE ESCALAMIENTO:
| Razón | assigned_group | assigned_user |
| REPLACE criterio 1 | REPLACE grupo 1 | REPLACE persona 1|
| REPLACE criterio 2 | REPLACE grupo 2 | REPLACE persona 2|
REGLAS:
- NO duplicar tickets: si el especialista creó uno, no crear otro.
- SIEMPRE registrar en el sistema de tickets — directamente (si escalas)
o vía el ticket del especialista (si delegaste exitosamente).
ESTILO DE RESPUESTA:
- Conciso, estructurado, frases cortas.
- Razonamiento visible: "Clasifico → X specialist", "Recibo TKT-XXXX".
- Reportá el ticket ID final.
EJEMPLOS DE COMPORTAMIENTO ESPERADO:
Ejemplo 1 — REPLACE caso happy path:
Input: "REPLACE input ejemplo"
Razonamiento: 1. Clasifico → X. 2. Delego a Y. 3. Recibo TKT-...
Respuesta: "REPLACE respuesta esperada"
Ejemplo 2 — REPLACE caso de escalación:
Input: "REPLACE input que escala"
Razonamiento: 1. Clasifico → escalación porque Z.
2. NO delego. 3. Creo ticket ESCALATED.
Respuesta: "REPLACE respuesta de escalación"
Tu valor es ser PREDECIBLE, TRANSPARENTE y CONSERVADOR.
Cuando dudes, escalá. Cuando puedas delegar, delegá.
# Specialists que este orchestrator puede invocar.
# DEBEN existir ya en el tenant antes de importar este YAML.
collaborators:
- REPLACE_specialist_1
- REPLACE_specialist_2
- REPLACE_specialist_3
# Tools del orchestrator: SOLO meta-tools de ticketing + escalación.
# NUNCA tools de remediación (reset_password, restart_service, etc.).
# Si necesitás más de 10 tools acá, partí en sub-orchestrators.
tools:
- create_ticket
- update_ticket
- list_tickets
- get_ticket
# KB opcional — solo si el orchestrator necesita runbook de escalación.
knowledge_base:
- REPLACE_kb_escalation_name # o [] si no necesitás KB
starter_prompts:
is_default_prompts: true
prompts:
- id: example_1
title: "REPLACE título"
prompt: "REPLACE prompt de ejemplo"
- id: example_2
title: "REPLACE título 2"
prompt: "REPLACE prompt de ejemplo 2"

View File

@@ -0,0 +1,71 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — SINGLE AGENT con META-TOOL (patrón Dun)
#
# Usar cuando el flujo es LINEAL y CONOCIDO:
# caso entra → A → B → C → D → resultado, siempre el mismo orden.
#
# El agente invoca UNA sola tool (`run_full_case`) y el backend orquesta
# los substeps internamente. Esto evita el recursion_limit=30 de langgraph
# (issue I-001).
#
# La observabilidad se preserva con `write_audit` desde el backend por cada
# substep — la UI reconstruye el timeline igual.
# ─────────────────────────────────────────────────────────────────────────────
spec_version: v1
kind: native
name: REPLACE_agent_name # ej: qa_studio_agent
display_name: "REPLACE Display Name"
description: |
Agente único para REPLACE_CASO. Procesa el caso completo invocando
una sola meta-tool. El backend orquesta los substeps.
style: react
llm: groq/openai/gpt-oss-120b
instructions: |
Eres el agente de REPLACE_CASO para REPLACE_CLIENTE.
REGLA #0 (CRÍTICA): NUNCA escribas la tool call como texto. Usá el
protocolo de tool_calls del LLM. Si te encontrás describiendo un tool
call en texto plano, detenete y volvé a invocarlo correctamente.
TU FLUJO ES SIMPLE Y FIJO:
1. El usuario te pide procesar un caso (te pasa el case_id o info equivalente).
2. Invocás `run_full_case(case_id)` UNA sola vez.
3. La tool devuelve { ok, package_url, audit_url, ... }.
4. Respondés al usuario con el resultado, citando el `audit_url` para
que pueda inspeccionar los substeps.
NO INVOQUES MÚLTIPLES TOOLS. El backend hace todo internamente.
Si la tool devuelve `ok: false`:
- Reportá el error textual al usuario.
- Sugerí mirar el `audit_url` para diagnóstico.
- NO intentes "arreglar" llamando otras tools.
EJEMPLOS:
Usuario: "Procesá el caso CASE-12345"
Razonamiento: invocar run_full_case("CASE-12345").
Tool devuelve: { ok: true, package_url: "...", audit_url: "..." }
Respuesta: "Caso CASE-12345 procesado OK. Package: <package_url>.
Detalle de pasos: <audit_url>."
# Sin collaborators.
collaborators: []
# Una sola meta-tool — el backend orquesta los substeps.
tools:
- run_full_case
# Sin KB — el flujo está codificado en el prompt y en el backend.
knowledge_base: []
starter_prompts:
is_default_prompts: true
prompts:
- id: process_case
title: "Procesar caso"
prompt: "Procesá el caso REPLACE-EXAMPLE-ID"

View File

@@ -0,0 +1,85 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — SPECIALIST
#
# Agente de dominio. Sabe UNA cosa pero la sabe bien. Máx 10 tools (issue A1).
# Es invocado por un orchestrator vía `collaborators`, o standalone.
# ─────────────────────────────────────────────────────────────────────────────
spec_version: v1
kind: native
name: REPLACE_specialist_name # ej: ad_specialist_cotemar
display_name: "REPLACE Display Name"
description: |
Especialista de REPLACE_DOMINIO para REPLACE_CLIENTE.
Ejecuta REPLACE listar capacidades.
style: react
llm: groq/openai/gpt-oss-120b
instructions: |
Eres el Especialista de REPLACE_DOMINIO de REPLACE_CLIENTE.
REGLA #0: NUNCA escribas la tool call como texto. Usá el protocolo
de tool_calls.
TU DOMINIO:
- REPLACE qué hacés (1 frase)
TUS HERRAMIENTAS:
- REPLACE_tool_1: REPLACE qué hace
- REPLACE_tool_2: REPLACE qué hace
- create_ticket: registrar el resultado en el sistema de tickets
FLUJO STANDARD:
1. Recibir la solicitud del orchestrator (o usuario directo).
2. Consultar el runbook aplicable (KB).
3. Ejecutar las tools en el orden que el runbook indica.
4. Crear ticket con el resultado (status=RESOLVED si todo OK).
5. Reportar al orchestrator: ticket_id + resultado.
CUÁNDO REPORTAR FALLO (sin crear ticket de éxito):
- REPLACE criterio 1 (ej: user_inactive)
- REPLACE criterio 2 (ej: out_of_scope)
- REPLACE criterio 3
Cuando reportes fallo, devolvé: { ok: false, reason: "...", details: {...} }
El orchestrator decidirá si escalar.
REGLAS:
- NO ejecutar acciones fuera de tu dominio. Si te piden algo que no es
REPLACE_DOMINIO, devolvé `out_of_scope`.
- NO crear tickets si la acción falló — esa decisión es del orchestrator.
- SIEMPRE incluí en extra del ticket: { runbook: "REPLACE_id", ...metadata }.
EJEMPLOS:
Ejemplo 1 — Happy path:
Input del orchestrator: { username: "X" }
Razonamiento: lookup_user(X) → encontrado.
Aplico runbook REPLACE_id paso a paso.
create_ticket(status=RESOLVED, extra={runbook:"REPLACE_id"}).
Respuesta: { ok: true, ticket_id: "TKT-XXXX" }
Ejemplo 2 — Fallo recuperable:
Input: { username: "X" }
Razonamiento: lookup_user(X) → inactivo.
NO ejecuto la acción. NO creo ticket.
Respuesta: { ok: false, reason: "user_inactive", details: {...} }
# Sin collaborators — un specialist es hoja del grafo.
collaborators: []
# Máximo 10 tools (regla A1). Si necesitás más, considerá meta-tool (I-001).
tools:
- REPLACE_tool_1
- REPLACE_tool_2
- REPLACE_tool_3
- create_ticket
# KB con SOLO los runbooks de este dominio (regla A5).
knowledge_base:
- REPLACE_kb_name # o [] si es API-driven sin runbook
starter_prompts:
is_default_prompts: false # specialists no aparecen al usuario directo
prompts: []

View File

@@ -0,0 +1,29 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — Connection API KEY con HEADER CUSTOM (estilo Dun)
#
# Para producción: el sistema externo exige un token, pero NO podemos
# usar `Authorization: Bearer` porque choca con el IAM token que
# Orchestrate ya inyecta para llamar al tool (regla C2).
#
# Solución: usar un header custom como `X-Orchestrate-Token`.
# ─────────────────────────────────────────────────────────────────────────────
spec_version: v1
kind: connection
name: REPLACE_app_name # ej: buro_backend
display_name: "REPLACE Display Name"
description: |
Connection a REPLACE_SISTEMA con API key en header custom.
schema_version: "1.0"
auth_type: api_key
identifier: REPLACE_app_name_apikey
# Header custom (NO Authorization: Bearer).
api_key_header: "X-Orchestrate-Token"
preference:
- environment: draft
schema_id: api_key
- environment: live
schema_id: api_key

View File

@@ -0,0 +1,25 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — Connection KEY_VALUE (sin auth)
#
# Para mocks demo o sistemas internos sin auth. La credencial es solo
# BASE_URL — la tool la lee del env.
# ─────────────────────────────────────────────────────────────────────────────
spec_version: v1
kind: connection
name: REPLACE_app_name # ej: unus_demo, ad_demo
display_name: "REPLACE Display Name"
description: |
Connection a REPLACE_SISTEMA.
Auth: none (mock / sistema interno sin auth).
schema_version: "1.0"
auth_type: key_value
identifier: REPLACE_app_name_kv
# IMPORTANTE: declarar BOTH draft Y live (issue I-004).
preference:
- environment: draft
schema_id: key_value
- environment: live
schema_id: key_value

View File

@@ -0,0 +1,34 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — Connection MCP
#
# Para sistemas que exponen un MCP server (HubSpot, Outline, GitHub MCP,
# etc.). ADK descubre las tools del server automáticamente.
# ─────────────────────────────────────────────────────────────────────────────
spec_version: v1
kind: connection
name: REPLACE_mcp_app_name # ej: hubspot_mcp
display_name: "REPLACE Display Name"
description: |
Connection MCP a REPLACE_SISTEMA. Las tools se descubren del server.
schema_version: "1.0"
auth_type: mcp
identifier: REPLACE_mcp_app_name_conn
preference:
- environment: draft
schema_id: mcp
- environment: live
schema_id: mcp
# Setup post-import:
# orchestrate connections set-credentials -a REPLACE_mcp_app_name \
# --env draft \
# -e "MCP_SERVER_URL=https://mi-mcp-server.example.com" \
# -e "MCP_TOKEN=$MCP_TOKEN"
#
# En el YAML del agente que use estas tools:
# tools:
# - REPLACE_mcp_app_name/tool_descubierta_1
# - REPLACE_mcp_app_name/tool_descubierta_2

View File

@@ -0,0 +1,24 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — Knowledge Base
#
# UNA KB por agente (regla A5 — least privilege). Los documents listados
# acá deben existir bajo `wxo/knowledge_base/runbooks/` (o el path que pongas).
#
# KB es OPCIONAL (regla A6) — si el agente es API-driven sin necesidad
# de razonar sobre procedimiento escrito, omití la KB.
# ─────────────────────────────────────────────────────────────────────────────
spec_version: v1
kind: knowledge_base
name: REPLACE_kb_name # ej: runbooks_ad_cotemar
description: |
Base de conocimiento del especialista REPLACE_DOMINIO.
Contiene los runbooks aplicables a este agente.
documents:
- "runbooks/runbook-XX-replace-name.txt"
# - "runbooks/runbook-YY-otro.txt"
embeddings:
# Default sano para ES+EN. Otros: ibm/slate-30m-english-rtrvr (más liviano).
model: ibm/slate-125m-english-rtrvr

View File

@@ -0,0 +1,35 @@
# Runbook XX — REPLACE título
## Trigger
- REPLACE cuándo aplica este runbook (input específico, evento específico)
- REPLACE condición secundaria si aplica
## Precondiciones
- REPLACE qué tiene que ser verdad antes de ejecutar
- REPLACE permisos / accesos necesarios
- REPLACE datos que el agente debe haber recolectado
## Pasos
1. REPLACE primer paso, verbo imperativo. "Consultar lookup_user(username)".
2. REPLACE segundo paso. "Si user.active=false → reportar fallo (ver Fallo §1)".
3. REPLACE tercer paso. "Ejecutar reset_password(username)".
4. REPLACE cuarto paso. "Crear ticket(...)".
## Éxito
- REPLACE qué confirma que el procedimiento salió bien
- REPLACE qué campos del ticket final deben estar presentes
- REPLACE qué responder al usuario / al orchestrator
## Fallo
- §1 REPLACE caso de fallo 1 → qué hacer (típicamente: reportar al orchestrator con reason="...")
- §2 REPLACE caso de fallo 2
- §3 REPLACE timeout / error de sistema externo
## Escalamiento
- REPLACE cuándo escalar (siempre, o solo en ciertos casos)
- REPLACE a qué grupo / persona
- REPLACE qué información incluir en el ticket de escalación
## Notas
- REPLACE consideraciones especiales, datos sensibles, restricciones legales/compliance
- REPLACE cambios recientes / versión del runbook

View File

@@ -0,0 +1,47 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — Connection MCP (Model Context Protocol)
#
# Para sistemas que exponen un MCP server (HubSpot, Outline, GitHub MCP, etc.).
# ADK descubre las tools del server automáticamente.
#
# Setup paso a paso:
#
# 1. Importar este YAML:
# orchestrate connections add -a REPLACE_mcp_name
#
# 2. Configurar en draft Y live:
# for ENV in draft live; do
# orchestrate connections configure -a REPLACE_mcp_name \
# --env $ENV --type team --kind mcp
# orchestrate connections set-credentials -a REPLACE_mcp_name \
# --env $ENV \
# -e "MCP_SERVER_URL=https://mi-mcp-server.example.com" \
# -e "MCP_TOKEN=$MCP_TOKEN"
# done
#
# 3. Listar las tools que ADK descubrió:
# orchestrate tools list --app-id REPLACE_mcp_name
#
# 4. Referenciarlas en el YAML del agente:
# tools:
# - REPLACE_mcp_name/get_contact
# - REPLACE_mcp_name/create_deal
# ─────────────────────────────────────────────────────────────────────────────
spec_version: v1
kind: connection
name: REPLACE_mcp_name
display_name: "REPLACE — MCP server"
description: |
Connection MCP a REPLACE_SISTEMA.
Tools descubiertas automáticamente del server.
schema_version: "1.0"
auth_type: mcp
identifier: REPLACE_mcp_name_conn
preference:
- environment: draft
schema_id: mcp
- environment: live
schema_id: mcp

View File

@@ -0,0 +1,108 @@
"""Template — endpoint en tu backend que expone un OpenAPI filtrado a WxO.
Patrón Dun: el backend FastAPI expone TODOS sus endpoints, pero solo
algunos son "tools" que WxO puede invocar. Este endpoint filtra el
openapi.json a una allowlist `PUBLIC_TOOLS`, fuerza description +
security per-operation, y resuelve $ref transitivamente.
WxO lo importa con:
orchestrate tools import -k openapi \
-f <(curl -s $BACKEND_URL/api/v1/orchestrate-tools-spec.json) \
--app-id mi_backend
(O bien `deploy-wxo.sh` baja el spec y lo patche localmente.)
Cómo integrar:
from .orchestrate_spec import build_public_spec
app = FastAPI(...)
@app.get("/api/v1/orchestrate-tools-spec.json")
def orchestrate_tools_spec():
return build_public_spec(app)
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
# ───────────────────────────────────────────────────────────────────────────
# ALLOWLIST — qué operaciones expone tu backend a WxO.
# Formato: "METHOD /path/exacto"
# ───────────────────────────────────────────────────────────────────────────
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: Any) -> dict[str, Any]:
"""Genera el spec filtrado para WxO.
Args:
app: la instancia FastAPI (u objeto con .openapi()).
Returns:
dict con el OpenAPI spec listo para importar.
"""
full = deepcopy(app.openapi())
spec: dict[str, Any] = {
"openapi": full.get("openapi", "3.0.0"),
"info": {**full["info"], "title": full["info"]["title"] + " (orchestrate)"},
"servers": full.get("servers", [{"url": "https://CHANGEME/api/v1"}]),
"paths": {},
"components": {"schemas": {}, "securitySchemes": full.get("components", {}).get("securitySchemes", {})},
"security": full.get("security", []),
}
# ── 1. Filtrar paths a la allowlist
for path, item in full.get("paths", {}).items():
kept_methods: dict[str, Any] = {}
for method, op in item.items():
if method.upper() not in ("GET", "POST", "PATCH", "PUT", "DELETE"):
continue
key = f"{method.upper()} {path}"
if key not in PUBLIC_TOOLS:
continue
# Fix issue I-005: description obligatorio per-op
op["description"] = op.get("description") or op.get("summary") or f"{method} {path}"
# Fix issue I-006: security per-op (no se hereda)
op["security"] = spec["security"]
kept_methods[method] = op
if kept_methods:
spec["paths"][path] = kept_methods
# ── 2. Resolver $ref transitivamente — solo schemas usados
all_schemas = full.get("components", {}).get("schemas", {})
needed: set[str] = set()
def _walk(o: Any) -> None:
if isinstance(o, dict):
for k, v in o.items():
if k == "$ref" and isinstance(v, str) and v.startswith("#/components/schemas/"):
needed.add(v.split("/")[-1])
else:
_walk(v)
elif isinstance(o, list):
for x in o:
_walk(x)
_walk(spec["paths"])
# Closure transitiva
pending = list(needed)
while pending:
s = pending.pop()
if s in spec["components"]["schemas"]:
continue
if s not in all_schemas:
continue
spec["components"]["schemas"][s] = all_schemas[s]
before = set(needed)
_walk(all_schemas[s])
for new in needed - before:
pending.append(new)
return spec

View File

@@ -0,0 +1,80 @@
# ─────────────────────────────────────────────────────────────────────────────
# Template — OpenAPI 3.1 spec para WxO
#
# Importar con:
# orchestrate tools import -k openapi -f este_archivo.yaml --app-id mi_app
#
# REQUISITOS (paga con dolor):
# - description per-operation (issue I-005)
# - security per-operation, NO solo global (issue I-006)
# - servers[0].url parcheado en deploy time según env
# - Si el backend usa FastAPI, mejor exponer un endpoint /orchestrate-tools-spec.json
# que filtra dinámicamente. Ver _backend_filter_endpoint.py.
# ─────────────────────────────────────────────────────────────────────────────
openapi: 3.1.0
info:
title: "REPLACE Service API (orchestrate-exposed subset)"
version: "1.0.0"
description: "Subset filtrado de la API expuesto a WxO."
servers:
# PATCH ME en deploy time:
# jq --arg url "$BACKEND_URL" '.servers = [{"url": $url}]'
- url: "https://CHANGEME/api/v1"
# Security definitions (NO heredan a las ops — issue I-006)
components:
securitySchemes:
OrchestrateToken:
type: apiKey
in: header
name: X-Orchestrate-Token # custom header, NO Bearer (regla C2)
schemas:
RunCaseInput:
type: object
required: [case_id, target_id]
properties:
case_id:
type: string
description: "ID del caso a procesar."
target_id:
type: integer
description: "ID del recurso objetivo."
options:
type: array
items: { type: string }
description: "Opciones adicionales."
RunCaseResult:
type: object
properties:
ok: { type: boolean }
ticket_id: { type: string }
package_url: { type: string }
error: { type: string, nullable: true }
paths:
/cases/run:
post:
operationId: run_full_case
summary: "Run a case end-to-end"
# description OBLIGATORIO — issue I-005
description: |
Procesa un caso completo invocando los substeps en el backend.
Devuelve ok=true + ticket_id + package_url, o ok=false + error.
Cada substep emite write_audit con case_id.
security: # per-operation — issue I-006
- OrchestrateToken: []
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/RunCaseInput" }
responses:
"200":
description: "Resultado del procesamiento."
content:
application/json:
schema: { $ref: "#/components/schemas/RunCaseResult" }

View File

@@ -0,0 +1,100 @@
"""Template — validador HMAC-SHA256 para webhooks de Orchestrate.
Cuando WxO te manda un webhook (run lifecycle, completion, etc.), valida
la firma para descartar requests no autorizados.
Headers que WxO envía:
X-Orchestrate-Timestamp (unix ts, ms)
X-Orchestrate-Nonce (string aleatorio)
X-Orchestrate-Signature (hex de HMAC-SHA256)
La firma se calcula sobre:
HMAC-SHA256(secret, f"{timestamp}.{nonce}.{body}")
Reglas:
- Tolerar skew de timestamp de 300s (HTTP 408 si stale)
- Rechazar nonces repetidos (replay protection — guardar últimos 10k)
- HTTP 401 si firma no matchea
Origen: Dun `backend/app/orchestrate/webhook_validator.py`.
"""
from __future__ import annotations
import hashlib
import hmac
import time
from collections import deque
from threading import Lock
from typing import Optional
class WebhookValidator:
def __init__(self, secret: str, skew_seconds: int = 300, nonce_cache_size: int = 10_000):
self._secret = secret.encode("utf-8")
self._skew = skew_seconds
self._nonces: deque[str] = deque(maxlen=nonce_cache_size)
self._nonces_set: set[str] = set()
self._lock = Lock()
def validate(self, timestamp: str, nonce: str, signature: str, body: bytes) -> Optional[str]:
"""Returns None if valid, else an error code: 'stale' | 'replay' | 'badsig'."""
# ── 1. Timestamp freshness
try:
ts_ms = int(timestamp)
except ValueError:
return "badsig"
now_ms = int(time.time() * 1000)
if abs(now_ms - ts_ms) > self._skew * 1000:
return "stale"
# ── 2. Nonce replay
with self._lock:
if nonce in self._nonces_set:
return "replay"
# ── 3. Signature
msg = f"{timestamp}.{nonce}.".encode("utf-8") + body
expected = hmac.new(self._secret, msg, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return "badsig"
# Valid — register nonce
with self._lock:
if len(self._nonces) >= self._nonces.maxlen:
# deque is bounded; rotate set in sync
expired = self._nonces[0]
self._nonces_set.discard(expired)
self._nonces.append(nonce)
self._nonces_set.add(nonce)
return None
# Ejemplo de uso en FastAPI:
#
# from fastapi import APIRouter, Request, Header, HTTPException
# from .webhook_validator import WebhookValidator
#
# router = APIRouter()
# _validator = WebhookValidator(secret=os.environ["WEBHOOK_SECRET"])
#
# @router.post("/webhooks/orchestrate")
# async def orch_webhook(
# request: Request,
# x_orchestrate_timestamp: str = Header(...),
# x_orchestrate_nonce: str = Header(...),
# x_orchestrate_signature: str = Header(...),
# ):
# body = await request.body()
# err = _validator.validate(
# x_orchestrate_timestamp,
# x_orchestrate_nonce,
# x_orchestrate_signature,
# body,
# )
# if err == "stale":
# raise HTTPException(status_code=408, detail="timestamp skew")
# if err == "replay":
# raise HTTPException(status_code=409, detail="nonce reused")
# if err == "badsig":
# raise HTTPException(status_code=401, detail="bad signature")
# # ... handle payload

View File

@@ -0,0 +1,90 @@
"""Pydantic coercion helpers for tool input schemas.
LLMs stringify everything — they send `"95727067"` when your schema
expects `int`, or `'[{"id":1}]'` when you expect `list[dict]`. Pydantic
strict mode returns 422 and the agent fails.
Use these as `mode="before"` validators on every tool input schema.
Example:
from pydantic import BaseModel, field_validator
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list
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)
See `docs/known-issues.md#i-003` for the full story.
"""
from __future__ import annotations
import json
from typing import Any
def _coerce_int(v: Any) -> Any:
"""Accepts int or stringified int."""
if isinstance(v, str):
s = v.strip()
if s.lstrip("-").isdigit():
return int(s)
return v
def _coerce_float(v: Any) -> Any:
if isinstance(v, str):
try:
return float(v.strip())
except ValueError:
pass
return v
def _coerce_bool(v: Any) -> Any:
"""Accepts bool, 'true'/'false', '1'/'0', 'yes'/'no'."""
if isinstance(v, bool):
return v
if isinstance(v, str):
s = v.strip().lower()
if s in ("true", "1", "yes", "y"):
return True
if s in ("false", "0", "no", "n"):
return False
return v
def _coerce_list(v: Any) -> Any:
"""Accepts list, stringified JSON list, or single value (wrapped)."""
if isinstance(v, list):
return v
if isinstance(v, str):
s = v.strip()
if s.startswith("[") and s.endswith("]"):
try:
parsed = json.loads(s)
if isinstance(parsed, list):
return parsed
except json.JSONDecodeError:
pass
# Fallback: wrap single value
return [v]
return v
def _coerce_dict(v: Any) -> Any:
"""Accepts dict or stringified JSON dict."""
if isinstance(v, dict):
return v
if isinstance(v, str):
s = v.strip()
if s.startswith("{") and s.endswith("}"):
try:
parsed = json.loads(s)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return v

View File

@@ -0,0 +1,178 @@
"""Observable tool decorator.
Wraps the ADK `@tool` so every call emits a structured trace
(inputs, outputs, latency, side effects). See `docs/observability-pattern.md`.
Usage:
from wxo.tools.python._observable_tool import observable_tool
@observable_tool(name="reset_password", domain="ad")
def reset_password(username: str) -> dict:
# your logic
return {"temp_password": "xxx"}
The decorated function is still a valid ADK `@tool` — the decorator delegates.
The LLM sees only `result`. The full trace goes to the configured sink.
Sinks (controlled by env var TRACE_SINK):
- `sqlite` (default): writes to `traces.db` next to the process
- `http`: POST to $TRACE_SINK_URL (default http://web:8000/api/traces)
- `otlp`: OpenTelemetry OTLP gRPC to $OTEL_EXPORTER_OTLP_ENDPOINT
Set `_bypass_tracing=True` in the decorator to skip tracing for a specific
tool (rare — used for hot-path tools or debug).
"""
from __future__ import annotations
import functools
import json
import os
import sqlite3
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Callable
# === inline compat shim (NO eliminar — TRM lo necesita) ===
try:
from ibm_watsonx_orchestrate.agent_builder.tools import tool as _adk_tool
except ImportError:
def _adk_tool(*args, **kwargs):
def deco(f): return f
return deco if not args else deco(args[0])
# ============================================================
TRACE_SINK = os.environ.get("TRACE_SINK", "sqlite")
TRACE_SINK_URL = os.environ.get("TRACE_SINK_URL", "http://web:8000/api/traces")
TRACES_DB_PATH = os.environ.get("TRACES_DB_PATH", "/tmp/traces.db")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _emit_trace(trace: dict[str, Any]) -> None:
"""Send the trace to the configured sink. Failures must NEVER break the tool."""
try:
if TRACE_SINK == "sqlite":
_emit_sqlite(trace)
elif TRACE_SINK == "http":
_emit_http(trace)
elif TRACE_SINK == "otlp":
_emit_otlp(trace)
# else: silently drop (TRACE_SINK=off)
except Exception:
# Never let observability break the tool.
pass
def _emit_sqlite(trace: dict[str, Any]) -> None:
conn = sqlite3.connect(TRACES_DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS traces (
trace_id TEXT PRIMARY KEY,
tool TEXT,
domain TEXT,
agent_caller TEXT,
correlation_id TEXT,
started_at TEXT,
duration_ms INTEGER,
payload TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS ix_traces_started ON traces (started_at)")
conn.execute(
"INSERT OR REPLACE INTO traces VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
trace["trace_id"], trace["tool"], trace.get("domain"),
trace.get("agent_caller"), trace.get("correlation_id"),
trace["started_at"], trace["duration_ms"],
json.dumps(trace),
),
)
conn.commit()
conn.close()
def _emit_http(trace: dict[str, Any]) -> None:
import requests
requests.post(TRACE_SINK_URL, json=trace, timeout=2)
def _emit_otlp(trace: dict[str, Any]) -> None:
# Stub. Requires opentelemetry-sdk + exporter. Implement in v2.
pass
def observable_tool(
*,
name: str,
domain: str = "",
description: str | None = None,
_bypass_tracing: bool = False,
):
"""Decorator that wraps ADK `@tool` with structured tracing."""
def decorator(func: Callable) -> Callable:
# First wrap the function to capture traces.
@functools.wraps(func)
def wrapper(*args, **kwargs):
if _bypass_tracing:
return func(*args, **kwargs)
trace_id = f"{domain or 'tool'}-{name}-{uuid.uuid4().hex[:6]}"
started = time.perf_counter()
started_iso = _now_iso()
inputs = {}
try:
inputs = kwargs.copy()
# positional → harder; we capture the dict for now
if args:
inputs["_positional"] = [repr(a)[:200] for a in args]
except Exception:
pass
result = None
error = None
try:
result = func(*args, **kwargs)
except Exception as e:
error = repr(e)
raise
finally:
duration_ms = int((time.perf_counter() - started) * 1000)
trace = {
"trace_id": trace_id,
"tool": name,
"domain": domain,
"started_at": started_iso,
"duration_ms": duration_ms,
"inputs": inputs,
"result_preview": _preview(result),
"error": error,
"agent_caller": os.environ.get("WXO_CURRENT_AGENT", "unknown"),
"correlation_id": os.environ.get("WXO_CORRELATION_ID", ""),
}
_emit_trace(trace)
return result
# Then wrap with the ADK tool decorator so it's discoverable.
kwargs = {"name": name}
if description:
kwargs["description"] = description
return _adk_tool(**kwargs)(wrapper)
return decorator
def _preview(value: Any, max_len: int = 500) -> Any:
"""Truncate big values for trace storage."""
if value is None:
return None
try:
s = json.dumps(value, default=str)
if len(s) <= max_len:
return json.loads(s)
return {"_truncated": True, "preview": s[:max_len]}
except Exception:
return {"_repr": repr(value)[:max_len]}

View File

@@ -0,0 +1,136 @@
"""Template — Python tools wrapper.
Reemplazar REPLACE_* con los valores de tu caso. Una función por endpoint
mockeado o por acción atómica. Máx 10 funciones por archivo (regla A1 —
si necesitás más, partí en specialists).
Patrón:
- Cada función usa `@observable_tool` (NO `@tool` directo — regla T1).
- Cada función con input no-trivial declara un schema Pydantic con
coerción explícita (regla T2 — issue I-003).
- BASE_URL viene del env de la connection, nunca hardcoded (regla T4).
- Compat shim inline al inicio del archivo (regla T3 — issue I-010).
"""
from __future__ import annotations
# === inline compat shim (NO eliminar — TRM lo necesita) ===
try:
from ibm_watsonx_orchestrate.agent_builder.tools import tool as _adk_tool
except ImportError:
def _adk_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 typing import Optional
# Imports de helpers del template — ABSOLUTOS, no relativos (regla T3).
# Cuando este archivo se importe en el TRM, los _observability/_coercion
# tienen que estar en sys.path. El deploy script los inlinea o los
# distribuye junto.
try:
from wxo.tools.python._observable_tool import observable_tool
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list, _coerce_bool
except ImportError:
# Fallback si los helpers no están disponibles en el TRM:
# observable_tool degrada a @tool puro, coerción degrada a identidad.
def observable_tool(*, name, domain="", description=None, _bypass_tracing=False):
def deco(f):
return _adk_tool(name=name, description=description)(f) if description else _adk_tool(name=name)(f)
return deco
def _coerce_int(v): return int(v) if isinstance(v, str) and v.strip().isdigit() else v
def _coerce_list(v): return [v] if isinstance(v, str) else v
def _coerce_bool(v): return v
# BASE_URL viene de la connection. NUNCA hardcoded.
BASE_URL = os.environ.get("BASE_URL", "")
TIMEOUT_SEC = int(os.environ.get("TOOL_TIMEOUT_SEC", "10"))
# ─── Input schemas with coercion ────────────────────────────────────────────
class LookupInput(BaseModel):
username: str
class ExecuteInput(BaseModel):
target_id: int
options: list[str] = []
notify: bool = True
# Coerciones explícitas — el LLM stringifica todo (issue I-003)
_coerce_id = field_validator("target_id", mode="before")(_coerce_int)
_coerce_opts = field_validator("options", mode="before")(_coerce_list)
_coerce_notify = field_validator("notify", mode="before")(_coerce_bool)
# ─── Tools ──────────────────────────────────────────────────────────────────
@observable_tool(
name="REPLACE_lookup",
domain="REPLACE_domain",
description="REPLACE — busca un recurso por identificador y devuelve sus datos."
)
def REPLACE_lookup(username: str) -> dict:
"""REPLACE descripción de qué hace esta tool.
Args:
username: REPLACE qué es el username
Returns:
dict con campos: REPLACE qué campos devuelve
"""
resp = requests.post(
f"{BASE_URL}/users/lookup",
json={"username": username},
timeout=TIMEOUT_SEC,
)
resp.raise_for_status()
return resp.json()
@observable_tool(
name="REPLACE_execute",
domain="REPLACE_domain",
description="REPLACE — ejecuta la acción principal contra el sistema externo."
)
def REPLACE_execute(target_id: int, options: Optional[list[str]] = None, notify: bool = True) -> dict:
"""REPLACE descripción.
Args:
target_id: REPLACE
options: REPLACE
notify: REPLACE
Returns:
dict con: REPLACE
"""
payload = {
"target_id": target_id,
"options": options or [],
"notify": notify,
}
resp = requests.post(
f"{BASE_URL}/execute",
json=payload,
timeout=TIMEOUT_SEC,
)
resp.raise_for_status()
return resp.json()
@observable_tool(
name="REPLACE_get_status",
domain="REPLACE_domain",
description="REPLACE — devuelve el estado actual de un recurso."
)
def REPLACE_get_status(resource_id: str) -> dict:
"""REPLACE descripción."""
resp = requests.get(
f"{BASE_URL}/status/{resource_id}",
timeout=TIMEOUT_SEC,
)
resp.raise_for_status()
return resp.json()

View File

@@ -0,0 +1,2 @@
requests>=2.31.0
pydantic>=2.5.0