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,314 @@
# Tool Authoring Guide
WxO acepta tools de tres formas. Acá te explico cuándo usar cuál y cómo.
## Decisión rápida
| Situación | Tipo |
|---|---|
| PoC / demo / mocks | **Python `@tool`** |
| Tu backend ya expone REST (FastAPI/Express/etc.) | **OpenAPI** |
| SaaS de terceros con MCP server | **MCP** |
| SaaS de terceros sin MCP | **OpenAPI** (escribís el spec) o **Python** wrapper |
---
## Tipo 1 — Python `@tool` (estilo Cotemar)
### Cuándo
- Estás prototipando rápido
- Los sistemas externos son mocks separados
- La lógica del wrapper es simple
- Querés tener cada tool en su propia función testeable
### Template
`wxo/tools/python/_template_tools.py`:
```python
# === inline compat shim (NO eliminar — TRM lo necesita) ===
try:
from ibm_watsonx_orchestrate.agent_builder.tools import tool
except ImportError:
def tool(*args, **kwargs):
def deco(f): return f
return deco if not args else deco(args[0])
# ============================================================
import os
import requests
from pydantic import BaseModel, field_validator
from wxo.tools.python._observable_tool import observable_tool
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list
BASE_URL = os.environ.get("BASE_URL", "")
class ResetPasswordInput(BaseModel):
username: str
notify: bool = True
@observable_tool(name="reset_password", domain="ad")
def reset_password(username: str, notify: bool = True) -> dict:
"""Reset AD password and return temp credentials."""
resp = requests.post(
f"{BASE_URL}/users/{username}/reset-password",
json={"notify": notify},
timeout=10
)
resp.raise_for_status()
return resp.json()
```
### Import
```bash
orchestrate tools import -k python \
-f wxo/tools/python/ad_tools.py \
-r wxo/tools/python/requirements.txt \
--app-id ad_demo
```
### Gotchas
- **No usar imports relativos** (`from .._compat import ...`). Inlinear el shim. (Issue I-010)
- **Coerción de tipos siempre.** El LLM stringifica todo. (Issue I-003)
- **`BASE_URL` desde env**, nunca hardcoded.
---
## Tipo 2 — OpenAPI (estilo Dun)
### Cuándo
- Tu backend ya define endpoints REST con tipos
- Querés single-source-of-truth (el spec)
- Tenés FastAPI/Express/etc. y solo necesitás exponer un subset a WxO
### Template — endpoint público filtrado
`wxo/tools/openapi/_backend_filter_endpoint.py`:
```python
from copy import deepcopy
from fastapi import FastAPI
# Allowlist de operaciones que WxO puede ver
PUBLIC_TOOLS = {
"POST /api/v1/cases/run",
"GET /api/v1/cases/{case_id}",
"POST /api/v1/cases/{case_id}/approve",
}
def build_public_spec(app: FastAPI) -> dict:
"""Filtra el openapi.json de tu app a la allowlist PUBLIC_TOOLS,
fuerza description per-operation, y resuelve $ref transitivamente."""
full = app.openapi()
spec = {
"openapi": full["openapi"],
"info": {**full["info"], "title": full["info"]["title"] + " (orchestrate)"},
"servers": full.get("servers", [{"url": "https://CHANGEME/api/v1"}]),
"paths": {},
"components": {"schemas": {}},
"security": full.get("security", []),
}
# Filtrar paths
for path, item in full["paths"].items():
for method, op in item.items():
if method.upper() in ("GET", "POST", "PATCH", "PUT", "DELETE"):
key = f"{method.upper()} {path}"
if key in PUBLIC_TOOLS:
spec["paths"].setdefault(path, {})[method] = op
# description fallback (issue I-005)
op["description"] = op.get("description") or op.get("summary") or f"{method} {path}"
# security per-operation (issue I-006)
op["security"] = spec["security"]
# Resolver $ref transitivamente
needed_schemas = set()
def walk(o):
if isinstance(o, dict):
for k, v in o.items():
if k == "$ref" and isinstance(v, str) and v.startswith("#/components/schemas/"):
needed_schemas.add(v.split("/")[-1])
else:
walk(v)
elif isinstance(o, list):
for x in o: walk(x)
walk(spec["paths"])
all_schemas = full.get("components", {}).get("schemas", {})
# Resolver hasta closure
pending = list(needed_schemas)
while pending:
s = pending.pop()
if s not in all_schemas: continue
spec["components"]["schemas"][s] = all_schemas[s]
# Encontrar refs nuevos dentro
before = set(needed_schemas)
walk(all_schemas[s])
for new in needed_schemas - before:
pending.append(new)
return spec
# Endpoint que sirve el spec filtrado
@app.get("/api/v1/orchestrate-tools-spec.json")
def public_spec():
return build_public_spec(app)
```
### Patch del `servers[0].url` en deploy time
`scripts/deploy-wxo.sh` baja el spec del backend live y reemplaza el server URL:
```bash
curl -s "$BACKEND_URL/api/v1/orchestrate-tools-spec.json" \
| jq --arg url "$BACKEND_URL/api/v1" '.servers = [{"url": $url}]' \
> /tmp/spec.json
orchestrate tools import -k openapi -f /tmp/spec.json --app-id mi_backend
```
### Schema Pydantic con coerción
En tu backend, cada endpoint input model lleva field_validator:
```python
from pydantic import BaseModel, field_validator
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list
class RunCaseInput(BaseModel):
case_id: str
target_companyid: int
profile_ids: list[str]
_coerce_companyid = field_validator("target_companyid", mode="before")(_coerce_int)
_coerce_profiles = field_validator("profile_ids", mode="before")(_coerce_list)
```
### Observabilidad con `write_audit`
En cada handler:
```python
from app.audit import write_audit
@router.post("/cases/run")
def run_case(input: RunCaseInput, x_orchestrate_token: str = Header(...)):
write_audit(input.case_id, "tool.run_case.started", input.dict())
result = do_the_work(input)
write_audit(input.case_id, "tool.run_case.completed", result)
return result
```
---
## Tipo 3 — MCP
### Cuándo
- El SaaS ya expone un MCP server (HubSpot, Outline, GitHub vía MCP, etc.)
- Querés que ADK descubra las tools automáticamente
- Necesitás permisos del sistema externo respetados
### Template
`wxo/tools/mcp/_template_mcp_connection.yaml`:
```yaml
spec_version: v1
kind: connection
name: hubspot_mcp
display_name: "HubSpot via MCP"
schema_version: "1.0"
auth_type: mcp
identifier: hubspot_mcp_conn
preference:
- environment: draft
schema_id: mcp
- environment: live
schema_id: mcp
```
### Setup
```bash
orchestrate connections add -a hubspot_mcp
for ENV in draft live; do
orchestrate connections configure -a hubspot_mcp --env $ENV --type team --kind mcp
orchestrate connections set-credentials -a hubspot_mcp --env $ENV \
-e "MCP_SERVER_URL=https://hubspot-mcp.example.com" \
-e "MCP_TOKEN=$HUBSPOT_TOKEN"
done
```
ADK descubre las tools del server y las hace disponibles al agente. En el
YAML del agente listás las que necesita:
```yaml
tools:
- hubspot_mcp/get_contact
- hubspot_mcp/create_deal
```
### Caveats
- La latencia depende del MCP server. Algunos son lentos.
- Los nombres de tools vienen del MCP server, no los podés renombrar.
- Coordiná con el equipo del MCP server qué tools va a exponer.
---
## Híbrido
Un agente puede usar las tres formas al mismo tiempo:
```yaml
tools:
# MCP (descubiertas del server)
- hubspot_mcp/get_contact
- hubspot_mcp/update_deal
# OpenAPI (importadas del backend propio)
- run_full_case
- get_case_status
# Python (wrappers a mocks o sistemas pequeños)
- lookup_internal_user
```
Lo único que comparten es el contrato observable: cada call emite la
misma traza JSON.
---
## Patrón "meta-tool" (issue I-001)
Si tu flujo es lineal (caso → A → B → C → D), colapsá los 4 tools en uno:
```yaml
# Mal — el agente puede explotar con recursion_limit
tools:
- get_company
- search_credits
- build_profile
- package_zip
- upload
```
```yaml
# Bien — agente invoca 1 tool, backend orquesta el resto
tools:
- run_full_case
```
El backend ejecuta los 4 pasos internamente y emite `write_audit` por cada
uno. La UI reconstruye el timeline igual. El agente es feliz y no muere
por recursion.
---
## Checklist antes de mergear un tool nuevo
- [ ] ¿Usa `@observable_tool`, no `@tool` directo?
- [ ] ¿Tiene `description` (docstring) clara que el LLM va a leer?
- [ ] ¿El input model tiene `@field_validator(mode="before")` para todo int/list?
- [ ] ¿Lee `BASE_URL` del env, no hardcoded?
- [ ] ¿Si es OpenAPI, tiene `description` y `security` per-operation?
- [ ] ¿El agente que lo usa tiene <10 tools después de agregarlo?
- [ ] ¿Hay un scenario de eval que cubra la happy path?
- [ ] ¿`./evals/lint_wxo_yaml.py` pasa?