feat: v1 — boilerplate WxO + web
Boilerplate completo para construir soluciones agénticas sobre IBM watsonx Orchestrate (ADK 2.x) con una capa web encima. Destilado de cotemar-poc-n1 y dun-casos-prueba. Incluye: - 11 docs (best practices, architecture patterns, ADK cheatsheet, observability, tool authoring, deployment, RUNBOOK, DEPLOY_TO_NEW_WOX, known-issues con 16 errores reales y fix, eval strategy, INDEX) - 13 templates WxO (orchestrator/specialist/single-meta agents, 3 connections, KB + runbook, observable_tool decorator, coercion helpers, Python tools, OpenAPI spec, backend filter endpoint, webhook validator HMAC, MCP connection) - 6 scripts (deploy idempotente con fallback ADK, undeploy, reset, check-adk-version, new-specialist scaffold, eval-agents runner) - 4 eval pieces (linter de best practices, runner, smoke-test, direct backend probe) + scenario templates - 8 subagentes Claude (.claude/agents/) — wxo-architect, wxo-agent-author, wxo-tool-author, runbook-author, mock-builder, backend-tool-builder, eval-author, web-layer-builder - Skill bundle fit-wxo-bootstrap/ (SKILL.md + 3 templates) listo para copiar a ~/.claude/skills/ - Web layer default FastAPI+HTMX con vista timeline observable + endpoint receptor de trazas - Docker compose Coolify-ready (healthcheck wget -qO-, sin labels Traefik, network split internal:true) - CI Gitea workflow con lint + smoke Best practices enforced por evals/lint_wxo_yaml.py: - A1: máx 10 tools por agente - A3: orchestrator sin tools de remediación - A6: agente sin propósito (react sin tools ni KB) - T1: @observable_tool obligatorio, no @tool directo - T3: _compat shim inline en tools.py - D1: docker-compose sin wget --spider - I-005: OpenAPI con description per-operation - I-009: sin labels Traefik manuales Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
138
.claude/agents/backend-tool-builder.md
Normal file
138
.claude/agents/backend-tool-builder.md
Normal 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)
|
||||
101
.claude/agents/eval-author.md
Normal file
101
.claude/agents/eval-author.md
Normal 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)
|
||||
92
.claude/agents/mock-builder.md
Normal file
92
.claude/agents/mock-builder.md
Normal 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 '{"...":"..."}'
|
||||
```
|
||||
83
.claude/agents/runbook-author.md
Normal file
83
.claude/agents/runbook-author.md
Normal 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`)
|
||||
170
.claude/agents/web-layer-builder.md
Normal file
170
.claude/agents/web-layer-builder.md
Normal 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)
|
||||
76
.claude/agents/wxo-agent-author.md
Normal file
76
.claude/agents/wxo-agent-author.md
Normal 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.
|
||||
102
.claude/agents/wxo-architect.md
Normal file
102
.claude/agents/wxo-architect.md
Normal 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]
|
||||
- [ ] ...
|
||||
```
|
||||
94
.claude/agents/wxo-tool-author.md
Normal file
94
.claude/agents/wxo-tool-author.md
Normal 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
42
.env.example
Normal 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
36
.gitea/workflows/ci.yml
Normal 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
41
.gitignore
vendored
Normal 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
81
INSTRUCCIONES.md
Normal 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
112
README.md
Normal 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
18
docker-compose.local.yml
Normal 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
93
docker-compose.yml
Normal 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
218
docs/DEPLOY_TO_NEW_WOX.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Deploy to a New WxO Tenant — Cold Start Playbook
|
||||
|
||||
Cuando arrancás esta solución en un tenant WxO **completamente nuevo**
|
||||
(otro cliente, otro environment, fresh start), seguí este playbook
|
||||
literal. Es el camino más corto desde "tengo credenciales" a "demo
|
||||
funcionando".
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — Pre-requisitos
|
||||
|
||||
- [ ] IBM Cloud account del cliente con WxO instance provisionada
|
||||
- [ ] API key del tenant
|
||||
- [ ] Coolify (u otro host Docker) accesible
|
||||
- [ ] Dominio público con DNS apuntando al host
|
||||
- [ ] Acceso a Gitea/repo para clonar el código
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Provisionar el ADK localmente
|
||||
|
||||
```bash
|
||||
python3.12 -m venv .venv-wxo
|
||||
source .venv-wxo/bin/activate
|
||||
pip install --upgrade "ibm-watsonx-orchestrate==2.1.0"
|
||||
orchestrate --version # 2.1.x
|
||||
```
|
||||
|
||||
Verificá que el modelo esté disponible:
|
||||
|
||||
```bash
|
||||
orchestrate env add -n nuevo-tenant \
|
||||
--iam-url https://iam.cloud.ibm.com/identity/token \
|
||||
-u "$ORCHESTRATE_API_URL"
|
||||
orchestrate env activate nuevo-tenant
|
||||
|
||||
orchestrate models list | grep gpt-oss-120b
|
||||
```
|
||||
|
||||
Si no aparece `gpt-oss-120b`, editá los YAMLs en `wxo/agents/` y poné
|
||||
`meta-llama/llama-3-3-70b-instruct` (con la advertencia de I-002).
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Secretos
|
||||
|
||||
Crear `.env` desde `.env.example`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Llenar:
|
||||
- `ORCHESTRATE_API_URL`
|
||||
- `ORCHESTRATE_API_KEY`
|
||||
- `WATSONX_API_KEY` (si vas a usar Plan A)
|
||||
- `PUBLIC_HOST`
|
||||
- (Coolify) `COOLIFY_API_URL`, `COOLIFY_API_TOKEN`
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Deploy del backend / mocks
|
||||
|
||||
Si tu solución incluye un backend propio o mocks:
|
||||
|
||||
```bash
|
||||
# Coolify: crear app desde el git repo, configurar env vars, deploy
|
||||
# O localmente:
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Verificá que el `PUBLIC_HOST/health` responde 200:
|
||||
|
||||
```bash
|
||||
curl -sf "https://$PUBLIC_HOST/health"
|
||||
```
|
||||
|
||||
Si da 503 → Traefik exclude por healthcheck. Issue I-008.
|
||||
Si da default cert → labels manuales en compose. Issue I-009.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Deploy del stack WxO
|
||||
|
||||
```bash
|
||||
PUBLIC_HOST=mi-cliente.fitlabs.dev ./scripts/deploy-wxo.sh
|
||||
```
|
||||
|
||||
El script:
|
||||
1. Crea las connections (draft + live)
|
||||
2. Importa los tools
|
||||
3. Importa las KBs
|
||||
4. Importa los agentes en orden (specialists primero)
|
||||
5. Hace `deploy --env live` de cada uno
|
||||
6. Crea el canal webchat
|
||||
|
||||
Si falla a mitad de camino:
|
||||
- Re-corré el script (es idempotente)
|
||||
- Si persiste, ver `docs/known-issues.md`
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Capturar IDs
|
||||
|
||||
```bash
|
||||
orchestrate agents list
|
||||
```
|
||||
|
||||
Buscá tu orchestrator en el env `live`, capturá:
|
||||
- `agent_id` (UUID)
|
||||
- `environment_id` (UUID)
|
||||
|
||||
Ponelos en:
|
||||
- `web/.../templates/index.html` (o donde esté tu landing): `agentId` y `agentEnvironmentId`
|
||||
- `.env`: `WXO_AGENT_ID`
|
||||
- Coolify panel: `WXO_AGENT_ID` env var
|
||||
|
||||
Commit + redeploy de la landing.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Embed Security OFF (manual, UI)
|
||||
|
||||
**Esto es manual. No hay API.**
|
||||
|
||||
1. WxO Console → Settings
|
||||
2. Embed Security → **Off**
|
||||
3. Confirmar
|
||||
|
||||
Sin esto, el widget de chat queda en "Cargando..." para siempre.
|
||||
Issue I-007.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Smoke test
|
||||
|
||||
```bash
|
||||
./evals/smoke-test.sh
|
||||
```
|
||||
|
||||
Si pasa, validá manualmente:
|
||||
|
||||
```bash
|
||||
# Abrir el chat
|
||||
open "https://$PUBLIC_HOST/"
|
||||
```
|
||||
|
||||
Tirale al chat tu primer scenario. Verifica que:
|
||||
1. El agente responde
|
||||
2. El tool se llama (mirá `docker logs <web>` para ver las trazas)
|
||||
3. El resultado final aparece en la UI (kanban / control plane / lo que sea)
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Direct backend probe (diagnóstico)
|
||||
|
||||
Si el chat funciona pero ves errores, ejecutá:
|
||||
|
||||
```bash
|
||||
./evals/direct-backend-probe.sh
|
||||
```
|
||||
|
||||
Esto llama tu backend directamente con curl + el token de WxO. Sirve
|
||||
para aislar:
|
||||
- **200 OK con error de lógica** → el backend está bien, el problema
|
||||
está en la conversación con el agente
|
||||
- **401/403** → problema de auth / connection / credentials
|
||||
- **502/504** → problema de red entre WxO y tu backend (firewall, DNS,
|
||||
Cloud Run cold start, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Activar Plan A (webhook → Runs API)
|
||||
|
||||
Si tu solución tiene auto-trigger desde un sistema externo:
|
||||
|
||||
1. Capturá el `WXO_AGENT_ID` (paso 5)
|
||||
2. Asegurate que `WATSONX_API_KEY` está en `.env`
|
||||
3. Re-deployá el backend/mocks (`docker compose up -d --force-recreate`)
|
||||
4. Probá disparando un evento desde el sistema externo
|
||||
5. Mirá los logs: debería ver `→ POST /v1/orchestrate/runs ... 200`
|
||||
|
||||
Issue I-011 si los env vars no se actualizan: `restart` no relee `.env`,
|
||||
usar `--force-recreate`.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Set up monitoring básico
|
||||
|
||||
- Coolify alerta por email si el container reinicia >3 veces en 5 min
|
||||
- Healthcheck endpoint custom si necesitás
|
||||
- Logs accesibles desde Coolify panel
|
||||
- `./evals/smoke-test.sh` corriendo nightly desde CI
|
||||
|
||||
---
|
||||
|
||||
## Checklist final
|
||||
|
||||
- [ ] Step 0 — Pre-reqs listos
|
||||
- [ ] Step 1 — ADK provisionado + modelo disponible
|
||||
- [ ] Step 2 — `.env` completo
|
||||
- [ ] Step 3 — Backend/mocks responden 200 en /health
|
||||
- [ ] Step 4 — `./scripts/deploy-wxo.sh` sin errores
|
||||
- [ ] Step 5 — IDs capturados y propagados
|
||||
- [ ] Step 6 — Embed Security OFF
|
||||
- [ ] Step 7 — Smoke test pasa
|
||||
- [ ] Step 8 — Direct probe responde 200
|
||||
- [ ] Step 9 — (si aplica) Plan A funciona
|
||||
- [ ] Step 10 — Monitoring básico
|
||||
|
||||
Cuando los 10 estén ✅, estás listo para demo.
|
||||
|
||||
---
|
||||
|
||||
## Si algo se rompió en producción
|
||||
|
||||
Ver `docs/RUNBOOK.md` para recovery procedures.
|
||||
35
docs/INDEX.md
Normal file
35
docs/INDEX.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Index — Doc de docs
|
||||
|
||||
Mapa de toda la documentación del boilerplate. Si no sabés por dónde empezar, leé en este orden.
|
||||
|
||||
## Para empezar (lectura obligatoria, 15 min)
|
||||
|
||||
1. [`../README.md`](../README.md) — Filosofía del template, qué hay acá
|
||||
2. [`../INSTRUCCIONES.md`](../INSTRUCCIONES.md) — Quickstart de 5 pasos
|
||||
3. [`wxo-best-practices.md`](wxo-best-practices.md) — Las 30+ reglas que el linter enforcea
|
||||
|
||||
## Para diseñar (al arrancar un caso nuevo)
|
||||
|
||||
4. [`architecture-patterns.md`](architecture-patterns.md) — Árbol de decisión del `wxo-architect`
|
||||
5. [`tool-authoring-guide.md`](tool-authoring-guide.md) — Python vs OpenAPI vs MCP
|
||||
6. [`eval-strategy.md`](eval-strategy.md) — Qué validar y cómo
|
||||
|
||||
## Para implementar
|
||||
|
||||
7. [`adk-2x-cheatsheet.md`](adk-2x-cheatsheet.md) — Comandos ADK, YAML schemas, gotchas
|
||||
8. [`observability-pattern.md`](observability-pattern.md) — Contrato `@observable_tool` + `write_audit`
|
||||
|
||||
## Para desplegar
|
||||
|
||||
9. [`deployment-guide.md`](deployment-guide.md) — Deploy genérico parametrizable
|
||||
10. [`DEPLOY_TO_NEW_WOX.md`](DEPLOY_TO_NEW_WOX.md) — Cold start playbook
|
||||
|
||||
## Para operar
|
||||
|
||||
11. [`RUNBOOK.md`](RUNBOOK.md) — URLs, monitoring, recovery, rotation
|
||||
12. [`known-issues.md`](known-issues.md) — Errores con su fix
|
||||
|
||||
## Apéndices
|
||||
|
||||
- [`../skill/fit-wxo-bootstrap/SKILL.md`](../skill/fit-wxo-bootstrap/SKILL.md) — Cómo funciona la skill
|
||||
- [`../.claude/agents/`](../.claude/agents/) — Los 7 subagentes Claude del template
|
||||
178
docs/RUNBOOK.md
Normal file
178
docs/RUNBOOK.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# RUNBOOK — Operaciones
|
||||
|
||||
Cómo monitorear, recuperar, rotar y troubleshootear una solución
|
||||
desplegada con este template.
|
||||
|
||||
## URLs y endpoints clave
|
||||
|
||||
| URL | Para qué |
|
||||
|---|---|
|
||||
| `https://$PUBLIC_HOST/` | Landing con chat embed |
|
||||
| `https://$PUBLIC_HOST/web/` | App principal (UI) |
|
||||
| `https://$PUBLIC_HOST/health` | Healthcheck (200 = todo OK) |
|
||||
| `https://$PUBLIC_HOST/api/traces` | Vista timeline de tool calls (admin) |
|
||||
| WxO Console | Settings, agents, channels |
|
||||
| Coolify panel | Deploy, logs, env vars |
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Healthcheck simple
|
||||
```bash
|
||||
curl -sf "https://$PUBLIC_HOST/health" && echo OK || echo FAIL
|
||||
```
|
||||
|
||||
### Smoke test nightly
|
||||
Configurar CI/cron que corra cada noche:
|
||||
```bash
|
||||
./evals/smoke-test.sh
|
||||
```
|
||||
Si falla, alerta por email/Slack.
|
||||
|
||||
### Logs en tiempo real
|
||||
```bash
|
||||
# Local
|
||||
docker compose logs -f --tail=100
|
||||
|
||||
# Coolify
|
||||
# Panel → app → Logs tab
|
||||
```
|
||||
|
||||
Buscar:
|
||||
- `ERROR` o `Exception` en stdout
|
||||
- `recursion_limit` (issue I-001)
|
||||
- `kid not found` (issue I-004)
|
||||
- `422 Unprocessable Entity` (issue I-003)
|
||||
|
||||
## Recovery procedures
|
||||
|
||||
### El chat embed quedó en "Cargando..."
|
||||
1. Settings → Embed Security debe estar **Off**. Issue I-007.
|
||||
2. Verificar que el `agentId` y `agentEnvironmentId` del HTML
|
||||
coincidan con `orchestrate agents list --env live`.
|
||||
3. Browser console: buscar errores CORS o 401.
|
||||
|
||||
### Casos atascados en `executing` (issue I-012)
|
||||
El watchdog del backend los promueve a `paused` automáticamente
|
||||
después de 90s. Si no pasa:
|
||||
1. Ver logs: `grep "case_id=XXXX"`
|
||||
2. Si Orchestrate jamás llamó al callback, el run murió: re-trigger desde la UI
|
||||
3. Si el callback llegó pero la DB no actualizó, ver issue I-013
|
||||
|
||||
### El agente printea tool calls como texto (issue I-002)
|
||||
1. Verificar `llm:` en el YAML del agente = `groq/openai/gpt-oss-120b`
|
||||
2. Verificar que REGLA #0 está en el prompt
|
||||
3. Si persiste con gpt-oss-120b → reportar a IBM, es regresión del modelo
|
||||
|
||||
### Pydantic 422 en tool calls (issue I-003)
|
||||
1. Ver el payload exacto que el LLM mandó (logs del backend)
|
||||
2. Si stringificó algo que debería ser int/list → falta `@field_validator(mode="before")`
|
||||
3. Agregar el validator, redeployar el backend
|
||||
|
||||
### Deploy WxO falla con `kid not found` (issue I-004)
|
||||
```bash
|
||||
# Re-configurar la connection en live
|
||||
orchestrate connections configure -a <app_id> --env live --type team --kind <kind>
|
||||
orchestrate connections set-credentials -a <app_id> --env live -e "BASE_URL=..."
|
||||
|
||||
# Re-deployar agente
|
||||
orchestrate agents deploy --name <agent> --env live
|
||||
```
|
||||
|
||||
### Coolify dio 503 después de redeploy (issue I-008)
|
||||
1. `docker logs <container>` → buscar healthcheck failures
|
||||
2. Cambiar `wget --spider` a `wget -qO-` en el Dockerfile
|
||||
3. Rebuild
|
||||
|
||||
### Backend OK pero WxO no llega (issue I-008/I-009)
|
||||
```bash
|
||||
# Probar desde fuera
|
||||
curl -sf "https://$PUBLIC_HOST/api/v1/health"
|
||||
|
||||
# Si 200 OK pero WxO da timeout → firewall/network
|
||||
# Si 503 → Traefik/healthcheck
|
||||
# Si TRAEFIK DEFAULT CERT → labels manuales, ver I-009
|
||||
```
|
||||
|
||||
## Rotación de secretos
|
||||
|
||||
### API key del tenant WxO
|
||||
1. WxO Console → IAM → Generate new API key
|
||||
2. Actualizar `.env`: `ORCHESTRATE_API_KEY=<nuevo>`
|
||||
3. `docker compose up -d --force-recreate` (NO `restart`, issue I-011)
|
||||
4. `orchestrate env activate <env-name>` con el nuevo key
|
||||
|
||||
### Credenciales de connection
|
||||
```bash
|
||||
orchestrate connections set-credentials -a <app_id> --env draft -e "BASE_URL=..." -e "API_KEY=<nuevo>"
|
||||
orchestrate connections set-credentials -a <app_id> --env live -e "BASE_URL=..." -e "API_KEY=<nuevo>"
|
||||
```
|
||||
|
||||
No hay que re-deployar agentes — las connections se resuelven en runtime.
|
||||
|
||||
### Webhook signing secret
|
||||
1. Generar nuevo secret
|
||||
2. Actualizar `.env` + Coolify env vars: `WEBHOOK_SECRET=<nuevo>`
|
||||
3. `--force-recreate`
|
||||
4. Si el sistema externo usa el viejo secret, coordinar el switch
|
||||
|
||||
## Backup y restore
|
||||
|
||||
### Backup
|
||||
- **Código + YAMLs**: vive en Gitea. Backup automático del repo.
|
||||
- **Backend DB**: `docker exec <db-container> pg_dump > backup.sql` (o equivalente)
|
||||
- **Audit logs**: parte del backup de la DB
|
||||
- **Trazas SQLite**: `cp web/data/traces.db backup/`
|
||||
- **WxO config**: los YAMLs del repo. `orchestrate agents list` te dice qué está en live.
|
||||
|
||||
### Restore
|
||||
- **Código**: `git clone` y `./scripts/deploy-wxo.sh`
|
||||
- **DB**: `pg_restore < backup.sql`
|
||||
- **Traces**: si las perdés, no es crítico (son observabilidad histórica)
|
||||
|
||||
### Disaster recovery completo
|
||||
1. Clonar repo en nuevo host
|
||||
2. `.env` con credenciales del tenant (las que ya tenías guardadas)
|
||||
3. `./scripts/deploy-wxo.sh` — recrea todo en WxO desde los YAMLs
|
||||
4. `docker compose up -d` — levanta backend/web
|
||||
5. Restore DB si necesario
|
||||
6. Capturar nuevos IDs, propagar
|
||||
7. Smoke test
|
||||
|
||||
Tiempo objetivo: <30 min.
|
||||
|
||||
## Troubleshooting checklist
|
||||
|
||||
Cuando algo se rompe y no sabés por dónde empezar:
|
||||
|
||||
1. [ ] `curl -sf $PUBLIC_HOST/health` — ¿el servicio responde?
|
||||
2. [ ] `docker ps` — ¿los containers están healthy?
|
||||
3. [ ] `docker logs <container> --tail=200` — ¿hay errores recientes?
|
||||
4. [ ] `orchestrate agents list` — ¿el agente está en live?
|
||||
5. [ ] `./evals/direct-backend-probe.sh` — ¿la auth funciona?
|
||||
6. [ ] `./evals/smoke-test.sh` — ¿el flujo end-to-end?
|
||||
7. [ ] WxO Console → Runs → últimos runs — ¿qué error tienen?
|
||||
8. [ ] `docs/known-issues.md` — ¿coincide con algún I-XXX?
|
||||
|
||||
Si después de los 8 sigue roto, escalalo. Pero el 90% de los problemas
|
||||
están en uno de los 8.
|
||||
|
||||
## Versión / actualización del ADK
|
||||
|
||||
```bash
|
||||
./scripts/check-adk-version.sh
|
||||
```
|
||||
|
||||
Si hay nueva versión:
|
||||
1. **NO actualizar directamente.**
|
||||
2. Crear branch `chore/adk-X.Y.Z`
|
||||
3. `pip install ibm-watsonx-orchestrate==X.Y.Z`
|
||||
4. Re-deployar a un env de staging
|
||||
5. Correr todas las evals
|
||||
6. Si pasan, PR + merge + deploy a prod
|
||||
7. Actualizar el pin en `requirements.txt` + `check-adk-version.sh`
|
||||
|
||||
## Cuando todo lo demás falla
|
||||
|
||||
Ver `docs/known-issues.md` y `docs/adk-2x-cheatsheet.md`. Si tampoco
|
||||
está ahí, abrir issue en el repo del template para que la próxima
|
||||
persona no lo sufra.
|
||||
262
docs/adk-2x-cheatsheet.md
Normal file
262
docs/adk-2x-cheatsheet.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# ADK 2.x Cheatsheet
|
||||
|
||||
Comandos, schemas YAML, gotchas. **Versión pineada del template: `ibm-watsonx-orchestrate==2.1.0`**.
|
||||
|
||||
> `./scripts/check-adk-version.sh` te avisa si hay versión nueva en PyPI.
|
||||
|
||||
## Setup del ADK
|
||||
|
||||
```bash
|
||||
python3.12 -m venv .venv-wxo
|
||||
source .venv-wxo/bin/activate
|
||||
pip install --upgrade "ibm-watsonx-orchestrate==2.1.0"
|
||||
orchestrate --version # debe imprimir 2.1.x
|
||||
```
|
||||
|
||||
## Environments
|
||||
|
||||
```bash
|
||||
# Registrar tenant
|
||||
orchestrate env add -n mi-tenant \
|
||||
--iam-url https://iam.cloud.ibm.com/identity/token \
|
||||
-u "$ORCHESTRATE_API_URL"
|
||||
|
||||
# Activar (pide API key, lo guarda en keyring)
|
||||
orchestrate env activate mi-tenant
|
||||
|
||||
# Listar
|
||||
orchestrate env list
|
||||
|
||||
# Borrar
|
||||
orchestrate env remove -n mi-tenant
|
||||
```
|
||||
|
||||
## Modelos disponibles
|
||||
|
||||
```bash
|
||||
orchestrate models list # todos
|
||||
orchestrate models list | grep gpt-oss # filtro
|
||||
```
|
||||
|
||||
**Preferred para tool-calling:** `groq/openai/gpt-oss-120b`
|
||||
**Fallback:** `meta-llama/llama-3-3-70b-instruct` (con caveat — ver `known-issues.md`)
|
||||
|
||||
## Conexiones
|
||||
|
||||
```bash
|
||||
# Crear
|
||||
orchestrate connections add -a mi_app
|
||||
|
||||
# Configurar tipo (key_value | api_key | bearer | oauth2)
|
||||
# IMPORTANTE: hacerlo en draft Y live
|
||||
orchestrate connections configure -a mi_app --env draft --type team --kind key_value
|
||||
orchestrate connections configure -a mi_app --env live --type team --kind key_value
|
||||
|
||||
# Credenciales
|
||||
orchestrate connections set-credentials -a mi_app --env draft -e "BASE_URL=https://..."
|
||||
orchestrate connections set-credentials -a mi_app --env live -e "BASE_URL=https://..."
|
||||
|
||||
# Para API key con header custom (estilo Dun):
|
||||
orchestrate connections set-credentials -a mi_app --env draft \
|
||||
-e "X_API_KEY=secreto" -e "API_KEY_HEADER=X-Orchestrate-Token"
|
||||
|
||||
# Listar
|
||||
orchestrate connections list
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### Python `@tool`
|
||||
|
||||
```bash
|
||||
orchestrate tools import -k python \
|
||||
-f wxo/tools/python/mi_tool.py \
|
||||
-r wxo/tools/python/requirements.txt \
|
||||
--app-id mi_app
|
||||
```
|
||||
|
||||
Cada función decorada con `@tool(...)` se descubre y registra. Si tu
|
||||
archivo tiene 6 funciones decoradas → 6 tools.
|
||||
|
||||
### OpenAPI
|
||||
|
||||
```bash
|
||||
orchestrate tools import -k openapi \
|
||||
-f wxo/tools/openapi/mi_spec.yaml \
|
||||
--app-id mi_app
|
||||
```
|
||||
|
||||
Pre-requisitos del spec:
|
||||
- OpenAPI 3.0 o 3.1
|
||||
- `description` per-operation (no solo `summary`)
|
||||
- `security` per-operation (no global)
|
||||
- `servers[0].url` apuntando al host correcto (patch en deploy time)
|
||||
|
||||
### MCP
|
||||
|
||||
```bash
|
||||
orchestrate connections add -a mi_mcp_app
|
||||
orchestrate connections configure -a mi_mcp_app --env draft --type team --kind mcp
|
||||
orchestrate connections set-credentials -a mi_mcp_app --env draft \
|
||||
-e "MCP_SERVER_URL=https://mi-mcp-server.example.com"
|
||||
```
|
||||
|
||||
ADK descubre las tools del MCP server automáticamente.
|
||||
|
||||
## Knowledge bases
|
||||
|
||||
```bash
|
||||
orchestrate knowledge-bases import -f wxo/knowledge_base/mi_kb.kb.yaml
|
||||
orchestrate knowledge-bases list
|
||||
```
|
||||
|
||||
Schema YAML mínimo:
|
||||
```yaml
|
||||
spec_version: v1
|
||||
kind: knowledge_base
|
||||
name: mi_kb
|
||||
description: "..."
|
||||
documents:
|
||||
- "runbooks/runbook-01.txt"
|
||||
- "runbooks/runbook-02.txt"
|
||||
embeddings:
|
||||
model: ibm/slate-125m-english-rtrvr
|
||||
```
|
||||
|
||||
Los paths de `documents` son relativos al directorio del YAML.
|
||||
|
||||
## Agentes
|
||||
|
||||
```bash
|
||||
# Import (al draft env)
|
||||
orchestrate agents import -f wxo/agents/mi_agente.agent.yaml
|
||||
|
||||
# Deploy a live
|
||||
orchestrate agents deploy --name mi_agente --env live
|
||||
|
||||
# Listar
|
||||
orchestrate agents list
|
||||
|
||||
# Test con fixture
|
||||
orchestrate agents test mi_agente --input-file evals/scenarios/test1.json
|
||||
```
|
||||
|
||||
Schema YAML mínimo de un agente:
|
||||
```yaml
|
||||
spec_version: v1
|
||||
kind: native
|
||||
name: mi_agente
|
||||
display_name: "Mi Agente"
|
||||
description: "..."
|
||||
style: react
|
||||
llm: groq/openai/gpt-oss-120b
|
||||
instructions: |
|
||||
Eres ...
|
||||
collaborators: []
|
||||
tools: []
|
||||
knowledge_base: []
|
||||
starter_prompts:
|
||||
is_default_prompts: true
|
||||
prompts: []
|
||||
```
|
||||
|
||||
## Canales
|
||||
|
||||
```bash
|
||||
# Web chat
|
||||
orchestrate channels create webchat \
|
||||
--agent mi_agente \
|
||||
--name "Mi Agente - Web"
|
||||
|
||||
# Listar
|
||||
orchestrate channels list
|
||||
```
|
||||
|
||||
El comando imprime el snippet `<script>` con `agentId` y
|
||||
`agentEnvironmentId`. Anotalos.
|
||||
|
||||
**Paso manual obligatorio después:**
|
||||
> WxO Console → Settings → Embed Security → **Off**
|
||||
|
||||
## Runs API (Plan A — webhook to agent)
|
||||
|
||||
Para que un sistema externo dispare al agente sin pasar por chat:
|
||||
|
||||
```bash
|
||||
# 1. Conseguir IAM bearer token
|
||||
curl -s -X POST "https://iam.cloud.ibm.com/identity/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=urn:ibm:params:oauth:grant-type:apikey&apikey=$ORCHESTRATE_API_KEY" \
|
||||
| jq -r .access_token
|
||||
|
||||
# 2. POST a /v1/orchestrate/runs
|
||||
curl -X POST "$ORCHESTRATE_API_URL/v1/orchestrate/runs" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"agent_id": "<uuid-del-agente-en-live>",
|
||||
"message": "Hubo un evento X en el servicio Y..."
|
||||
}'
|
||||
```
|
||||
|
||||
Ver `cotemar-poc-n1/mocks/dynatrace/webhook.py` para implementación
|
||||
completa (incluye refresh del token, error handling, retry).
|
||||
|
||||
## Webhooks lifecycle (run completion)
|
||||
|
||||
WxO puede llamar a tu backend cuando un run termina. Validá la firma:
|
||||
|
||||
```python
|
||||
# Headers a recibir:
|
||||
# X-Orchestrate-Timestamp
|
||||
# X-Orchestrate-Nonce
|
||||
# X-Orchestrate-Signature
|
||||
|
||||
# HMAC-SHA256(secret, f"{timestamp}.{nonce}.{body}")
|
||||
# Tolerancia 300s sobre timestamp
|
||||
# 408 si stale, 401 si firma mala
|
||||
```
|
||||
|
||||
Ver `wxo/tools/openapi/_webhook_validator.py` template.
|
||||
|
||||
## Evaluaciones (CLI nativa)
|
||||
|
||||
```bash
|
||||
# Test individual
|
||||
orchestrate agents test mi_agente --input-file scenario.json
|
||||
|
||||
# Suite (un .json por scenario)
|
||||
for f in evals/scenarios/*.json; do
|
||||
orchestrate agents test mi_agente --input-file "$f"
|
||||
done
|
||||
```
|
||||
|
||||
Output: respuesta del agente + trace de tool calls.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
orchestrate agents remove --name mi_agente
|
||||
orchestrate tools remove --name mi_tool
|
||||
orchestrate knowledge-bases remove --name mi_kb
|
||||
orchestrate connections remove -a mi_app
|
||||
orchestrate channels delete --id <channel-id>
|
||||
```
|
||||
|
||||
El template trae `./scripts/reset-wxo.sh` que hace todo en orden.
|
||||
|
||||
## Gotchas mortales (los que descubrimos pagando)
|
||||
|
||||
| Síntoma | Causa | Fix |
|
||||
|---|---|---|
|
||||
| `kid not found` al deployear a live | Connection no existe en live | `connections configure` en `draft` Y `live` |
|
||||
| `No description provided` al importar OpenAPI | Falta `description` per-operation | Forzar `description = summary or "..."` |
|
||||
| `recursion_limit reached` en runs | >2 tool calls en flow ReAct | Patrón meta-tool |
|
||||
| Agent printea tool calls como texto | Llama 3.3 70B drift después del turno 3 | Cambiar a `gpt-oss-120b` + REGLA #0 en prompt |
|
||||
| Pydantic 422 en tool input | LLM mandó `"123"` cuando schema espera `int` | `@field_validator(mode="before")` con coerción |
|
||||
| `ModuleNotFoundError: wxo.tools._compat` | TRM importa el archivo standalone | Inlinear el shim en cada `tools.py` |
|
||||
| Embed widget queda en "Cargando..." | Embed Security ON en WxO | Settings → Embed Security → Off |
|
||||
| Coolify Traefik 503 | Healthcheck `wget --spider` falla | Usar `wget -qO-` o `curl -sf` |
|
||||
| Traefik default cert en vez de Let's Encrypt | Labels Traefik manuales en compose | Solo `traefik.enable=true` |
|
||||
|
||||
Ver `known-issues.md` para el detalle completo de cada uno.
|
||||
192
docs/architecture-patterns.md
Normal file
192
docs/architecture-patterns.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Architecture Patterns
|
||||
|
||||
Árbol de decisión que usa el subagente `wxo-architect` cuando arrancás un
|
||||
caso nuevo. Te llevo pregunta por pregunta.
|
||||
|
||||
---
|
||||
|
||||
## Pregunta 1 — ¿Cuántos dominios distintos toca el caso?
|
||||
|
||||
Un "dominio" = una superficie funcional bien delimitada (identidad,
|
||||
operaciones, RRHH, finanzas, CRM, etc.).
|
||||
|
||||
- **0–1 dominio** → 1 agente solo. Salí del árbol con la **Topología Single**.
|
||||
- **2–5 dominios** → N specialists + 1 orchestrator. **Topología Multi-Specialist** (estilo Cotemar).
|
||||
- **6+ dominios** → 2 capas: sub-orchestrators temáticos. **Topología Multi-Capa**.
|
||||
|
||||
---
|
||||
|
||||
## Pregunta 2 — ¿El flujo es lineal o ramificado?
|
||||
|
||||
- **Lineal y conocido** (caso entra → A → B → C → D → resultado, siempre el mismo orden): aplicá **Patrón Meta-Tool** (estilo Dun). Una sola tool `run_full_case` que el backend orquesta internamente. El agente invoca esa tool y listo.
|
||||
- **Ramificado** (el agente decide qué hacer según el input): N tools individuales, agente razona. Cuidado con `recursion_limit=30` (ver `known-issues.md`).
|
||||
- **Mixto**: parte lineal en meta-tool, parte ramificada en tools individuales. Es lo más común en casos reales.
|
||||
|
||||
---
|
||||
|
||||
## Pregunta 3 — ¿Cada agente necesita razonar sobre procedimientos escritos?
|
||||
|
||||
Para cada agente, preguntate: "¿el LLM necesita citar un runbook para
|
||||
decidir bien?"
|
||||
|
||||
- **Sí** (procedural): este agente tiene **KB con runbooks**. Estilo
|
||||
Cotemar AD specialist.
|
||||
- **No** (API-driven): este agente **no tiene KB**. El system prompt
|
||||
describe qué hace, las tools describen las acciones disponibles.
|
||||
Estilo Dun QA Studio.
|
||||
- **Mixto**: KB para el "qué" + tools para el "cómo". Solo cuando los
|
||||
procedimientos son largos y variables.
|
||||
|
||||
---
|
||||
|
||||
## Pregunta 4 — ¿Qué tipo de tools va a tener cada agente?
|
||||
|
||||
Por cada dominio, elegí el tipo de tool:
|
||||
|
||||
### Opción A — OpenAPI (preferido si tenés backend propio)
|
||||
|
||||
**Cuándo:** tu solución incluye un backend (FastAPI, Express, lo que sea)
|
||||
que ya define endpoints REST.
|
||||
|
||||
**Pros:**
|
||||
- Una sola fuente de verdad (el spec)
|
||||
- ADK importa todo en un comando
|
||||
- Cambios al backend = re-import del spec, no toques YAML de tool
|
||||
|
||||
**Pattern del template (estilo Dun):**
|
||||
- Backend expone `/orchestrate-tools-spec.json` (endpoint filtrado)
|
||||
- Filtra el openapi.json a una allowlist `PUBLIC_TOOLS`
|
||||
- Fuerza `description` y `security` per-operation
|
||||
- Patch del `servers[0].url` en deploy time según env
|
||||
|
||||
Ver `wxo/tools/openapi/_backend_filter_endpoint.py` para el template.
|
||||
|
||||
### Opción B — Python `@tool` (preferido para mocks/PoC)
|
||||
|
||||
**Cuándo:** estás prototipando, mocks externos, lógica simple por tool,
|
||||
sin backend que valga la pena mantener.
|
||||
|
||||
**Pros:**
|
||||
- Rápido de escribir, una función = una tool
|
||||
- Fácil de testear con pytest
|
||||
- Bajo overhead
|
||||
|
||||
**Pattern del template (estilo Cotemar):**
|
||||
- Una función `@observable_tool(name="x")` por endpoint mockeado
|
||||
- Lee `BASE_URL` del environment de su connection
|
||||
- `requests.post(...)` y devuelve dict
|
||||
- `_compat.py` inline al inicio del archivo
|
||||
|
||||
Ver `wxo/tools/python/_template_tools.py`.
|
||||
|
||||
### Opción C — MCP (preferido si el sistema lo expone)
|
||||
|
||||
**Cuándo:** el sistema externo (HubSpot, Outline, una DB con MCP server,
|
||||
GitHub vía MCP, etc.) ya expone un MCP server.
|
||||
|
||||
**Pros:**
|
||||
- Trae types + permisos del sistema
|
||||
- Mantenido por el vendor
|
||||
- Una sola connection MCP da acceso a todas las tools del server
|
||||
|
||||
**Pattern del template:**
|
||||
- Una `connection.yaml` de tipo MCP apuntando al server
|
||||
- ADK descubre las tools y las hace disponibles al agente
|
||||
- Ver `wxo/tools/mcp/_template_mcp_connection.yaml`
|
||||
|
||||
### Decisión
|
||||
|
||||
| Situación | Recomendación |
|
||||
|---|---|
|
||||
| Es PoC, mocks externos | Python @tool |
|
||||
| Tengo backend propio (FastAPI/Express) | OpenAPI |
|
||||
| Conecto a SaaS con MCP server | MCP |
|
||||
| Conecto a SaaS sin MCP server | OpenAPI (escribís el spec a mano) o Python |
|
||||
| Mezcla | Cada agente puede usar más de uno |
|
||||
|
||||
---
|
||||
|
||||
## Pregunta 5 — ¿Web layer: cuál stack?
|
||||
|
||||
Dos happy-paths probados:
|
||||
|
||||
### Stack A — FastAPI + HTMX + Jinja (default)
|
||||
**Cuándo:** demos, PoCs, control planes, kanbans, paneles operativos
|
||||
**Pros:** SSR, sin build step, polling con `hx-trigger` es trivial,
|
||||
una sola persona la mantiene
|
||||
**Contras:** menos interactivo, menos componibilidad
|
||||
**Origen:** estilo Cotemar (UNUS Kanban + Control Plane)
|
||||
|
||||
### Stack B — FastAPI + React + Vite + Tailwind + zustand
|
||||
**Cuándo:** app productiva con varios usuarios, mucha interacción, UI rica
|
||||
**Pros:** ecosistema React, lazy loading, state management decente
|
||||
**Contras:** build step, más superficie de mantenimiento
|
||||
**Origen:** estilo Dun (QA Studio)
|
||||
|
||||
### Decisión
|
||||
- **Demo / interno / PoC** → Stack A
|
||||
- **Producción / multi-usuario / app rica** → Stack B
|
||||
- **Híbrido** → Stack A para dashboards, Stack B para la app principal,
|
||||
unidos por reverse proxy
|
||||
|
||||
El template trae Stack A en `web/_default_fastapi_htmx/`. Stack B viene
|
||||
documentado pero no implementado (el subagente Claude `web-layer-builder`
|
||||
te lo arma según el caso).
|
||||
|
||||
---
|
||||
|
||||
## Pregunta 6 — ¿Deploy: dónde corre esto?
|
||||
|
||||
Tres opciones soportadas:
|
||||
|
||||
| Target | Para qué | Ver |
|
||||
|---|---|---|
|
||||
| **Coolify** (FIT default) | Demos + PoCs productivos en `*.fitlabs.dev` | `docs/deployment-guide.md` |
|
||||
| **Docker compose local** | Dev / testing | `docs/INSTRUCCIONES.md` |
|
||||
| **K8s / Cloud Run / otro** | Producción del cliente | `docs/deployment-guide.md` § "Other targets" |
|
||||
|
||||
---
|
||||
|
||||
## Topologías resultantes
|
||||
|
||||
### Topología Single (1 agente)
|
||||
```
|
||||
[user] → [agente único] ──tools──→ [sistema externo]
|
||||
└──KB──→ [opcional]
|
||||
```
|
||||
|
||||
### Topología Multi-Specialist (Cotemar pattern)
|
||||
```
|
||||
┌──→ ad_specialist ──tools──→ AD
|
||||
[user] → [N1] ────┼──→ ops_specialist ──tools──→ Ops
|
||||
(orch) └──→ rrhh_specialist ──tools──→ HR
|
||||
│
|
||||
└─escalate→ runbook 03 (KB propia)
|
||||
```
|
||||
|
||||
### Topología Meta-Tool (Dun pattern)
|
||||
```
|
||||
[user] → [agente único] ──run_full_case(id)──→ [backend orquesta:
|
||||
step 1, step 2, ...
|
||||
write_audit cada paso]
|
||||
```
|
||||
|
||||
### Topología Multi-Capa (>5 dominios)
|
||||
```
|
||||
┌─→ [sub-orch identidad] ─→ ad, hr, vendors
|
||||
[user] → [super-orch] ────┤
|
||||
├─→ [sub-orch ops] ─→ dynatrace, k8s, db
|
||||
└─→ [sub-orch finanzas] ─→ sap, billing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cuando dudes
|
||||
|
||||
- **¿1 agente o varios?** → Si los dominios son disjuntos, varios. Si todo es del mismo dominio, uno.
|
||||
- **¿KB o no?** → Si hay procedimiento escrito que el LLM debe seguir literal, sí. Si no, no.
|
||||
- **¿OpenAPI o Python?** → ¿Tenés backend? OpenAPI. ¿Mocks? Python.
|
||||
- **¿Meta-tool o tools sueltas?** → ¿El flujo es lineal y conocido? Meta-tool. ¿El agente decide? Sueltas.
|
||||
|
||||
Cuando dudes, **arrancá con menos**. Es más fácil splittear un agente en
|
||||
dos que mergear dos en uno.
|
||||
149
docs/deployment-guide.md
Normal file
149
docs/deployment-guide.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Deployment Guide
|
||||
|
||||
Cómo desplegar una solución generada con este template. Cubre Coolify
|
||||
(default FIT) + alternativas.
|
||||
|
||||
## Resumen
|
||||
|
||||
```bash
|
||||
# 1. Tener el .env con las credenciales del tenant WxO y del host público
|
||||
# 2. Tener un Coolify (o un docker host) corriendo
|
||||
# 3. ./scripts/deploy-wxo.sh ← despliega agentes/tools/KBs/connections al tenant
|
||||
# 4. docker compose up -d ← despliega la capa web + mocks (si los hay)
|
||||
# 5. WxO UI → Embed Security OFF (manual, una vez por tenant)
|
||||
# 6. ./evals/smoke-test.sh ← valida end-to-end
|
||||
```
|
||||
|
||||
## Variables de entorno requeridas
|
||||
|
||||
Ver `.env.example` para la lista completa. Las críticas:
|
||||
|
||||
```bash
|
||||
# Tenant WxO
|
||||
ORCHESTRATE_API_URL=https://api.us-south.watson-orchestrate.cloud.ibm.com/instances/<uuid>
|
||||
ORCHESTRATE_API_KEY=<tu-api-key>
|
||||
|
||||
# Para Plan A (webhook a Runs API). Opcional.
|
||||
WATSONX_API_KEY=<api-key-ibm-cloud>
|
||||
IBM_CLOUD_IAM_URL=https://iam.cloud.ibm.com/identity/token
|
||||
WXO_AGENT_ID=<uuid-del-orchestrator-en-live>
|
||||
|
||||
# Host público donde corre tu stack
|
||||
PUBLIC_HOST=mi-cliente.fitlabs.dev
|
||||
```
|
||||
|
||||
## Coolify (FIT default)
|
||||
|
||||
### Crear el stack
|
||||
1. **Coolify panel** → New Resource → Docker Compose
|
||||
2. Connect to git repo (Gitea)
|
||||
3. Build pack: docker compose, path `./docker-compose.yml`
|
||||
4. **Domain**: tu `PUBLIC_HOST` en el servicio que expone la landing/web
|
||||
5. **Env vars**: copiar todos los valores del `.env` al panel de Coolify
|
||||
6. **Deploy**
|
||||
|
||||
### Si Traefik da 503
|
||||
Casi siempre es healthcheck. Verificá:
|
||||
- El healthcheck usa `wget -qO-` o `curl -sf` (no `--spider`) → issue I-008
|
||||
- El endpoint `/health` del servicio responde 200
|
||||
- En logs: `docker logs <container>` busca errores de startup
|
||||
|
||||
### Si sale "TRAEFIK DEFAULT CERT"
|
||||
Tenés labels Traefik manuales en el compose. Borralos. Issue I-009.
|
||||
|
||||
### Redeploy via API
|
||||
```bash
|
||||
curl -X POST "$COOLIFY_API_URL/applications/$COOLIFY_APP_UUID/deploy?force=true" \
|
||||
-H "Authorization: Bearer $COOLIFY_API_TOKEN"
|
||||
```
|
||||
|
||||
## Docker Compose local
|
||||
|
||||
Para development. Trae el override `docker-compose.local.yml` que agrega
|
||||
ports + bridge network.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
LANDING_PORT=18900 docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.local.yml \
|
||||
up -d --build
|
||||
```
|
||||
|
||||
URLs:
|
||||
- Landing: `http://localhost:18900/`
|
||||
- Web layer: `http://localhost:18900/web/`
|
||||
- Cualquier mock: `http://localhost:18900/<mock-name>/`
|
||||
|
||||
## Otros targets (K8s, Cloud Run, etc.)
|
||||
|
||||
El template es Docker-compose-first, pero los servicios son containers
|
||||
estándar. Para migrar:
|
||||
|
||||
1. **Kubernetes**: convertir cada service del compose en `Deployment` +
|
||||
`Service`. Usar un Ingress controller para el routing (estilo Traefik).
|
||||
`kompose convert` te da una primera versión.
|
||||
2. **Cloud Run**: cada service va por separado. Las connections entre
|
||||
ellos por URLs HTTP públicas o VPC connector.
|
||||
3. **ECS Fargate**: task definition por service. ALB para el routing.
|
||||
|
||||
El **WxO deploy (`./scripts/deploy-wxo.sh`) es independiente del target**.
|
||||
Lo que cambia es solo el host público que el ADK ve en las connections.
|
||||
|
||||
## Deploy del WxO stack
|
||||
|
||||
`./scripts/deploy-wxo.sh` es idempotente. Hace:
|
||||
|
||||
1. Connections en **draft y live** (issue I-004)
|
||||
2. Tools import (Python + OpenAPI según haya)
|
||||
3. KBs import
|
||||
4. Agents import (specialists primero, orchestrator último)
|
||||
5. Agents deploy a live
|
||||
6. Channel creation
|
||||
|
||||
Lo podés re-correr todas las veces que quieras.
|
||||
|
||||
### Variables que afecta el script
|
||||
|
||||
| Var | Default | Para qué |
|
||||
|---|---|---|
|
||||
| `PUBLIC_HOST` | `mi-cliente.fitlabs.dev` | Base URL de las connections |
|
||||
| `WXO_ENV_NAME` | `default` | Cuál env del ADK usar |
|
||||
| `SKIP_TOOLS` | `false` | Saltar la fase de tools (útil si solo cambiaste agentes) |
|
||||
| `SKIP_KB` | `false` | Saltar KBs (idem) |
|
||||
|
||||
## Capturar IDs después del deploy
|
||||
|
||||
```bash
|
||||
orchestrate agents list
|
||||
# Buscá: mesa_n1 (o como se llame tu orchestrator)
|
||||
# y su row con env=live
|
||||
# Capturá: agent_id + env_id
|
||||
```
|
||||
|
||||
Esos dos UUIDs van a:
|
||||
- `web/.../index.html` → `agentId` + `agentEnvironmentId` del widget de chat
|
||||
- `.env` → `WXO_AGENT_ID` (para Plan A webhook)
|
||||
- Coolify env vars
|
||||
|
||||
## Paso manual obligatorio — Embed Security OFF
|
||||
|
||||
> WxO Console → Settings → Embed Security → **Off**
|
||||
|
||||
Sin esto el embed se queda en "Cargando agente...". Issue I-007.
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
./evals/smoke-test.sh
|
||||
```
|
||||
|
||||
Hace login → crea un caso → ejecuta → polea → assert. Si pasa, el deploy
|
||||
está OK.
|
||||
|
||||
## Si algo falla
|
||||
|
||||
1. `./evals/direct-backend-probe.sh` — aísla auth/connection de logic
|
||||
2. `./scripts/check-adk-version.sh` — confirma que el ADK pinneado todavía es válido
|
||||
3. `docs/known-issues.md` — los 16 errores conocidos con su fix
|
||||
4. `docs/RUNBOOK.md` — operaciones de recovery
|
||||
247
docs/eval-strategy.md
Normal file
247
docs/eval-strategy.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Eval Strategy
|
||||
|
||||
Cómo validar que tu solución hace lo que debe. **Cuatro capas**, cada una
|
||||
respondiendo una pregunta distinta.
|
||||
|
||||
---
|
||||
|
||||
## Capa 1 — Native (ADK)
|
||||
|
||||
**Pregunta:** ¿El agente responde algo razonable al input?
|
||||
|
||||
**Herramienta:** `orchestrate agents test`
|
||||
|
||||
```bash
|
||||
orchestrate agents test mi_agente --input-file evals/scenarios/test1.json
|
||||
```
|
||||
|
||||
Output:
|
||||
- La respuesta del agente
|
||||
- La traza de tool calls
|
||||
- El timing
|
||||
|
||||
**Cobertura:**
|
||||
- Happy path
|
||||
- Casos límite (input ambiguo, datos faltantes)
|
||||
- Casos de error (sistema externo caído, datos inválidos)
|
||||
|
||||
**Limitación:** no compara contra expected. Solo te muestra qué pasó.
|
||||
|
||||
**Fixture format** (`evals/scenarios/test1.json`):
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{ "role": "user", "content": "Necesito resetear la contraseña de juan.perez@cotemar.com" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Capa 2 — Agent behavior (custom)
|
||||
|
||||
**Pregunta:** ¿El agente llamó las tools correctas en el orden correcto?
|
||||
|
||||
**Herramienta:** `evals/runner.py` que consume trazas observables.
|
||||
|
||||
**Fixture format** (`evals/scenarios/scenario_reset.yaml`):
|
||||
```yaml
|
||||
name: reset_password_happy_path
|
||||
input: "Necesito resetear la contraseña de juan.perez@cotemar.com"
|
||||
expect:
|
||||
agent_response_contains: ["TKT-"]
|
||||
tool_calls_in_order:
|
||||
- tool: lookup_user
|
||||
inputs.username: "juan.perez@cotemar.com"
|
||||
- tool: reset_password
|
||||
- tool: create_ticket
|
||||
inputs.status: "RESOLVED"
|
||||
no_tool_calls:
|
||||
- escalate_to_n2
|
||||
```
|
||||
|
||||
**Cómo corre:**
|
||||
1. Manda el input al agente vía WxO Runs API
|
||||
2. Espera la respuesta
|
||||
3. Lee las trazas emitidas durante el run (capa observability)
|
||||
4. Compara contra `expect`
|
||||
5. Pass/fail
|
||||
|
||||
**Por qué importa:** un agente puede dar la respuesta correcta llamando
|
||||
las tools incorrectas (ej: dijo `RESOLVED` sin llamar `reset_password`).
|
||||
Esto detecta esos casos.
|
||||
|
||||
---
|
||||
|
||||
## Capa 3 — Runbook compliance (custom)
|
||||
|
||||
**Pregunta:** ¿El estado final del sistema coincide con lo que el runbook
|
||||
dice que debe pasar?
|
||||
|
||||
**Herramienta:** `evals/runner.py` con asserts sobre la DB del backend.
|
||||
|
||||
**Fixture format** (`evals/scenarios/scenario_reset.yaml` — sección
|
||||
`final_state`):
|
||||
```yaml
|
||||
final_state:
|
||||
query: "SELECT * FROM tickets WHERE created_at > $START_TIME"
|
||||
expect_count: 1
|
||||
expect_fields:
|
||||
status: "RESOLVED"
|
||||
category: "password_reset"
|
||||
assigned_group: "Capital Humano"
|
||||
extra.runbook: "01"
|
||||
```
|
||||
|
||||
**Cómo corre:**
|
||||
1. Captura timestamp inicial
|
||||
2. Ejecuta el scenario (manda input al agente)
|
||||
3. Query a la DB para ver qué se creó/actualizó
|
||||
4. Asserts contra `final_state`
|
||||
|
||||
**Por qué importa:** la traza puede decir "todo bien" pero el ticket
|
||||
final puede tener el grupo equivocado, falta de metadata, etc.
|
||||
|
||||
---
|
||||
|
||||
## Capa 4 — Web layer (custom, opcional)
|
||||
|
||||
**Pregunta:** ¿La UI muestra correctamente el resultado?
|
||||
|
||||
**Herramienta:** Playwright o `requests` + parsing HTML.
|
||||
|
||||
**Cuándo aplica:** si la solución incluye un control plane / kanban /
|
||||
dashboard que el usuario consume directamente.
|
||||
|
||||
**Fixture format** (`evals/scenarios/scenario_reset.yaml` — sección `ui`):
|
||||
```yaml
|
||||
ui:
|
||||
visit: "/insights"
|
||||
expect_text:
|
||||
- "Resueltos: 1"
|
||||
- "Runbook 01"
|
||||
visit: "/"
|
||||
expect_selector: ".ticket-card--RESOLVED"
|
||||
expect_count_selector:
|
||||
selector: ".ticket-card"
|
||||
count: ">=1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Runner
|
||||
|
||||
```bash
|
||||
./evals/eval-agents.sh
|
||||
```
|
||||
|
||||
Hace en orden:
|
||||
1. `lint_wxo_yaml.py` — best practices estáticas (issue check antes de runtime)
|
||||
2. Native eval por cada `evals/scenarios/*.json`
|
||||
3. Behavior eval por cada `evals/scenarios/*.yaml`
|
||||
4. Final state eval (parte del mismo YAML)
|
||||
5. UI eval si existe sección `ui`
|
||||
|
||||
Reporta:
|
||||
```
|
||||
[lint] PASS
|
||||
[native] PASS (5/5)
|
||||
[behavior] PASS (4/5) — scenario "edge_case_inactive_user" FAILED
|
||||
[final_state] PASS (4/5)
|
||||
[ui] SKIP (no fixtures)
|
||||
```
|
||||
|
||||
Exit code 0 si todo pasa, 1 si alguno falla.
|
||||
|
||||
---
|
||||
|
||||
## Smoke test (CI nightly)
|
||||
|
||||
`evals/smoke-test.sh` es el subset mínimo que debe pasar **siempre**:
|
||||
|
||||
1. Health endpoint responde 200
|
||||
2. Un caso simple end-to-end (login → create → execute → poll → assert)
|
||||
3. El audit log tiene las entries esperadas
|
||||
4. La UI muestra el resultado
|
||||
|
||||
Si falla, algo de la infraestructura está mal (deploy, DNS, healthcheck,
|
||||
auth, etc.). NO mira el agente en profundidad — para eso están las
|
||||
capas 2/3.
|
||||
|
||||
```bash
|
||||
./evals/smoke-test.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Direct backend probe (diagnóstico)
|
||||
|
||||
`evals/direct-backend-probe.sh` golpea el backend directamente con
|
||||
curl + el token de WxO. Para aislar problemas:
|
||||
|
||||
```bash
|
||||
$ ./evals/direct-backend-probe.sh
|
||||
[1/3] Healthcheck OK (200)
|
||||
[2/3] Direct tool call OK (200) — got expected "case not found"
|
||||
[3/3] Auth test OK (200 con token, 401 sin)
|
||||
✓ Backend OK from WxO side. Si la solución falla, es el agente o el LLM.
|
||||
```
|
||||
|
||||
Si los 3 pasan pero el agente igual falla, el problema está en:
|
||||
- El prompt del agente
|
||||
- El modelo (issue I-002)
|
||||
- El recursion_limit (issue I-001)
|
||||
- Los schemas (issue I-003)
|
||||
|
||||
---
|
||||
|
||||
## Estrategia recomendada por fase del proyecto
|
||||
|
||||
### Fase 1 — Desarrollo
|
||||
- **Capa 1** (native) frecuente, manualmente
|
||||
- **Lint** antes de cada commit (pre-commit hook)
|
||||
|
||||
### Fase 2 — Estabilización
|
||||
- Agregar **Capa 2** (behavior) para cada scenario
|
||||
- Agregar **Capa 3** (final_state) para cada scenario
|
||||
- CI corre las 3 en cada PR
|
||||
|
||||
### Fase 3 — Demo / handoff
|
||||
- Agregar **Capa 4** (UI) para los 2-3 paths críticos
|
||||
- **Smoke test nightly** en CI
|
||||
|
||||
### Fase 4 — Producción
|
||||
- Smoke test cada 15 min (cron)
|
||||
- Direct backend probe en healthcheck
|
||||
- Behavior eval suite semanal
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear por scenario
|
||||
|
||||
```
|
||||
evals/scenarios/
|
||||
├── scenario_reset_password.yaml ← Capas 2, 3, 4 unified
|
||||
├── scenario_reset_password.input.json ← Capa 1 (input para `agents test`)
|
||||
├── scenario_restart_service.yaml
|
||||
├── scenario_restart_service.input.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
El subagente `eval-author` (en `.claude/agents/eval-author.md`) genera
|
||||
estos pares automáticamente cuando le das un runbook o un agente nuevo.
|
||||
|
||||
---
|
||||
|
||||
## Cuando una eval falla
|
||||
|
||||
1. Mirá `evals/last_run.log` — qué fixture, qué assert
|
||||
2. Si es **Capa 1**: el agente respondió mal o no respondió. Ver prompt.
|
||||
3. Si es **Capa 2**: el agente respondió bien pero llamó las tools mal.
|
||||
Ver instructions del agente.
|
||||
4. Si es **Capa 3**: tools se llamaron pero el resultado final está
|
||||
incorrecto. Ver el handler del backend.
|
||||
5. Si es **Capa 4**: backend OK pero UI no muestra. Ver el view / HTMX template.
|
||||
|
||||
Repará, corré la eval específica (`./evals/eval-agents.sh -s
|
||||
scenario_x`), commit.
|
||||
407
docs/known-issues.md
Normal file
407
docs/known-issues.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# Known Issues — Errores reales con su fix
|
||||
|
||||
Cada item acá fue pagado en sangre por Cotemar o Dun. Cuando te toque
|
||||
debuggear, leé primero, googleá después.
|
||||
|
||||
---
|
||||
|
||||
## I-001 — `recursion_limit reached` (langgraph)
|
||||
|
||||
**Síntoma:** después de 2-3 tool calls el run muere con
|
||||
`GraphRecursionError`.
|
||||
|
||||
**Causa:** WxO usa langgraph internamente con `recursion_limit=30`
|
||||
hardcoded (no configurable vía YAML ni API). Cada turno ReAct quema
|
||||
~10 frames.
|
||||
|
||||
**Fix:** Si tu flujo es lineal y conocido, **patrón meta-tool**: colapsá
|
||||
N tools en una sola `run_full_case(id)` que el backend orquesta
|
||||
internamente. El agente solo invoca esa tool.
|
||||
|
||||
```python
|
||||
# Antes: agent llama 5 tools en orden
|
||||
@tool def get_company(id): ...
|
||||
@tool def search_credits(id): ...
|
||||
@tool def build_profile(id): ...
|
||||
@tool def package_zip(id): ...
|
||||
@tool def upload(id): ...
|
||||
|
||||
# Después: agent llama 1 meta-tool
|
||||
@tool
|
||||
def run_full_case(case_id):
|
||||
# Backend orquesta los 5 pasos internamente
|
||||
# cada uno emite write_audit(...)
|
||||
return {"package_url": "...", "audit_url": "..."}
|
||||
```
|
||||
|
||||
**Origen:** Dun commit `895f5ee feat(orchestrate): consolidar 11 tools → run_full_case`.
|
||||
|
||||
---
|
||||
|
||||
## I-002 — Llama 3.3 70B printea tool calls como texto
|
||||
|
||||
**Síntoma:** después del turno ~3, el agente "responde" con texto que
|
||||
literalmente dice:
|
||||
```
|
||||
Tool call: reset_password(username="juan.perez@cotemar.com")
|
||||
```
|
||||
en lugar de invocar la tool.
|
||||
|
||||
**Causa:** Drift del modelo Llama 3.3 70B con tool calling. Documentado
|
||||
por IBM, mitigación recomendada: cambiar de modelo.
|
||||
|
||||
**Fix:**
|
||||
1. **Cambiar `llm:` a `groq/openai/gpt-oss-120b`** en el YAML del agente.
|
||||
2. Agregar **REGLA #0** explícita en el prompt:
|
||||
> "NUNCA escribas la tool call como texto. Usá el protocolo de tool_calls."
|
||||
|
||||
**Origen:** Dun commit `84be316`.
|
||||
|
||||
---
|
||||
|
||||
## I-003 — Pydantic 422 "value is not a valid integer"
|
||||
|
||||
**Síntoma:** Una tool falla con `ValidationError: value is not a valid
|
||||
integer` aunque vos jurás que mandás un int.
|
||||
|
||||
**Causa:** El LLM stringifica todo. Manda `"95727067"` cuando el schema
|
||||
es `int`. Pydantic v2 strict mode rechaza.
|
||||
|
||||
**Fix:** Coerción explícita en el schema:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
def _coerce_int(v):
|
||||
if isinstance(v, str) and v.strip().isdigit():
|
||||
return int(v)
|
||||
return v
|
||||
|
||||
def _coerce_list(v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try: return json.loads(v)
|
||||
except: return [v]
|
||||
return v
|
||||
|
||||
class ResetPasswordInput(BaseModel):
|
||||
user_id: int
|
||||
groups: list[str]
|
||||
_coerce_user_id = field_validator("user_id", mode="before")(_coerce_int)
|
||||
_coerce_groups = field_validator("groups", mode="before")(_coerce_list)
|
||||
```
|
||||
|
||||
El template trae estos helpers en
|
||||
[`wxo/tools/python/_coercion_helpers.py`](../wxo/tools/python/_coercion_helpers.py).
|
||||
|
||||
**Origen:** Dun commit `3cdf3bf`.
|
||||
|
||||
---
|
||||
|
||||
## I-004 — `kid not found` al `agents deploy --env live`
|
||||
|
||||
**Síntoma:** import del agente sale OK al draft, pero `deploy --env live`
|
||||
falla con `kid not found`.
|
||||
|
||||
**Causa:** Las conexiones que el agente usa solo existen en el env `draft`.
|
||||
La promoción a `live` requiere que las connections existan también en `live`.
|
||||
|
||||
**Fix:** Configurar connection en **ambos** envs:
|
||||
|
||||
```bash
|
||||
for ENV in draft live; do
|
||||
orchestrate connections configure -a mi_app --env $ENV --type team --kind key_value
|
||||
orchestrate connections set-credentials -a mi_app --env $ENV -e "BASE_URL=..."
|
||||
done
|
||||
```
|
||||
|
||||
El `deploy-wxo.sh` del template ya lo hace.
|
||||
|
||||
**Origen:** Cotemar (debug session 2026-05-13).
|
||||
|
||||
---
|
||||
|
||||
## I-005 — OpenAPI import: "No description provided"
|
||||
|
||||
**Síntoma:** `orchestrate tools import -k openapi` falla con `No
|
||||
description provided for operation X`.
|
||||
|
||||
**Causa:** ADK exige `description` por operación. FastAPI genera solo
|
||||
`summary` cuando la función no tiene docstring.
|
||||
|
||||
**Fix:** Al exponer el spec, forzar el fallback:
|
||||
|
||||
```python
|
||||
# En tu endpoint /orchestrate-tools-spec.json
|
||||
for path_item in spec["paths"].values():
|
||||
for op in path_item.values():
|
||||
if isinstance(op, dict) and "responses" in op:
|
||||
op["description"] = op.get("description") or op.get("summary") or "..."
|
||||
```
|
||||
|
||||
**Origen:** Dun `backend/app/api/v1/spec.py:60-66`.
|
||||
|
||||
---
|
||||
|
||||
## I-006 — OpenAPI security no se hereda
|
||||
|
||||
**Síntoma:** Spec tiene `security:` global, pero ADK importa los tools
|
||||
sin auth.
|
||||
|
||||
**Causa:** ADK no respeta `security` global. Requiere per-operation.
|
||||
|
||||
**Fix:** Re-declarar en cada op:
|
||||
|
||||
```python
|
||||
for path_item in spec["paths"].values():
|
||||
for op in path_item.values():
|
||||
if isinstance(op, dict) and "responses" in op:
|
||||
op["security"] = spec.get("security", [])
|
||||
```
|
||||
|
||||
**Origen:** Dun commit `1bc04cf`.
|
||||
|
||||
---
|
||||
|
||||
## I-007 — Embed widget queda en "Cargando agente..."
|
||||
|
||||
**Síntoma:** Tu landing page con `<script src=".../webchat.js">` carga,
|
||||
pero el widget muestra para siempre el spinner.
|
||||
|
||||
**Causa:** Embed Security ON en la configuración del tenant. Sin
|
||||
credenciales anónimas el widget no puede iniciar la sesión.
|
||||
|
||||
**Fix manual (UI):**
|
||||
> WxO Console → Settings → **Embed Security** → **Off**
|
||||
|
||||
Documentado en IBM:
|
||||
<https://developer.watson-orchestrate.ibm.com/channels/establishing_channels>
|
||||
|
||||
**Origen:** Cotemar (2 horas de debug).
|
||||
|
||||
---
|
||||
|
||||
## I-008 — Coolify/Traefik 503 después de deploy
|
||||
|
||||
**Síntoma:** El container está corriendo (`docker ps` muestra healthy en
|
||||
status... eventualmente unhealthy), pero el dominio público devuelve 503.
|
||||
|
||||
**Causa:** Healthcheck con `wget --spider` es buggy en busybox alpine —
|
||||
a veces marca unhealthy aunque el endpoint responda. Traefik excluye el
|
||||
backend.
|
||||
|
||||
**Fix:** Cambiar a `wget -qO-` o `curl -sf`:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||
# o
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
**Origen:** Cotemar (debug Coolify).
|
||||
|
||||
---
|
||||
|
||||
## I-009 — Coolify cert "TRAEFIK DEFAULT CERT"
|
||||
|
||||
**Síntoma:** El dominio sale con cert de Traefik default en vez de Let's
|
||||
Encrypt.
|
||||
|
||||
**Causa:** Declaraste labels Traefik manuales en `docker-compose.yml`.
|
||||
Coolify v4 los autogenera del panel FQDN.
|
||||
|
||||
**Fix:** Borrar todos los `traefik.http.routers.*` del compose. Dejar
|
||||
solo:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=coolify"
|
||||
```
|
||||
|
||||
Y configurar el FQDN desde el panel de Coolify.
|
||||
|
||||
**Origen:** Dun commit `373a336`.
|
||||
|
||||
---
|
||||
|
||||
## I-010 — Tool error `ModuleNotFoundError: wxo.tools._compat`
|
||||
|
||||
**Síntoma:** El tool falla en runtime con import error referenciando
|
||||
`_compat`.
|
||||
|
||||
**Causa:** ADK importa cada `tools.py` standalone, sin el package padre.
|
||||
Los imports relativos del estilo `from .._compat import X` fallan.
|
||||
|
||||
**Fix:** Inlinear el shim al inicio del archivo:
|
||||
|
||||
```python
|
||||
# tools/python/mi_tool.py
|
||||
# === inline compat shim (NO eliminar — TRM lo necesita) ===
|
||||
try:
|
||||
from ibm_watsonx_orchestrate.agent_builder.tools import tool
|
||||
except ImportError:
|
||||
def tool(*args, **kwargs):
|
||||
def deco(f): return f
|
||||
return deco if not args else deco(args[0])
|
||||
# ============================================================
|
||||
|
||||
@tool(name="mi_tool", ...)
|
||||
def mi_tool(...):
|
||||
...
|
||||
```
|
||||
|
||||
El template ya viene así. **No reorganizar los imports.**
|
||||
|
||||
**Origen:** Cotemar.
|
||||
|
||||
---
|
||||
|
||||
## I-011 — `.env` no se recarga con `restart`
|
||||
|
||||
**Síntoma:** Cambiás `.env` y hacés `docker compose restart`, pero el
|
||||
container sigue con los valores viejos.
|
||||
|
||||
**Causa:** `restart` no relee env vars. Solo `up` lo hace.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
**Origen:** Dun `INSTRUCCIONES.md:159-164`.
|
||||
|
||||
---
|
||||
|
||||
## I-012 — Caso atascado en `executing`
|
||||
|
||||
**Síntoma:** Un caso queda para siempre en estado `executing`. Polls
|
||||
desde la UI no avanzan.
|
||||
|
||||
**Causa:** La conexión Orchestrate → backend se cayó silenciosamente
|
||||
durante el run. El backend nunca recibió el callback final.
|
||||
|
||||
**Fix:** Watchdog en el GET del caso:
|
||||
|
||||
```python
|
||||
@router.get("/cases/{case_id}")
|
||||
def get_case(case_id):
|
||||
case = db.get(case_id)
|
||||
if case.status == "executing":
|
||||
age = datetime.utcnow() - case.last_update
|
||||
if age > timedelta(seconds=90):
|
||||
case.status = "paused"
|
||||
db.commit()
|
||||
return case
|
||||
```
|
||||
|
||||
**Origen:** Dun commit `23b815e`.
|
||||
|
||||
---
|
||||
|
||||
## I-013 — `asyncio.create_task` deja casos colgados
|
||||
|
||||
**Síntoma:** A veces el `start_run` falla silenciosamente y el caso
|
||||
queda en estado inicial sin error.
|
||||
|
||||
**Causa:** `asyncio.create_task(orchestrate_start(...))` no espera ni
|
||||
captura excepciones.
|
||||
|
||||
**Fix:** Usar `await` + wrapper que captura:
|
||||
|
||||
```python
|
||||
async def _start_run_safe(case_id, ...):
|
||||
try:
|
||||
await orchestrate.start_run(...)
|
||||
except Exception as e:
|
||||
logger.exception("start_run failed")
|
||||
await db.update_case(case_id, status="failed", error=str(e))
|
||||
|
||||
await _start_run_safe(case_id, ...)
|
||||
```
|
||||
|
||||
**Origen:** Dun commit `fa47f14` + `abb5b73`.
|
||||
|
||||
---
|
||||
|
||||
## I-014 — Subcomando ADK cambió de nombre
|
||||
|
||||
**Síntoma:** Tu script de deploy falla porque `connections set-identifier`
|
||||
no existe (o `connections configure --url` no existe).
|
||||
|
||||
**Causa:** Entre versiones del ADK los subcomandos se renombran.
|
||||
|
||||
**Fix:** Fallback en el script:
|
||||
|
||||
```bash
|
||||
orchestrate connections set-identifier --url "$URL" -a mi_app 2>/dev/null \
|
||||
|| orchestrate connections configure --url "$URL" -a mi_app 2>/dev/null \
|
||||
|| echo "warning: no compatible subcommand found"
|
||||
```
|
||||
|
||||
**Origen:** Dun `deploy.sh:65-74`.
|
||||
|
||||
---
|
||||
|
||||
## I-015 — Frontend descarga el index.html en vez del archivo real
|
||||
|
||||
**Síntoma:** Click en un botón de descarga, abre el archivo y resulta
|
||||
ser el HTML de la SPA.
|
||||
|
||||
**Causa:** La función `downloadAuthenticated` confunde `fetchUrl` con
|
||||
`blobUrl` (misma variable). El navegador redirige a la SPA.
|
||||
|
||||
**Fix:**
|
||||
```ts
|
||||
async function downloadAuthenticated(fetchUrl: string, filename: string) {
|
||||
const res = await fetch(fetchUrl, { headers: authHeaders });
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob); // OJO: variable distinta
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
```
|
||||
|
||||
**Origen:** Dun commit `4e7e3fd`.
|
||||
|
||||
---
|
||||
|
||||
## I-016 — Polling para de actualizar después de approve
|
||||
|
||||
**Síntoma:** UI hace polling cada 1.5s. Click en "approve" y el polling
|
||||
muere.
|
||||
|
||||
**Causa:** El `useEffect` con `setInterval` no re-agenda el timer
|
||||
después del fetch.
|
||||
|
||||
**Fix:** Re-agendar dentro del callback del fetch:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
let timer: number;
|
||||
const poll = async () => {
|
||||
const data = await fetchCase(id);
|
||||
setData(data);
|
||||
if (!["done", "failed", "paused"].includes(data.status)) {
|
||||
timer = window.setTimeout(poll, 1500);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
return () => clearTimeout(timer);
|
||||
}, [id]);
|
||||
```
|
||||
|
||||
**Origen:** Dun commit `83fb85c`.
|
||||
|
||||
---
|
||||
|
||||
Si pasaste varias horas buscando un bug y al final encontraste el fix,
|
||||
agregalo acá. Este archivo es el repositorio de "lecciones que no quiero
|
||||
volver a pagar".
|
||||
178
docs/observability-pattern.md
Normal file
178
docs/observability-pattern.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Observability Pattern
|
||||
|
||||
Cada tool wrapper en este template emite trazas estructuradas por defecto.
|
||||
La capa web las consume, las muestra en una vista timeline, y las evals las
|
||||
usan para verificar que el agente llamó las tools correctas en el orden
|
||||
correcto.
|
||||
|
||||
## El contrato
|
||||
|
||||
Toda tool, sin importar el lenguaje, devuelve este envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": { /* el resultado real que el LLM consume */ },
|
||||
"trace": {
|
||||
"trace_id": "ad-reset-7f3a",
|
||||
"tool": "reset_password",
|
||||
"domain": "ad",
|
||||
"started_at": "2026-05-16T14:23:01.123Z",
|
||||
"duration_ms": 187,
|
||||
"inputs": { "username": "juan.perez@cotemar.com" },
|
||||
"side_effects": [
|
||||
{ "type": "http_call", "method": "POST", "url": "...", "status": 200, "duration_ms": 142 }
|
||||
],
|
||||
"observed_state_before": { "can_reset": true, "user_active": true },
|
||||
"observed_state_after": { "password_rotated": true, "expires_at": "..." },
|
||||
"agent_caller": "ad_specialist_cotemar",
|
||||
"correlation_id": "case-abcd-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
El LLM ve solo `result`. La traza va al sink configurado y no contamina
|
||||
el razonamiento del agente.
|
||||
|
||||
## Decorator Python (`@observable_tool`)
|
||||
|
||||
Reemplaza al `@tool` directo de la ADK:
|
||||
|
||||
```python
|
||||
from wxo.tools.python._observable_tool import observable_tool
|
||||
|
||||
@observable_tool(name="reset_password", domain="ad")
|
||||
def reset_password(username: str) -> dict:
|
||||
# tu lógica normal
|
||||
return {"temp_password": "xxx", "expires_in": "24h"}
|
||||
```
|
||||
|
||||
El decorator:
|
||||
1. Genera un `trace_id` único
|
||||
2. Captura `started_at` y mide `duration_ms`
|
||||
3. Serializa `inputs`
|
||||
4. Intercepta llamadas HTTP (si usa `requests` patched) y las anota en `side_effects`
|
||||
5. Llama tu función
|
||||
6. Captura `result`
|
||||
7. Emite la traza al sink configurado vía env `TRACE_SINK`
|
||||
8. Devuelve `{result, trace}` envuelto
|
||||
|
||||
La función decorada sigue siendo un `@tool` válido para la ADK — el
|
||||
decorator delega.
|
||||
|
||||
## Sinks soportados
|
||||
|
||||
Tres modos según el caso, controlados por env var `TRACE_SINK`:
|
||||
|
||||
### `TRACE_SINK=sqlite` (default dev)
|
||||
Trazas se escriben a `traces.db` SQLite en el container del web layer.
|
||||
Útil para dev y CI.
|
||||
|
||||
### `TRACE_SINK=http`
|
||||
POST a `$TRACE_SINK_URL` (default `http://web:8000/api/traces`).
|
||||
Pattern Cotemar: el web layer recibe trazas live y las muestra en una
|
||||
vista timeline filterable.
|
||||
|
||||
### `TRACE_SINK=otlp`
|
||||
OpenTelemetry OTLP gRPC a `$OTEL_EXPORTER_OTLP_ENDPOINT`. Producción.
|
||||
Compatible con Jaeger, Dynatrace, Grafana Tempo, etc.
|
||||
|
||||
## Backend integrado (estilo Dun) — `write_audit`
|
||||
|
||||
Cuando la tool no es un wrapper Python sino un endpoint del backend
|
||||
(patrón Dun con OpenAPI import), el equivalente es `write_audit`:
|
||||
|
||||
```python
|
||||
# backend/app/audit.py
|
||||
def write_audit(case_id: str, event: str, payload: dict):
|
||||
db.execute(
|
||||
"INSERT INTO audit_logs (case_id, event, payload, ts) VALUES (?, ?, ?, ?)",
|
||||
(case_id, event, json.dumps(payload), datetime.utcnow())
|
||||
)
|
||||
|
||||
# backend/app/api/v1/orchestrate_tools.py
|
||||
@router.post("/reset-password")
|
||||
def reset_password(input: ResetPasswordInput, case_id: str):
|
||||
write_audit(case_id, "tool.reset_password.started", {"input": input.dict()})
|
||||
result = ad_client.reset(input.username)
|
||||
write_audit(case_id, "tool.reset_password.completed", {"result": result})
|
||||
return result
|
||||
```
|
||||
|
||||
El backend reconstruye el timeline desde `audit_logs`, no desde trazas
|
||||
volátiles de Orchestrate.
|
||||
|
||||
## Schema unificado para multi-lenguaje
|
||||
|
||||
Si tu wrapper es Node/Java/Go, el decorator equivalente debe emitir el
|
||||
**mismo envelope JSON**. Los templates están en:
|
||||
|
||||
```
|
||||
wxo/tools/_observability/
|
||||
├── python/observable_tool.py
|
||||
├── node/observable-tool.ts (TODO en v1, escribilo si lo necesitás)
|
||||
├── java/ObservableTool.java (TODO en v1)
|
||||
└── README.md ← el contrato schema
|
||||
```
|
||||
|
||||
## Vista timeline en el web layer
|
||||
|
||||
El `web/_default_fastapi_htmx/` viene con:
|
||||
|
||||
- `POST /api/traces` — endpoint que recibe trazas
|
||||
- Tabla `traces` en SQLite con índices en `(agent_caller, started_at)` y `(trace_id)`
|
||||
- `GET /traces` — vista HTMX con timeline
|
||||
- Componente que polea `GET /traces/recent?since=...` cada 2s
|
||||
|
||||
Captura visual:
|
||||
|
||||
```
|
||||
14:23:01.123 ad_specialist reset_password(juan.perez@cotemar.com)
|
||||
├─ HTTP POST ad/reset-password 142ms 200
|
||||
└─ result: { temp_password: "xxx" } 187ms total
|
||||
14:23:02.456 ad_specialist create_ticket(...)
|
||||
└─ HTTP POST unus/api/tickets 89ms 201
|
||||
└─ result: { ticket_id: "TKT-A1B2" } 103ms total
|
||||
```
|
||||
|
||||
## Evals que consumen trazas
|
||||
|
||||
Las evals de behavior verifican comportamiento intermedio, no solo el
|
||||
resultado final:
|
||||
|
||||
```yaml
|
||||
# evals/scenarios/scenario_reset_password.yaml
|
||||
input: "Necesito resetear la contraseña de juan.perez@cotemar.com"
|
||||
expect:
|
||||
agent_response_contains: ["TKT-"]
|
||||
tool_calls_in_order:
|
||||
- tool: lookup_user
|
||||
inputs.username: "juan.perez@cotemar.com"
|
||||
- tool: reset_password
|
||||
- tool: create_ticket
|
||||
inputs.status: "RESOLVED"
|
||||
no_tool_calls:
|
||||
- escalate_to_n2
|
||||
```
|
||||
|
||||
El runner lee las trazas emitidas durante el run y matchea contra
|
||||
`expect`. Si el agente llamó las tools en el orden equivocado, falla
|
||||
aunque el ticket final esté bien.
|
||||
|
||||
## Regla del linter
|
||||
|
||||
`evals/lint_wxo_yaml.py` falla si encuentra un `@tool(...)` sin
|
||||
`@observable_tool(...)` en cualquier archivo bajo `wxo/tools/python/`.
|
||||
|
||||
No hay excepción. Si necesitás bypass para debug, marcalo:
|
||||
|
||||
```python
|
||||
@observable_tool(name="x", domain="y", _bypass_tracing=True) # explícito
|
||||
def x(): ...
|
||||
```
|
||||
|
||||
## Costo
|
||||
|
||||
El decorator agrega ~2-5ms de overhead por call. Para 99% de los casos
|
||||
es invisible. Si tenés un tool ultra-hot que llama 1000 veces por minuto,
|
||||
configurá `TRACE_SINK=sqlite` con buffer batch o desactiválo con
|
||||
`_bypass_tracing=True` (con audit a mano).
|
||||
314
docs/tool-authoring-guide.md
Normal file
314
docs/tool-authoring-guide.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Tool Authoring Guide
|
||||
|
||||
WxO acepta tools de tres formas. Acá te explico cuándo usar cuál y cómo.
|
||||
|
||||
## Decisión rápida
|
||||
|
||||
| Situación | Tipo |
|
||||
|---|---|
|
||||
| PoC / demo / mocks | **Python `@tool`** |
|
||||
| Tu backend ya expone REST (FastAPI/Express/etc.) | **OpenAPI** |
|
||||
| SaaS de terceros con MCP server | **MCP** |
|
||||
| SaaS de terceros sin MCP | **OpenAPI** (escribís el spec) o **Python** wrapper |
|
||||
|
||||
---
|
||||
|
||||
## Tipo 1 — Python `@tool` (estilo Cotemar)
|
||||
|
||||
### Cuándo
|
||||
- Estás prototipando rápido
|
||||
- Los sistemas externos son mocks separados
|
||||
- La lógica del wrapper es simple
|
||||
- Querés tener cada tool en su propia función testeable
|
||||
|
||||
### Template
|
||||
|
||||
`wxo/tools/python/_template_tools.py`:
|
||||
|
||||
```python
|
||||
# === inline compat shim (NO eliminar — TRM lo necesita) ===
|
||||
try:
|
||||
from ibm_watsonx_orchestrate.agent_builder.tools import tool
|
||||
except ImportError:
|
||||
def tool(*args, **kwargs):
|
||||
def deco(f): return f
|
||||
return deco if not args else deco(args[0])
|
||||
# ============================================================
|
||||
|
||||
import os
|
||||
import requests
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from wxo.tools.python._observable_tool import observable_tool
|
||||
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list
|
||||
|
||||
BASE_URL = os.environ.get("BASE_URL", "")
|
||||
|
||||
class ResetPasswordInput(BaseModel):
|
||||
username: str
|
||||
notify: bool = True
|
||||
|
||||
@observable_tool(name="reset_password", domain="ad")
|
||||
def reset_password(username: str, notify: bool = True) -> dict:
|
||||
"""Reset AD password and return temp credentials."""
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/users/{username}/reset-password",
|
||||
json={"notify": notify},
|
||||
timeout=10
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
### Import
|
||||
|
||||
```bash
|
||||
orchestrate tools import -k python \
|
||||
-f wxo/tools/python/ad_tools.py \
|
||||
-r wxo/tools/python/requirements.txt \
|
||||
--app-id ad_demo
|
||||
```
|
||||
|
||||
### Gotchas
|
||||
- **No usar imports relativos** (`from .._compat import ...`). Inlinear el shim. (Issue I-010)
|
||||
- **Coerción de tipos siempre.** El LLM stringifica todo. (Issue I-003)
|
||||
- **`BASE_URL` desde env**, nunca hardcoded.
|
||||
|
||||
---
|
||||
|
||||
## Tipo 2 — OpenAPI (estilo Dun)
|
||||
|
||||
### Cuándo
|
||||
- Tu backend ya define endpoints REST con tipos
|
||||
- Querés single-source-of-truth (el spec)
|
||||
- Tenés FastAPI/Express/etc. y solo necesitás exponer un subset a WxO
|
||||
|
||||
### Template — endpoint público filtrado
|
||||
|
||||
`wxo/tools/openapi/_backend_filter_endpoint.py`:
|
||||
|
||||
```python
|
||||
from copy import deepcopy
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Allowlist de operaciones que WxO puede ver
|
||||
PUBLIC_TOOLS = {
|
||||
"POST /api/v1/cases/run",
|
||||
"GET /api/v1/cases/{case_id}",
|
||||
"POST /api/v1/cases/{case_id}/approve",
|
||||
}
|
||||
|
||||
def build_public_spec(app: FastAPI) -> dict:
|
||||
"""Filtra el openapi.json de tu app a la allowlist PUBLIC_TOOLS,
|
||||
fuerza description per-operation, y resuelve $ref transitivamente."""
|
||||
full = app.openapi()
|
||||
spec = {
|
||||
"openapi": full["openapi"],
|
||||
"info": {**full["info"], "title": full["info"]["title"] + " (orchestrate)"},
|
||||
"servers": full.get("servers", [{"url": "https://CHANGEME/api/v1"}]),
|
||||
"paths": {},
|
||||
"components": {"schemas": {}},
|
||||
"security": full.get("security", []),
|
||||
}
|
||||
|
||||
# Filtrar paths
|
||||
for path, item in full["paths"].items():
|
||||
for method, op in item.items():
|
||||
if method.upper() in ("GET", "POST", "PATCH", "PUT", "DELETE"):
|
||||
key = f"{method.upper()} {path}"
|
||||
if key in PUBLIC_TOOLS:
|
||||
spec["paths"].setdefault(path, {})[method] = op
|
||||
# description fallback (issue I-005)
|
||||
op["description"] = op.get("description") or op.get("summary") or f"{method} {path}"
|
||||
# security per-operation (issue I-006)
|
||||
op["security"] = spec["security"]
|
||||
|
||||
# Resolver $ref transitivamente
|
||||
needed_schemas = set()
|
||||
def walk(o):
|
||||
if isinstance(o, dict):
|
||||
for k, v in o.items():
|
||||
if k == "$ref" and isinstance(v, str) and v.startswith("#/components/schemas/"):
|
||||
needed_schemas.add(v.split("/")[-1])
|
||||
else:
|
||||
walk(v)
|
||||
elif isinstance(o, list):
|
||||
for x in o: walk(x)
|
||||
walk(spec["paths"])
|
||||
all_schemas = full.get("components", {}).get("schemas", {})
|
||||
# Resolver hasta closure
|
||||
pending = list(needed_schemas)
|
||||
while pending:
|
||||
s = pending.pop()
|
||||
if s not in all_schemas: continue
|
||||
spec["components"]["schemas"][s] = all_schemas[s]
|
||||
# Encontrar refs nuevos dentro
|
||||
before = set(needed_schemas)
|
||||
walk(all_schemas[s])
|
||||
for new in needed_schemas - before:
|
||||
pending.append(new)
|
||||
|
||||
return spec
|
||||
|
||||
# Endpoint que sirve el spec filtrado
|
||||
@app.get("/api/v1/orchestrate-tools-spec.json")
|
||||
def public_spec():
|
||||
return build_public_spec(app)
|
||||
```
|
||||
|
||||
### Patch del `servers[0].url` en deploy time
|
||||
|
||||
`scripts/deploy-wxo.sh` baja el spec del backend live y reemplaza el server URL:
|
||||
|
||||
```bash
|
||||
curl -s "$BACKEND_URL/api/v1/orchestrate-tools-spec.json" \
|
||||
| jq --arg url "$BACKEND_URL/api/v1" '.servers = [{"url": $url}]' \
|
||||
> /tmp/spec.json
|
||||
|
||||
orchestrate tools import -k openapi -f /tmp/spec.json --app-id mi_backend
|
||||
```
|
||||
|
||||
### Schema Pydantic con coerción
|
||||
|
||||
En tu backend, cada endpoint input model lleva field_validator:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, field_validator
|
||||
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list
|
||||
|
||||
class RunCaseInput(BaseModel):
|
||||
case_id: str
|
||||
target_companyid: int
|
||||
profile_ids: list[str]
|
||||
_coerce_companyid = field_validator("target_companyid", mode="before")(_coerce_int)
|
||||
_coerce_profiles = field_validator("profile_ids", mode="before")(_coerce_list)
|
||||
```
|
||||
|
||||
### Observabilidad con `write_audit`
|
||||
|
||||
En cada handler:
|
||||
|
||||
```python
|
||||
from app.audit import write_audit
|
||||
|
||||
@router.post("/cases/run")
|
||||
def run_case(input: RunCaseInput, x_orchestrate_token: str = Header(...)):
|
||||
write_audit(input.case_id, "tool.run_case.started", input.dict())
|
||||
result = do_the_work(input)
|
||||
write_audit(input.case_id, "tool.run_case.completed", result)
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tipo 3 — MCP
|
||||
|
||||
### Cuándo
|
||||
- El SaaS ya expone un MCP server (HubSpot, Outline, GitHub vía MCP, etc.)
|
||||
- Querés que ADK descubra las tools automáticamente
|
||||
- Necesitás permisos del sistema externo respetados
|
||||
|
||||
### Template
|
||||
|
||||
`wxo/tools/mcp/_template_mcp_connection.yaml`:
|
||||
|
||||
```yaml
|
||||
spec_version: v1
|
||||
kind: connection
|
||||
name: hubspot_mcp
|
||||
display_name: "HubSpot via MCP"
|
||||
schema_version: "1.0"
|
||||
auth_type: mcp
|
||||
identifier: hubspot_mcp_conn
|
||||
preference:
|
||||
- environment: draft
|
||||
schema_id: mcp
|
||||
- environment: live
|
||||
schema_id: mcp
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
orchestrate connections add -a hubspot_mcp
|
||||
for ENV in draft live; do
|
||||
orchestrate connections configure -a hubspot_mcp --env $ENV --type team --kind mcp
|
||||
orchestrate connections set-credentials -a hubspot_mcp --env $ENV \
|
||||
-e "MCP_SERVER_URL=https://hubspot-mcp.example.com" \
|
||||
-e "MCP_TOKEN=$HUBSPOT_TOKEN"
|
||||
done
|
||||
```
|
||||
|
||||
ADK descubre las tools del server y las hace disponibles al agente. En el
|
||||
YAML del agente listás las que necesita:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- hubspot_mcp/get_contact
|
||||
- hubspot_mcp/create_deal
|
||||
```
|
||||
|
||||
### Caveats
|
||||
- La latencia depende del MCP server. Algunos son lentos.
|
||||
- Los nombres de tools vienen del MCP server, no los podés renombrar.
|
||||
- Coordiná con el equipo del MCP server qué tools va a exponer.
|
||||
|
||||
---
|
||||
|
||||
## Híbrido
|
||||
|
||||
Un agente puede usar las tres formas al mismo tiempo:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
# MCP (descubiertas del server)
|
||||
- hubspot_mcp/get_contact
|
||||
- hubspot_mcp/update_deal
|
||||
# OpenAPI (importadas del backend propio)
|
||||
- run_full_case
|
||||
- get_case_status
|
||||
# Python (wrappers a mocks o sistemas pequeños)
|
||||
- lookup_internal_user
|
||||
```
|
||||
|
||||
Lo único que comparten es el contrato observable: cada call emite la
|
||||
misma traza JSON.
|
||||
|
||||
---
|
||||
|
||||
## Patrón "meta-tool" (issue I-001)
|
||||
|
||||
Si tu flujo es lineal (caso → A → B → C → D), colapsá los 4 tools en uno:
|
||||
|
||||
```yaml
|
||||
# Mal — el agente puede explotar con recursion_limit
|
||||
tools:
|
||||
- get_company
|
||||
- search_credits
|
||||
- build_profile
|
||||
- package_zip
|
||||
- upload
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Bien — agente invoca 1 tool, backend orquesta el resto
|
||||
tools:
|
||||
- run_full_case
|
||||
```
|
||||
|
||||
El backend ejecuta los 4 pasos internamente y emite `write_audit` por cada
|
||||
uno. La UI reconstruye el timeline igual. El agente es feliz y no muere
|
||||
por recursion.
|
||||
|
||||
---
|
||||
|
||||
## Checklist antes de mergear un tool nuevo
|
||||
|
||||
- [ ] ¿Usa `@observable_tool`, no `@tool` directo?
|
||||
- [ ] ¿Tiene `description` (docstring) clara que el LLM va a leer?
|
||||
- [ ] ¿El input model tiene `@field_validator(mode="before")` para todo int/list?
|
||||
- [ ] ¿Lee `BASE_URL` del env, no hardcoded?
|
||||
- [ ] ¿Si es OpenAPI, tiene `description` y `security` per-operation?
|
||||
- [ ] ¿El agente que lo usa tiene <10 tools después de agregarlo?
|
||||
- [ ] ¿Hay un scenario de eval que cubra la happy path?
|
||||
- [ ] ¿`./evals/lint_wxo_yaml.py` pasa?
|
||||
253
docs/wxo-best-practices.md
Normal file
253
docs/wxo-best-practices.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# WxO Best Practices
|
||||
|
||||
Destiladas de Cotemar (`cotemar-poc-n1`) y Dun (`dun-casos-prueba`). Cada regla
|
||||
fue paga con dolor — el linter en `evals/lint_wxo_yaml.py` enforcea las
|
||||
críticas, el resto es disciplina humana.
|
||||
|
||||
## 🏗 Arquitectura de agentes
|
||||
|
||||
### A1 — Máximo 10 tools por agente
|
||||
LLM se confunde con catálogos grandes. Más de 10 → degrada la precisión de
|
||||
selección de tool. Si necesitás más, partí en specialists. **(enforced)**
|
||||
|
||||
### A2 — Especialistas por dominio, nada de "todistas"
|
||||
Un agente que sabe AD + Ops + RRHH falla más que tres que saben cada uno lo
|
||||
suyo. Aunque sean específicos chicos, separá. **(enforced — orchestrator no puede tener tools de remediación)**
|
||||
|
||||
### A3 — El orchestrator nunca remedia
|
||||
Su único poder es clasificar, delegar y crear/leer tickets. Si necesitás
|
||||
`reset_password`, `restart_service`, `create_user` — eso vive en un
|
||||
specialist. **(enforced)**
|
||||
|
||||
### A4 — Specialists se importan ANTES que el orchestrator
|
||||
El orchestrator declara a los specialists como `collaborators`. Si no
|
||||
existen aún en el tenant, el import del orchestrator falla. El
|
||||
`deploy-wxo.sh` ya respeta el orden, no lo cambies.
|
||||
|
||||
### A5 — Una KB por agente (least privilege) cuando hay KB
|
||||
El especialista AD no debe poder leer el runbook de Ops. Si todos
|
||||
comparten una KB, el LLM cita el runbook equivocado y razona desde la
|
||||
fuente equivocada.
|
||||
|
||||
### A6 — KB es opcional
|
||||
Agentes API-driven o MCP-driven no necesitan KB. **No declares KB vacía
|
||||
solo "por completitud"** — eso confunde el linter. Si no la necesitás,
|
||||
omitila. **(enforced — agente sin KB ni tools falla)**
|
||||
|
||||
### A7 — Patrón "meta-tool" para flujos lineales
|
||||
**Crítico.** langgraph en WxO tiene `recursion_limit=30` hardcoded. Cada
|
||||
turno ReAct quema ~10 frames → más de 2 tool calls explota. Si tu flujo es
|
||||
**lineal y conocido** (caso A → B → C → D), colapsá los 4 tools en uno
|
||||
solo (`run_full_case`) que el backend orquesta internamente. El agente
|
||||
solo invoca esa meta-tool. La observabilidad de los substeps se preserva
|
||||
con `write_audit` (ver §O1).
|
||||
|
||||
## 🧠 Prompting
|
||||
|
||||
### P1 — Hardcodear el modelo, documentar el fallback
|
||||
`llm: groq/openai/gpt-oss-120b` es el preferred para tool-calling.
|
||||
Fallback: `meta-llama/llama-3-3-70b-instruct` PERO con la advertencia de
|
||||
que después del turno ~3 emite tool calls como texto plano (ver
|
||||
`known-issues.md`).
|
||||
|
||||
### P2 — REGLA #0 explícita en el prompt
|
||||
> "NUNCA escribas la tool call como texto. Usá el protocolo de tool_calls."
|
||||
|
||||
Esto **es necesario** porque el LLM (especialmente llama) tiene drift y
|
||||
empieza a printear tool calls. Codificar el anti-pattern en el prompt
|
||||
reduce la frecuencia.
|
||||
|
||||
### P3 — Instrucciones con escenarios concretos
|
||||
No "sos un agente útil que ayuda con tickets". Sí: ejemplos enumerados de
|
||||
input → razonamiento → output esperado. Mínimo 3 ejemplos por agente. Ver
|
||||
`cotemar-poc-n1/wxo/agents/mesa_n1_cotemar.agent.yaml` como referencia.
|
||||
|
||||
### P4 — Tabla de escalamiento explícita en el prompt
|
||||
Si el agente puede escalar, la decisión "qué razón → qué grupo → qué
|
||||
persona" tiene que estar tabulada en el prompt, no implícita. El LLM no
|
||||
inventa, sigue tablas.
|
||||
|
||||
### P5 — `style: react` por defecto
|
||||
Para agentes con tools. Si no hace tool calling, podés usar `style: chat`,
|
||||
pero es raro. **(enforced — si un agente declara tools pero no `react`, warning)**
|
||||
|
||||
## 🛠 Tools
|
||||
|
||||
### T1 — `@observable_tool` siempre, nunca `@tool` directo
|
||||
El decorator captura inputs/outputs/latency/side_effects y los emite
|
||||
como traza estructurada. Sin esto perdés visibilidad y las evals no pueden
|
||||
verificar comportamiento intermedio. **(enforced)**
|
||||
|
||||
### T2 — Coerción de tipos en todo schema Pydantic
|
||||
LLMs estringifican ints y arrays (mandan `"95727067"` cuando el schema
|
||||
pide `int`). Pydantic 422 → agent falla. Toda input model lleva:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, field_validator
|
||||
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list
|
||||
|
||||
class MyInput(BaseModel):
|
||||
company_id: int
|
||||
groups: list[str]
|
||||
_coerce_company_id = field_validator("company_id", mode="before")(_coerce_int)
|
||||
_coerce_groups = field_validator("groups", mode="before")(_coerce_list)
|
||||
```
|
||||
|
||||
**(enforced)**
|
||||
|
||||
### T3 — `_compat.py` inline en cada archivo de tools
|
||||
La ADK importa cada `tools.py` standalone. Si tu archivo hace
|
||||
`from .._compat import ...`, el TRM falla con `ModuleNotFoundError`.
|
||||
El shim de compat se inlinea al inicio de cada archivo. El template ya
|
||||
lo trae. **(enforced)**
|
||||
|
||||
### T4 — Toda tool lee `BASE_URL` del environment de su connection
|
||||
Nunca hardcodear la URL del sistema externo. La connection inyecta
|
||||
`BASE_URL`. Cambio de tenant = cambio de credencial, no de código.
|
||||
|
||||
### T5 — Una connection = un dominio = un `app_id`
|
||||
3 conexiones (unus, dynatrace, ad) en Cotemar = 3 dominios. Mezclar
|
||||
varios dominios en una connection rompe el aislamiento de credenciales.
|
||||
|
||||
### T6 — Conexiones en BOTH `draft` Y `live`
|
||||
Si solo configurás `draft`, el `agents deploy --env live` falla con
|
||||
`kid not found`. El `deploy-wxo.sh` hace los dos pases siempre.
|
||||
|
||||
### T7 — Tipos de tool: elegir el correcto
|
||||
- **OpenAPI** (preferido si tenés backend): un solo spec, ADK importa todo, version-controlled
|
||||
- **Python `@tool`** (preferido para mocks): rápido para prototipar, fácil de testear
|
||||
- **MCP** (preferido si el sistema externo lo expone): trae types + permisos
|
||||
- Ver `tool-authoring-guide.md` para el árbol de decisión.
|
||||
|
||||
### T8 — OpenAPI: `description` per-operation OBLIGATORIO
|
||||
ADK falla con "No description provided" si solo hay `summary`. Si tu
|
||||
backend usa FastAPI sin docstrings, exponer el spec así:
|
||||
|
||||
```python
|
||||
op["description"] = op.get("summary") or "Endpoint XYZ"
|
||||
```
|
||||
|
||||
**(enforced — el linter lee el spec y verifica)**
|
||||
|
||||
### T9 — OpenAPI: `security` per-operation, NO global
|
||||
ADK no hereda el `security` global. Re-declaralo en cada operación.
|
||||
|
||||
### T10 — Filtrar el spec OpenAPI que exponés a WxO
|
||||
No mandes el openapi.json completo de tu backend. Filtralo a una
|
||||
allowlist `PUBLIC_TOOLS` y resolvé `$ref` transitivamente para que solo
|
||||
se expongan los schemas usados. Ver `wxo/tools/openapi/_backend_filter_endpoint.py`.
|
||||
|
||||
## 🔌 Conexiones
|
||||
|
||||
### C1 — `key_value` para mocks sin auth, `api_key` para producción
|
||||
Mocks demo: `auth_type: key_value` con un solo campo `BASE_URL`. Producción:
|
||||
`auth_type: api_key` con header **custom** (no `Authorization`).
|
||||
|
||||
### C2 — Header custom, no Bearer
|
||||
Usar `Authorization: Bearer …` choca con el IAM token que Orchestrate ya
|
||||
inyecta para llamar al tool. Usar `X-Orchestrate-Token` o similar.
|
||||
|
||||
### C3 — `set-credentials` en cada deploy
|
||||
Tu `deploy-wxo.sh` invoca `connections set-credentials` siempre, no solo
|
||||
al crear. Rotación de secretos = re-deploy del script.
|
||||
|
||||
### C4 — Fallback de subcomandos ADK
|
||||
Diferentes versiones de la ADK usan `set-identifier --url` vs
|
||||
`configure --url`. El script intenta los dos y warnea si ninguno existe.
|
||||
|
||||
## 📚 Knowledge Bases
|
||||
|
||||
### KB1 — Runbooks como `.txt` con secciones fijas
|
||||
- **Trigger** (cuándo aplica)
|
||||
- **Precondiciones** (qué chequeo antes)
|
||||
- **Pasos** (1, 2, 3 — verbo en imperativo)
|
||||
- **Éxito** (qué confirma que salió bien)
|
||||
- **Fallo** (qué hacer si falla cada paso)
|
||||
- **Escalamiento** (a quién/cómo si excede autoridad)
|
||||
|
||||
### KB2 — Embeddings: `ibm/slate-125m-english-rtrvr`
|
||||
Default sano para español + inglés mezclados. Si tu corpus es solo
|
||||
español, evaluá modelos multilingual de IBM.
|
||||
|
||||
## 🚢 Deploy / Coolify
|
||||
|
||||
### D1 — Healthcheck con `wget -qO-` o `curl -sf`
|
||||
`wget --spider` es buggy en busybox → marca container unhealthy → Traefik
|
||||
da 503. **(enforced en lint del docker-compose)**
|
||||
|
||||
### D2 — NO declarar labels Traefik manuales en docker-compose
|
||||
Coolify v4 los autogenera del panel FQDN. Si los declarás vos, sale
|
||||
"TRAEFIK DEFAULT CERT" en lugar de Let's Encrypt. Solo:
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=coolify"
|
||||
```
|
||||
|
||||
### D3 — Network split: `internal: true` para servicios sin internet
|
||||
Solo el web/landing va al network `coolify`. Backend, DB, workers → red
|
||||
interna. Mejor aislamiento + cero exposición accidental.
|
||||
|
||||
### D4 — `--force-recreate` después de cambiar `.env`
|
||||
`restart` no relee variables de entorno. Si cambiás `.env`, hacé
|
||||
`docker compose up -d --force-recreate`.
|
||||
|
||||
## 🔍 Observabilidad
|
||||
|
||||
### O1 — `write_audit("tool.<name>", payload)` desde el backend
|
||||
Cada handler escribe un audit log con el `case_id` o `correlation_id`. La
|
||||
UI reconstruye el timeline desde acá, no desde trazas de Orchestrate
|
||||
(que son volátiles y no queryable).
|
||||
|
||||
### O2 — Outputs persistidos para granular retry
|
||||
`ExecutionStep.output_payload` guarda `content_b64`, `zip_b64`,
|
||||
`package_url`. Si el caso falla en el paso 4, reanudás desde 3 sin
|
||||
regenerar 1-2.
|
||||
|
||||
### O3 — Webhook receiver con HMAC-SHA256
|
||||
Si recibís webhooks de WxO (Plan A o run lifecycle), validá firma:
|
||||
`X-Orchestrate-Timestamp` + `Nonce` + `Signature`, skew tolerance 300s,
|
||||
408 si stale, 401 si firma mala.
|
||||
|
||||
### O4 — Watchdog para estados atascados
|
||||
Si un caso queda en `executing` >90s, el siguiente `GET` lo auto-promueve
|
||||
a `paused` para que el usuario reintente. Sin esto, conexiones caídas
|
||||
dejan basura.
|
||||
|
||||
## 🧪 Evals
|
||||
|
||||
### E1 — Cuatro capas de eval
|
||||
1. **Native** — `orchestrate agents test <agent> --input-file scenario.json` (fixture-based)
|
||||
2. **Agent behavior** — verifica delegation/escalation correcta sobre dataset
|
||||
3. **Runbook compliance** — verifica que el ticket final tenga campos esperados
|
||||
4. **Web layer** — Playwright o requests asserts sobre la UI
|
||||
|
||||
Ver `eval-strategy.md` para detalles.
|
||||
|
||||
### E2 — Fixtures JSON versionados
|
||||
Bajo `evals/scenarios/*.json`, ejecutables con `orchestrate agents test`.
|
||||
Cada scenario cubre un caso (happy path, edge case, failure).
|
||||
|
||||
### E3 — Smoke test en bash
|
||||
`evals/smoke-test.sh` cubre login → create → execute → poll → assert.
|
||||
Útil para CI y para reproducir bugs reportados.
|
||||
|
||||
### E4 — Direct backend probe
|
||||
`evals/direct-backend-probe.sh` golpea el backend directamente con curl +
|
||||
el token para aislar "¿es problema del agente o de la auth?". 200 con
|
||||
error de lógica → agent issue. 401 → connection/credentials.
|
||||
|
||||
## 🚦 Versionado del ADK
|
||||
|
||||
### V1 — Pin a `ibm-watsonx-orchestrate==2.1.0`
|
||||
Cambios entre minor versions han roto schemas. `requirements.txt` pinea
|
||||
exact version.
|
||||
|
||||
### V2 — `check-adk-version.sh` avisa de versiones nuevas
|
||||
Compara el pin con el último en PyPI y warneaa. **NO actualices sin
|
||||
probar todas las evals primero**.
|
||||
|
||||
---
|
||||
|
||||
Para ver dónde se aplica cada regla, leé el código del linter:
|
||||
[`../evals/lint_wxo_yaml.py`](../evals/lint_wxo_yaml.py).
|
||||
65
evals/direct-backend-probe.sh
Executable file
65
evals/direct-backend-probe.sh
Executable 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
72
evals/eval-agents.sh
Executable 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
196
evals/lint_wxo_yaml.py
Normal 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
141
evals/runner.py
Normal 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())
|
||||
10
evals/scenarios/_template_scenario.input.json
Normal file
10
evals/scenarios/_template_scenario.input.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
48
evals/scenarios/_template_scenario.yaml
Normal file
48
evals/scenarios/_template_scenario.yaml
Normal 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
55
evals/smoke-test.sh
Executable 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 "════════════════════════════════════════"
|
||||
16
mocks/_example_mock/Dockerfile
Normal file
16
mocks/_example_mock/Dockerfile
Normal 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"]
|
||||
0
mocks/_example_mock/app/__init__.py
Normal file
0
mocks/_example_mock/app/__init__.py
Normal file
135
mocks/_example_mock/app/main.py
Normal file
135
mocks/_example_mock/app/main.py
Normal 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(),
|
||||
}
|
||||
3
mocks/_example_mock/requirements.txt
Normal file
3
mocks/_example_mock/requirements.txt
Normal 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
43
scripts/check-adk-version.sh
Executable 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
176
scripts/deploy-wxo.sh
Executable 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
63
scripts/new-specialist.sh
Executable 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
56
scripts/reset-wxo.sh
Executable 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
33
scripts/undeploy-wxo.sh
Executable 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"
|
||||
168
skill/fit-wxo-bootstrap/SKILL.md
Normal file
168
skill/fit-wxo-bootstrap/SKILL.md
Normal 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
|
||||
@@ -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)
|
||||
73
skill/fit-wxo-bootstrap/templates/conversation-guide.md
Normal file
73
skill/fit-wxo-bootstrap/templates/conversation-guide.md
Normal 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)
|
||||
171
skill/fit-wxo-bootstrap/templates/parallel-dispatch.md
Normal file
171
skill/fit-wxo-bootstrap/templates/parallel-dispatch.md
Normal 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)
|
||||
16
web/_default_fastapi_htmx/Dockerfile
Normal file
16
web/_default_fastapi_htmx/Dockerfile
Normal 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"]
|
||||
0
web/_default_fastapi_htmx/app/__init__.py
Normal file
0
web/_default_fastapi_htmx/app/__init__.py
Normal file
141
web/_default_fastapi_htmx/app/main.py
Normal file
141
web/_default_fastapi_htmx/app/main.py
Normal 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,
|
||||
})
|
||||
36
web/_default_fastapi_htmx/app/templates/_timeline_rows.html
Normal file
36
web/_default_fastapi_htmx/app/templates/_timeline_rows.html
Normal 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 %}
|
||||
39
web/_default_fastapi_htmx/app/templates/base.html
Normal file
39
web/_default_fastapi_htmx/app/templates/base.html
Normal 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>
|
||||
40
web/_default_fastapi_htmx/app/templates/index.html
Normal file
40
web/_default_fastapi_htmx/app/templates/index.html
Normal 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 %}
|
||||
13
web/_default_fastapi_htmx/app/templates/traces.html
Normal file
13
web/_default_fastapi_htmx/app/templates/traces.html
Normal 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 %}
|
||||
7
web/_default_fastapi_htmx/requirements.txt
Normal file
7
web/_default_fastapi_htmx/requirements.txt
Normal 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
|
||||
111
wxo/agents/_template-orchestrator.agent.yaml
Normal file
111
wxo/agents/_template-orchestrator.agent.yaml
Normal 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"
|
||||
71
wxo/agents/_template-single-meta.agent.yaml
Normal file
71
wxo/agents/_template-single-meta.agent.yaml
Normal 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"
|
||||
85
wxo/agents/_template-specialist.agent.yaml
Normal file
85
wxo/agents/_template-specialist.agent.yaml
Normal 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: []
|
||||
29
wxo/connections/_template-apikey-header.yaml
Normal file
29
wxo/connections/_template-apikey-header.yaml
Normal 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
|
||||
25
wxo/connections/_template-keyvalue.yaml
Normal file
25
wxo/connections/_template-keyvalue.yaml
Normal 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
|
||||
34
wxo/connections/_template-mcp.yaml
Normal file
34
wxo/connections/_template-mcp.yaml
Normal 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
|
||||
24
wxo/knowledge_base/_template.kb.yaml
Normal file
24
wxo/knowledge_base/_template.kb.yaml
Normal 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
|
||||
35
wxo/knowledge_base/runbooks/_template-runbook.txt
Normal file
35
wxo/knowledge_base/runbooks/_template-runbook.txt
Normal 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
|
||||
47
wxo/tools/mcp/_template_mcp_connection.yaml
Normal file
47
wxo/tools/mcp/_template_mcp_connection.yaml
Normal 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
|
||||
108
wxo/tools/openapi/_backend_filter_endpoint.py
Normal file
108
wxo/tools/openapi/_backend_filter_endpoint.py
Normal 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
|
||||
80
wxo/tools/openapi/_template_openapi_spec.yaml
Normal file
80
wxo/tools/openapi/_template_openapi_spec.yaml
Normal 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" }
|
||||
100
wxo/tools/openapi/_webhook_validator.py
Normal file
100
wxo/tools/openapi/_webhook_validator.py
Normal 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
|
||||
90
wxo/tools/python/_coercion_helpers.py
Normal file
90
wxo/tools/python/_coercion_helpers.py
Normal 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
|
||||
178
wxo/tools/python/_observable_tool.py
Normal file
178
wxo/tools/python/_observable_tool.py
Normal 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]}
|
||||
136
wxo/tools/python/_template_tools.py
Normal file
136
wxo/tools/python/_template_tools.py
Normal 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()
|
||||
2
wxo/tools/python/requirements.txt
Normal file
2
wxo/tools/python/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests>=2.31.0
|
||||
pydantic>=2.5.0
|
||||
Reference in New Issue
Block a user