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>
315 lines
8.7 KiB
Markdown
315 lines
8.7 KiB
Markdown
# 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?
|