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