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