feat: v1 — boilerplate WxO + web
Some checks failed
CI — Lint + Evals / lint (push) Has been cancelled
CI — Lint + Evals / smoke (push) Has been cancelled

Boilerplate completo para construir soluciones agénticas sobre IBM
watsonx Orchestrate (ADK 2.x) con una capa web encima.

Destilado de cotemar-poc-n1 y dun-casos-prueba. Incluye:

- 11 docs (best practices, architecture patterns, ADK cheatsheet,
  observability, tool authoring, deployment, RUNBOOK, DEPLOY_TO_NEW_WOX,
  known-issues con 16 errores reales y fix, eval strategy, INDEX)
- 13 templates WxO (orchestrator/specialist/single-meta agents,
  3 connections, KB + runbook, observable_tool decorator, coercion
  helpers, Python tools, OpenAPI spec, backend filter endpoint, webhook
  validator HMAC, MCP connection)
- 6 scripts (deploy idempotente con fallback ADK, undeploy, reset,
  check-adk-version, new-specialist scaffold, eval-agents runner)
- 4 eval pieces (linter de best practices, runner, smoke-test, direct
  backend probe) + scenario templates
- 8 subagentes Claude (.claude/agents/) — wxo-architect, wxo-agent-author,
  wxo-tool-author, runbook-author, mock-builder, backend-tool-builder,
  eval-author, web-layer-builder
- Skill bundle fit-wxo-bootstrap/ (SKILL.md + 3 templates) listo para
  copiar a ~/.claude/skills/
- Web layer default FastAPI+HTMX con vista timeline observable + endpoint
  receptor de trazas
- Docker compose Coolify-ready (healthcheck wget -qO-, sin labels Traefik,
  network split internal:true)
- CI Gitea workflow con lint + smoke

Best practices enforced por evals/lint_wxo_yaml.py:
- A1: máx 10 tools por agente
- A3: orchestrator sin tools de remediación
- A6: agente sin propósito (react sin tools ni KB)
- T1: @observable_tool obligatorio, no @tool directo
- T3: _compat shim inline en tools.py
- D1: docker-compose sin wget --spider
- I-005: OpenAPI con description per-operation
- I-009: sin labels Traefik manuales

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Felipe Arentsen
2026-05-16 14:59:44 +00:00
commit b089c2ef18
70 changed files with 6739 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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