From b089c2ef188a2e57feb468ff21898b0bba41a853 Mon Sep 17 00:00:00 2001 From: Felipe Arentsen Date: Sat, 16 May 2026 14:59:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v1=20=E2=80=94=20boilerplate=20WxO=20+?= =?UTF-8?q?=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/agents/backend-tool-builder.md | 138 ++++++ .claude/agents/eval-author.md | 101 +++++ .claude/agents/mock-builder.md | 92 ++++ .claude/agents/runbook-author.md | 83 ++++ .claude/agents/web-layer-builder.md | 170 ++++++++ .claude/agents/wxo-agent-author.md | 76 ++++ .claude/agents/wxo-architect.md | 102 +++++ .claude/agents/wxo-tool-author.md | 94 ++++ .env.example | 42 ++ .gitea/workflows/ci.yml | 36 ++ .gitignore | 41 ++ INSTRUCCIONES.md | 81 ++++ README.md | 112 +++++ docker-compose.local.yml | 18 + docker-compose.yml | 93 ++++ docs/DEPLOY_TO_NEW_WOX.md | 218 ++++++++++ docs/INDEX.md | 35 ++ docs/RUNBOOK.md | 178 ++++++++ docs/adk-2x-cheatsheet.md | 262 +++++++++++ docs/architecture-patterns.md | 192 +++++++++ docs/deployment-guide.md | 149 +++++++ docs/eval-strategy.md | 247 +++++++++++ docs/known-issues.md | 407 ++++++++++++++++++ docs/observability-pattern.md | 178 ++++++++ docs/tool-authoring-guide.md | 314 ++++++++++++++ docs/wxo-best-practices.md | 253 +++++++++++ evals/direct-backend-probe.sh | 65 +++ evals/eval-agents.sh | 72 ++++ evals/lint_wxo_yaml.py | 196 +++++++++ evals/runner.py | 141 ++++++ evals/scenarios/_template_scenario.input.json | 10 + evals/scenarios/_template_scenario.yaml | 48 +++ evals/smoke-test.sh | 55 +++ mocks/_example_mock/Dockerfile | 16 + mocks/_example_mock/app/__init__.py | 0 mocks/_example_mock/app/main.py | 135 ++++++ mocks/_example_mock/requirements.txt | 3 + scripts/check-adk-version.sh | 43 ++ scripts/deploy-wxo.sh | 176 ++++++++ scripts/new-specialist.sh | 63 +++ scripts/reset-wxo.sh | 56 +++ scripts/undeploy-wxo.sh | 33 ++ skill/fit-wxo-bootstrap/SKILL.md | 168 ++++++++ .../templates/architect-prompt-template.md | 56 +++ .../templates/conversation-guide.md | 73 ++++ .../templates/parallel-dispatch.md | 171 ++++++++ web/_default_fastapi_htmx/Dockerfile | 16 + web/_default_fastapi_htmx/app/__init__.py | 0 web/_default_fastapi_htmx/app/main.py | 141 ++++++ .../app/templates/_timeline_rows.html | 36 ++ .../app/templates/base.html | 39 ++ .../app/templates/index.html | 40 ++ .../app/templates/traces.html | 13 + web/_default_fastapi_htmx/requirements.txt | 7 + wxo/agents/_template-orchestrator.agent.yaml | 111 +++++ wxo/agents/_template-single-meta.agent.yaml | 71 +++ wxo/agents/_template-specialist.agent.yaml | 85 ++++ wxo/connections/_template-apikey-header.yaml | 29 ++ wxo/connections/_template-keyvalue.yaml | 25 ++ wxo/connections/_template-mcp.yaml | 34 ++ wxo/knowledge_base/_template.kb.yaml | 24 ++ .../runbooks/_template-runbook.txt | 35 ++ wxo/tools/mcp/_template_mcp_connection.yaml | 47 ++ wxo/tools/openapi/_backend_filter_endpoint.py | 108 +++++ wxo/tools/openapi/_template_openapi_spec.yaml | 80 ++++ wxo/tools/openapi/_webhook_validator.py | 100 +++++ wxo/tools/python/_coercion_helpers.py | 90 ++++ wxo/tools/python/_observable_tool.py | 178 ++++++++ wxo/tools/python/_template_tools.py | 136 ++++++ wxo/tools/python/requirements.txt | 2 + 70 files changed, 6739 insertions(+) create mode 100644 .claude/agents/backend-tool-builder.md create mode 100644 .claude/agents/eval-author.md create mode 100644 .claude/agents/mock-builder.md create mode 100644 .claude/agents/runbook-author.md create mode 100644 .claude/agents/web-layer-builder.md create mode 100644 .claude/agents/wxo-agent-author.md create mode 100644 .claude/agents/wxo-architect.md create mode 100644 .claude/agents/wxo-tool-author.md create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 INSTRUCCIONES.md create mode 100644 README.md create mode 100644 docker-compose.local.yml create mode 100644 docker-compose.yml create mode 100644 docs/DEPLOY_TO_NEW_WOX.md create mode 100644 docs/INDEX.md create mode 100644 docs/RUNBOOK.md create mode 100644 docs/adk-2x-cheatsheet.md create mode 100644 docs/architecture-patterns.md create mode 100644 docs/deployment-guide.md create mode 100644 docs/eval-strategy.md create mode 100644 docs/known-issues.md create mode 100644 docs/observability-pattern.md create mode 100644 docs/tool-authoring-guide.md create mode 100644 docs/wxo-best-practices.md create mode 100755 evals/direct-backend-probe.sh create mode 100755 evals/eval-agents.sh create mode 100644 evals/lint_wxo_yaml.py create mode 100644 evals/runner.py create mode 100644 evals/scenarios/_template_scenario.input.json create mode 100644 evals/scenarios/_template_scenario.yaml create mode 100755 evals/smoke-test.sh create mode 100644 mocks/_example_mock/Dockerfile create mode 100644 mocks/_example_mock/app/__init__.py create mode 100644 mocks/_example_mock/app/main.py create mode 100644 mocks/_example_mock/requirements.txt create mode 100755 scripts/check-adk-version.sh create mode 100755 scripts/deploy-wxo.sh create mode 100755 scripts/new-specialist.sh create mode 100755 scripts/reset-wxo.sh create mode 100755 scripts/undeploy-wxo.sh create mode 100644 skill/fit-wxo-bootstrap/SKILL.md create mode 100644 skill/fit-wxo-bootstrap/templates/architect-prompt-template.md create mode 100644 skill/fit-wxo-bootstrap/templates/conversation-guide.md create mode 100644 skill/fit-wxo-bootstrap/templates/parallel-dispatch.md create mode 100644 web/_default_fastapi_htmx/Dockerfile create mode 100644 web/_default_fastapi_htmx/app/__init__.py create mode 100644 web/_default_fastapi_htmx/app/main.py create mode 100644 web/_default_fastapi_htmx/app/templates/_timeline_rows.html create mode 100644 web/_default_fastapi_htmx/app/templates/base.html create mode 100644 web/_default_fastapi_htmx/app/templates/index.html create mode 100644 web/_default_fastapi_htmx/app/templates/traces.html create mode 100644 web/_default_fastapi_htmx/requirements.txt create mode 100644 wxo/agents/_template-orchestrator.agent.yaml create mode 100644 wxo/agents/_template-single-meta.agent.yaml create mode 100644 wxo/agents/_template-specialist.agent.yaml create mode 100644 wxo/connections/_template-apikey-header.yaml create mode 100644 wxo/connections/_template-keyvalue.yaml create mode 100644 wxo/connections/_template-mcp.yaml create mode 100644 wxo/knowledge_base/_template.kb.yaml create mode 100644 wxo/knowledge_base/runbooks/_template-runbook.txt create mode 100644 wxo/tools/mcp/_template_mcp_connection.yaml create mode 100644 wxo/tools/openapi/_backend_filter_endpoint.py create mode 100644 wxo/tools/openapi/_template_openapi_spec.yaml create mode 100644 wxo/tools/openapi/_webhook_validator.py create mode 100644 wxo/tools/python/_coercion_helpers.py create mode 100644 wxo/tools/python/_observable_tool.py create mode 100644 wxo/tools/python/_template_tools.py create mode 100644 wxo/tools/python/requirements.txt diff --git a/.claude/agents/backend-tool-builder.md b/.claude/agents/backend-tool-builder.md new file mode 100644 index 0000000..c8cf8c2 --- /dev/null +++ b/.claude/agents/backend-tool-builder.md @@ -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: +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) diff --git a/.claude/agents/eval-author.md b/.claude/agents/eval-author.md new file mode 100644 index 0000000..5735811 --- /dev/null +++ b/.claude/agents/eval-author.md @@ -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: +runbook_id: <01 | 02 | ...> +scenario_type: happy_path | edge_case | failure | escalation +business_input: "" +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_.yaml` + +```yaml +name: +agent: +input: "" +expect: + agent_response_contains: + - "" + - "" + tool_calls_in_order: + - tool: + inputs.: "" + - tool: + no_tool_calls: + - +final_state: + # query opcional + # expect_count + # expect_fields +``` + +### 2. `evals/scenarios/scenario_.input.json` + +```json +{ + "_agent": "", + "_description": "", + "messages": [ + { "role": "user", "content": "" } + ] +} +``` + +## 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) diff --git a/.claude/agents/mock-builder.md b/.claude/agents/mock-builder.md new file mode 100644 index 0000000..618bf7d --- /dev/null +++ b/.claude/agents/mock-builder.md @@ -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: +endpoints: [ + { method, path, request_schema, response_schema, behavior_description } +] +seed_data: +behaviors: [ + "lookup_user(juan.perez) → activo, depto Finanzas", + "reset_password(activo) → temp_password generado", + "reset_password(inactivo) → 404" +] +``` + +## Estructura del mock + +``` +mocks// +├── 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// +docker run -p 8000:8000 mock-test +curl localhost:8000/health +curl -X POST localhost:8000/ -d '{"...":"..."}' +``` diff --git a/.claude/agents/runbook-author.md b/.claude/agents/runbook-author.md new file mode 100644 index 0000000..25acc9c --- /dev/null +++ b/.claude/agents/runbook-author.md @@ -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 — + +## Trigger +- + +## Precondiciones +- + +## Pasos +1. +2. ... + +## Éxito +- + +## Fallo +- §1 +- §2 ... + +## Escalamiento +- + +## Notas +- +``` + +## 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-.txt` + +Luego también: +- Agregar el path al `documents:` del KB YAML correspondiente + (`wxo/knowledge_base/kb_.kb.yaml`) diff --git a/.claude/agents/web-layer-builder.md b/.claude/agents/web-layer-builder.md new file mode 100644 index 0000000..b0f8c17 --- /dev/null +++ b/.claude/agents/web-layer-builder.md @@ -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 +
+ + +``` + +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 +
+
+``` + +**Modo B (React):** +```ts +function usePolling(fetcher: () => Promise, isDone: (data: T) => boolean) { + const [data, setData] = useState(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) diff --git a/.claude/agents/wxo-agent-author.md b/.claude/agents/wxo-agent-author.md new file mode 100644 index 0000000..0a0026f --- /dev/null +++ b/.claude/agents/wxo-agent-author.md @@ -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: # ej: ad_specialist_acme +display_name: +description: <2-3 sentences> +domain: +tools: [tool_a, tool_b, ...] # nombres de tools que el agente puede invocar +collaborators: [name1, ...] # solo si role=orchestrator +knowledge_base: | none +client_name: +business_context: <2-5 sentences explicando QUÉ hace el agente y CÓMO razona> +escalation_table: +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/.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. diff --git a/.claude/agents/wxo-architect.md b/.claude/agents/wxo-architect.md new file mode 100644 index 0000000..f5095e5 --- /dev/null +++ b/.claude/agents/wxo-architect.md @@ -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] +- [ ] ... +``` diff --git a/.claude/agents/wxo-tool-author.md b/.claude/agents/wxo-tool-author.md new file mode 100644 index 0000000..ab2dd07 --- /dev/null +++ b/.claude/agents/wxo-tool-author.md @@ -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: +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 ` 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/_tools.py` +- OpenAPI: `wxo/tools/openapi/_spec.yaml` +- MCP: `wxo/tools/mcp/_mcp.yaml` diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..727c03a --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# ─── WxO tenant — usado por orchestrate CLI y Plan A webhook ───────────────── +# Pattern: https://api..watson-orchestrate.cloud.ibm.com/instances/ +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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6bb3500 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7101d11 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/INSTRUCCIONES.md b/INSTRUCCIONES.md new file mode 100644 index 0000000..4583922 --- /dev/null +++ b/INSTRUCCIONES.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c85d349 --- /dev/null +++ b/README.md @@ -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. diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..527d0cd --- /dev/null +++ b/docker-compose.local.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..644c95e --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/DEPLOY_TO_NEW_WOX.md b/docs/DEPLOY_TO_NEW_WOX.md new file mode 100644 index 0000000..c83352f --- /dev/null +++ b/docs/DEPLOY_TO_NEW_WOX.md @@ -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 ` 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. diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..3af2dad --- /dev/null +++ b/docs/INDEX.md @@ -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 diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 0000000..d046907 --- /dev/null +++ b/docs/RUNBOOK.md @@ -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 --env live --type team --kind +orchestrate connections set-credentials -a --env live -e "BASE_URL=..." + +# Re-deployar agente +orchestrate agents deploy --name --env live +``` + +### Coolify dio 503 después de redeploy (issue I-008) +1. `docker logs ` → 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=` +3. `docker compose up -d --force-recreate` (NO `restart`, issue I-011) +4. `orchestrate env activate ` con el nuevo key + +### Credenciales de connection +```bash +orchestrate connections set-credentials -a --env draft -e "BASE_URL=..." -e "API_KEY=" +orchestrate connections set-credentials -a --env live -e "BASE_URL=..." -e "API_KEY=" +``` + +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=` +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 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 --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. diff --git a/docs/adk-2x-cheatsheet.md b/docs/adk-2x-cheatsheet.md new file mode 100644 index 0000000..74ffdd8 --- /dev/null +++ b/docs/adk-2x-cheatsheet.md @@ -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 ` + + + +
+

fit-boilerplate-wox

+ +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/web/_default_fastapi_htmx/app/templates/index.html b/web/_default_fastapi_htmx/app/templates/index.html new file mode 100644 index 0000000..4614568 --- /dev/null +++ b/web/_default_fastapi_htmx/app/templates/index.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Landing — fit-boilerplate-wox{% endblock %} + +{% block content %} +
+

Boilerplate WxO + Web

+

Esta es la landing por defecto del template. Reemplazala con tu UI específica.

+
    +
  • WxO Agent ID: {{ wxo_agent_id or "(no configurado)" }}
  • +
  • WxO Instance: {{ wxo_instance_url or "(no configurado)" }}
  • +
+
+ +
+

Chat con el agente

+ {% if wxo_agent_id %} +
+ + +

+ ⚠ Acordate de activar Embed Security = Off en la consola WxO (issue I-007). +

+ {% else %} +

El embed se activa cuando completes WXO_AGENT_ID en .env.

+ {% endif %} +
+ +
+

Ver trazas de tool calls

+

→ /traces (timeline observable)

+
+{% endblock %} diff --git a/web/_default_fastapi_htmx/app/templates/traces.html b/web/_default_fastapi_htmx/app/templates/traces.html new file mode 100644 index 0000000..205eb93 --- /dev/null +++ b/web/_default_fastapi_htmx/app/templates/traces.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Traces — fit-boilerplate-wox{% endblock %} + +{% block content %} +
+

Tool call traces

+

Auto-refresh cada 2 segundos. Las trazas vienen del decorator @observable_tool.

+
+ +
+

Cargando…

+
+{% endblock %} diff --git a/web/_default_fastapi_htmx/requirements.txt b/web/_default_fastapi_htmx/requirements.txt new file mode 100644 index 0000000..4347323 --- /dev/null +++ b/web/_default_fastapi_htmx/requirements.txt @@ -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 diff --git a/wxo/agents/_template-orchestrator.agent.yaml b/wxo/agents/_template-orchestrator.agent.yaml new file mode 100644 index 0000000..cd646f6 --- /dev/null +++ b/wxo/agents/_template-orchestrator.agent.yaml @@ -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" diff --git a/wxo/agents/_template-single-meta.agent.yaml b/wxo/agents/_template-single-meta.agent.yaml new file mode 100644 index 0000000..5ef703e --- /dev/null +++ b/wxo/agents/_template-single-meta.agent.yaml @@ -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: . + Detalle de pasos: ." + +# 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" diff --git a/wxo/agents/_template-specialist.agent.yaml b/wxo/agents/_template-specialist.agent.yaml new file mode 100644 index 0000000..c9cc227 --- /dev/null +++ b/wxo/agents/_template-specialist.agent.yaml @@ -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: [] diff --git a/wxo/connections/_template-apikey-header.yaml b/wxo/connections/_template-apikey-header.yaml new file mode 100644 index 0000000..69aac34 --- /dev/null +++ b/wxo/connections/_template-apikey-header.yaml @@ -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 diff --git a/wxo/connections/_template-keyvalue.yaml b/wxo/connections/_template-keyvalue.yaml new file mode 100644 index 0000000..467d6e4 --- /dev/null +++ b/wxo/connections/_template-keyvalue.yaml @@ -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 diff --git a/wxo/connections/_template-mcp.yaml b/wxo/connections/_template-mcp.yaml new file mode 100644 index 0000000..767a8b3 --- /dev/null +++ b/wxo/connections/_template-mcp.yaml @@ -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 diff --git a/wxo/knowledge_base/_template.kb.yaml b/wxo/knowledge_base/_template.kb.yaml new file mode 100644 index 0000000..2f22cf9 --- /dev/null +++ b/wxo/knowledge_base/_template.kb.yaml @@ -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 diff --git a/wxo/knowledge_base/runbooks/_template-runbook.txt b/wxo/knowledge_base/runbooks/_template-runbook.txt new file mode 100644 index 0000000..d896570 --- /dev/null +++ b/wxo/knowledge_base/runbooks/_template-runbook.txt @@ -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 diff --git a/wxo/tools/mcp/_template_mcp_connection.yaml b/wxo/tools/mcp/_template_mcp_connection.yaml new file mode 100644 index 0000000..37b91bc --- /dev/null +++ b/wxo/tools/mcp/_template_mcp_connection.yaml @@ -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 diff --git a/wxo/tools/openapi/_backend_filter_endpoint.py b/wxo/tools/openapi/_backend_filter_endpoint.py new file mode 100644 index 0000000..9398ed4 --- /dev/null +++ b/wxo/tools/openapi/_backend_filter_endpoint.py @@ -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 diff --git a/wxo/tools/openapi/_template_openapi_spec.yaml b/wxo/tools/openapi/_template_openapi_spec.yaml new file mode 100644 index 0000000..0ac6fb5 --- /dev/null +++ b/wxo/tools/openapi/_template_openapi_spec.yaml @@ -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" } diff --git a/wxo/tools/openapi/_webhook_validator.py b/wxo/tools/openapi/_webhook_validator.py new file mode 100644 index 0000000..8f4e229 --- /dev/null +++ b/wxo/tools/openapi/_webhook_validator.py @@ -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 diff --git a/wxo/tools/python/_coercion_helpers.py b/wxo/tools/python/_coercion_helpers.py new file mode 100644 index 0000000..48045f1 --- /dev/null +++ b/wxo/tools/python/_coercion_helpers.py @@ -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 diff --git a/wxo/tools/python/_observable_tool.py b/wxo/tools/python/_observable_tool.py new file mode 100644 index 0000000..3d7b5fb --- /dev/null +++ b/wxo/tools/python/_observable_tool.py @@ -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]} diff --git a/wxo/tools/python/_template_tools.py b/wxo/tools/python/_template_tools.py new file mode 100644 index 0000000..0182ae8 --- /dev/null +++ b/wxo/tools/python/_template_tools.py @@ -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() diff --git a/wxo/tools/python/requirements.txt b/wxo/tools/python/requirements.txt new file mode 100644 index 0000000..41de05e --- /dev/null +++ b/wxo/tools/python/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +pydantic>=2.5.0