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>
5.7 KiB
Observability Pattern
Cada tool wrapper en este template emite trazas estructuradas por defecto. La capa web las consume, las muestra en una vista timeline, y las evals las usan para verificar que el agente llamó las tools correctas en el orden correcto.
El contrato
Toda tool, sin importar el lenguaje, devuelve este envelope:
{
"result": { /* el resultado real que el LLM consume */ },
"trace": {
"trace_id": "ad-reset-7f3a",
"tool": "reset_password",
"domain": "ad",
"started_at": "2026-05-16T14:23:01.123Z",
"duration_ms": 187,
"inputs": { "username": "juan.perez@cotemar.com" },
"side_effects": [
{ "type": "http_call", "method": "POST", "url": "...", "status": 200, "duration_ms": 142 }
],
"observed_state_before": { "can_reset": true, "user_active": true },
"observed_state_after": { "password_rotated": true, "expires_at": "..." },
"agent_caller": "ad_specialist_cotemar",
"correlation_id": "case-abcd-1234"
}
}
El LLM ve solo result. La traza va al sink configurado y no contamina
el razonamiento del agente.
Decorator Python (@observable_tool)
Reemplaza al @tool directo de la ADK:
from wxo.tools.python._observable_tool import observable_tool
@observable_tool(name="reset_password", domain="ad")
def reset_password(username: str) -> dict:
# tu lógica normal
return {"temp_password": "xxx", "expires_in": "24h"}
El decorator:
- Genera un
trace_idúnico - Captura
started_aty mideduration_ms - Serializa
inputs - Intercepta llamadas HTTP (si usa
requestspatched) y las anota enside_effects - Llama tu función
- Captura
result - Emite la traza al sink configurado vía env
TRACE_SINK - Devuelve
{result, trace}envuelto
La función decorada sigue siendo un @tool válido para la ADK — el
decorator delega.
Sinks soportados
Tres modos según el caso, controlados por env var TRACE_SINK:
TRACE_SINK=sqlite (default dev)
Trazas se escriben a traces.db SQLite en el container del web layer.
Útil para dev y CI.
TRACE_SINK=http
POST a $TRACE_SINK_URL (default http://web:8000/api/traces).
Pattern Cotemar: el web layer recibe trazas live y las muestra en una
vista timeline filterable.
TRACE_SINK=otlp
OpenTelemetry OTLP gRPC a $OTEL_EXPORTER_OTLP_ENDPOINT. Producción.
Compatible con Jaeger, Dynatrace, Grafana Tempo, etc.
Backend integrado (estilo Dun) — write_audit
Cuando la tool no es un wrapper Python sino un endpoint del backend
(patrón Dun con OpenAPI import), el equivalente es write_audit:
# backend/app/audit.py
def write_audit(case_id: str, event: str, payload: dict):
db.execute(
"INSERT INTO audit_logs (case_id, event, payload, ts) VALUES (?, ?, ?, ?)",
(case_id, event, json.dumps(payload), datetime.utcnow())
)
# backend/app/api/v1/orchestrate_tools.py
@router.post("/reset-password")
def reset_password(input: ResetPasswordInput, case_id: str):
write_audit(case_id, "tool.reset_password.started", {"input": input.dict()})
result = ad_client.reset(input.username)
write_audit(case_id, "tool.reset_password.completed", {"result": result})
return result
El backend reconstruye el timeline desde audit_logs, no desde trazas
volátiles de Orchestrate.
Schema unificado para multi-lenguaje
Si tu wrapper es Node/Java/Go, el decorator equivalente debe emitir el mismo envelope JSON. Los templates están en:
wxo/tools/_observability/
├── python/observable_tool.py
├── node/observable-tool.ts (TODO en v1, escribilo si lo necesitás)
├── java/ObservableTool.java (TODO en v1)
└── README.md ← el contrato schema
Vista timeline en el web layer
El web/_default_fastapi_htmx/ viene con:
POST /api/traces— endpoint que recibe trazas- Tabla
tracesen SQLite con índices en(agent_caller, started_at)y(trace_id) GET /traces— vista HTMX con timeline- Componente que polea
GET /traces/recent?since=...cada 2s
Captura visual:
14:23:01.123 ad_specialist reset_password(juan.perez@cotemar.com)
├─ HTTP POST ad/reset-password 142ms 200
└─ result: { temp_password: "xxx" } 187ms total
14:23:02.456 ad_specialist create_ticket(...)
└─ HTTP POST unus/api/tickets 89ms 201
└─ result: { ticket_id: "TKT-A1B2" } 103ms total
Evals que consumen trazas
Las evals de behavior verifican comportamiento intermedio, no solo el resultado final:
# evals/scenarios/scenario_reset_password.yaml
input: "Necesito resetear la contraseña de juan.perez@cotemar.com"
expect:
agent_response_contains: ["TKT-"]
tool_calls_in_order:
- tool: lookup_user
inputs.username: "juan.perez@cotemar.com"
- tool: reset_password
- tool: create_ticket
inputs.status: "RESOLVED"
no_tool_calls:
- escalate_to_n2
El runner lee las trazas emitidas durante el run y matchea contra
expect. Si el agente llamó las tools en el orden equivocado, falla
aunque el ticket final esté bien.
Regla del linter
evals/lint_wxo_yaml.py falla si encuentra un @tool(...) sin
@observable_tool(...) en cualquier archivo bajo wxo/tools/python/.
No hay excepción. Si necesitás bypass para debug, marcalo:
@observable_tool(name="x", domain="y", _bypass_tracing=True) # explícito
def x(): ...
Costo
El decorator agrega ~2-5ms de overhead por call. Para 99% de los casos
es invisible. Si tenés un tool ultra-hot que llama 1000 veces por minuto,
configurá TRACE_SINK=sqlite con buffer batch o desactiválo con
_bypass_tracing=True (con audit a mano).