feat: v1 — boilerplate WxO + web
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:
314
docs/tool-authoring-guide.md
Normal file
314
docs/tool-authoring-guide.md
Normal 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?
|
||||
Reference in New Issue
Block a user