# WxO Best Practices Destiladas de Cotemar (`cotemar-poc-n1`) y Dun (`dun-casos-prueba`). Cada regla fue paga con dolor — el linter en `evals/lint_wxo_yaml.py` enforcea las críticas, el resto es disciplina humana. ## 🏗 Arquitectura de agentes ### A1 — Máximo 10 tools por agente LLM se confunde con catálogos grandes. Más de 10 → degrada la precisión de selección de tool. Si necesitás más, partí en specialists. **(enforced)** ### A2 — Especialistas por dominio, nada de "todistas" Un agente que sabe AD + Ops + RRHH falla más que tres que saben cada uno lo suyo. Aunque sean específicos chicos, separá. **(enforced — orchestrator no puede tener tools de remediación)** ### A3 — El orchestrator nunca remedia Su único poder es clasificar, delegar y crear/leer tickets. Si necesitás `reset_password`, `restart_service`, `create_user` — eso vive en un specialist. **(enforced)** ### A4 — Specialists se importan ANTES que el orchestrator El orchestrator declara a los specialists como `collaborators`. Si no existen aún en el tenant, el import del orchestrator falla. El `deploy-wxo.sh` ya respeta el orden, no lo cambies. ### A5 — Una KB por agente (least privilege) cuando hay KB El especialista AD no debe poder leer el runbook de Ops. Si todos comparten una KB, el LLM cita el runbook equivocado y razona desde la fuente equivocada. ### A6 — KB es opcional Agentes API-driven o MCP-driven no necesitan KB. **No declares KB vacía solo "por completitud"** — eso confunde el linter. Si no la necesitás, omitila. **(enforced — agente sin KB ni tools falla)** ### A7 — Patrón "meta-tool" para flujos lineales **Crítico.** langgraph en WxO tiene `recursion_limit=30` hardcoded. Cada turno ReAct quema ~10 frames → más de 2 tool calls explota. Si tu flujo es **lineal y conocido** (caso A → B → C → D), colapsá los 4 tools en uno solo (`run_full_case`) que el backend orquesta internamente. El agente solo invoca esa meta-tool. La observabilidad de los substeps se preserva con `write_audit` (ver §O1). ## 🧠 Prompting ### P1 — Hardcodear el modelo, documentar el fallback `llm: groq/openai/gpt-oss-120b` es el preferred para tool-calling. Fallback: `meta-llama/llama-3-3-70b-instruct` PERO con la advertencia de que después del turno ~3 emite tool calls como texto plano (ver `known-issues.md`). ### P2 — REGLA #0 explícita en el prompt > "NUNCA escribas la tool call como texto. Usá el protocolo de tool_calls." Esto **es necesario** porque el LLM (especialmente llama) tiene drift y empieza a printear tool calls. Codificar el anti-pattern en el prompt reduce la frecuencia. ### P3 — Instrucciones con escenarios concretos No "sos un agente útil que ayuda con tickets". Sí: ejemplos enumerados de input → razonamiento → output esperado. Mínimo 3 ejemplos por agente. Ver `cotemar-poc-n1/wxo/agents/mesa_n1_cotemar.agent.yaml` como referencia. ### P4 — Tabla de escalamiento explícita en el prompt Si el agente puede escalar, la decisión "qué razón → qué grupo → qué persona" tiene que estar tabulada en el prompt, no implícita. El LLM no inventa, sigue tablas. ### P5 — `style: react` por defecto Para agentes con tools. Si no hace tool calling, podés usar `style: chat`, pero es raro. **(enforced — si un agente declara tools pero no `react`, warning)** ## 🛠 Tools ### T1 — `@observable_tool` siempre, nunca `@tool` directo El decorator captura inputs/outputs/latency/side_effects y los emite como traza estructurada. Sin esto perdés visibilidad y las evals no pueden verificar comportamiento intermedio. **(enforced)** ### T2 — Coerción de tipos en todo schema Pydantic LLMs estringifican ints y arrays (mandan `"95727067"` cuando el schema pide `int`). Pydantic 422 → agent falla. Toda input model lleva: ```python from pydantic import BaseModel, field_validator from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list class MyInput(BaseModel): company_id: int groups: list[str] _coerce_company_id = field_validator("company_id", mode="before")(_coerce_int) _coerce_groups = field_validator("groups", mode="before")(_coerce_list) ``` **(enforced)** ### T3 — `_compat.py` inline en cada archivo de tools La ADK importa cada `tools.py` standalone. Si tu archivo hace `from .._compat import ...`, el TRM falla con `ModuleNotFoundError`. El shim de compat se inlinea al inicio de cada archivo. El template ya lo trae. **(enforced)** ### T4 — Toda tool lee `BASE_URL` del environment de su connection Nunca hardcodear la URL del sistema externo. La connection inyecta `BASE_URL`. Cambio de tenant = cambio de credencial, no de código. ### T5 — Una connection = un dominio = un `app_id` 3 conexiones (unus, dynatrace, ad) en Cotemar = 3 dominios. Mezclar varios dominios en una connection rompe el aislamiento de credenciales. ### T6 — Conexiones en BOTH `draft` Y `live` Si solo configurás `draft`, el `agents deploy --env live` falla con `kid not found`. El `deploy-wxo.sh` hace los dos pases siempre. ### T7 — Tipos de tool: elegir el correcto - **OpenAPI** (preferido si tenés backend): un solo spec, ADK importa todo, version-controlled - **Python `@tool`** (preferido para mocks): rápido para prototipar, fácil de testear - **MCP** (preferido si el sistema externo lo expone): trae types + permisos - Ver `tool-authoring-guide.md` para el árbol de decisión. ### T8 — OpenAPI: `description` per-operation OBLIGATORIO ADK falla con "No description provided" si solo hay `summary`. Si tu backend usa FastAPI sin docstrings, exponer el spec así: ```python op["description"] = op.get("summary") or "Endpoint XYZ" ``` **(enforced — el linter lee el spec y verifica)** ### T9 — OpenAPI: `security` per-operation, NO global ADK no hereda el `security` global. Re-declaralo en cada operación. ### T10 — Filtrar el spec OpenAPI que exponés a WxO No mandes el openapi.json completo de tu backend. Filtralo a una allowlist `PUBLIC_TOOLS` y resolvé `$ref` transitivamente para que solo se expongan los schemas usados. Ver `wxo/tools/openapi/_backend_filter_endpoint.py`. ## 🔌 Conexiones ### C1 — `key_value` para mocks sin auth, `api_key` para producción Mocks demo: `auth_type: key_value` con un solo campo `BASE_URL`. Producción: `auth_type: api_key` con header **custom** (no `Authorization`). ### C2 — Header custom, no Bearer Usar `Authorization: Bearer …` choca con el IAM token que Orchestrate ya inyecta para llamar al tool. Usar `X-Orchestrate-Token` o similar. ### C3 — `set-credentials` en cada deploy Tu `deploy-wxo.sh` invoca `connections set-credentials` siempre, no solo al crear. Rotación de secretos = re-deploy del script. ### C4 — Fallback de subcomandos ADK Diferentes versiones de la ADK usan `set-identifier --url` vs `configure --url`. El script intenta los dos y warnea si ninguno existe. ## 📚 Knowledge Bases ### KB1 — Runbooks como `.txt` con secciones fijas - **Trigger** (cuándo aplica) - **Precondiciones** (qué chequeo antes) - **Pasos** (1, 2, 3 — verbo en imperativo) - **Éxito** (qué confirma que salió bien) - **Fallo** (qué hacer si falla cada paso) - **Escalamiento** (a quién/cómo si excede autoridad) ### KB2 — Embeddings: `ibm/slate-125m-english-rtrvr` Default sano para español + inglés mezclados. Si tu corpus es solo español, evaluá modelos multilingual de IBM. ## 🚢 Deploy / Coolify ### D1 — Healthcheck con `wget -qO-` o `curl -sf` `wget --spider` es buggy en busybox → marca container unhealthy → Traefik da 503. **(enforced en lint del docker-compose)** ### D2 — NO declarar labels Traefik manuales en docker-compose Coolify v4 los autogenera del panel FQDN. Si los declarás vos, sale "TRAEFIK DEFAULT CERT" en lugar de Let's Encrypt. Solo: ```yaml labels: - "traefik.enable=true" - "traefik.docker.network=coolify" ``` ### D3 — Network split: `internal: true` para servicios sin internet Solo el web/landing va al network `coolify`. Backend, DB, workers → red interna. Mejor aislamiento + cero exposición accidental. ### D4 — `--force-recreate` después de cambiar `.env` `restart` no relee variables de entorno. Si cambiás `.env`, hacé `docker compose up -d --force-recreate`. ## 🔍 Observabilidad ### O1 — `write_audit("tool.", payload)` desde el backend Cada handler escribe un audit log con el `case_id` o `correlation_id`. La UI reconstruye el timeline desde acá, no desde trazas de Orchestrate (que son volátiles y no queryable). ### O2 — Outputs persistidos para granular retry `ExecutionStep.output_payload` guarda `content_b64`, `zip_b64`, `package_url`. Si el caso falla en el paso 4, reanudás desde 3 sin regenerar 1-2. ### O3 — Webhook receiver con HMAC-SHA256 Si recibís webhooks de WxO (Plan A o run lifecycle), validá firma: `X-Orchestrate-Timestamp` + `Nonce` + `Signature`, skew tolerance 300s, 408 si stale, 401 si firma mala. ### O4 — Watchdog para estados atascados Si un caso queda en `executing` >90s, el siguiente `GET` lo auto-promueve a `paused` para que el usuario reintente. Sin esto, conexiones caídas dejan basura. ## 🧪 Evals ### E1 — Cuatro capas de eval 1. **Native** — `orchestrate agents test --input-file scenario.json` (fixture-based) 2. **Agent behavior** — verifica delegation/escalation correcta sobre dataset 3. **Runbook compliance** — verifica que el ticket final tenga campos esperados 4. **Web layer** — Playwright o requests asserts sobre la UI Ver `eval-strategy.md` para detalles. ### E2 — Fixtures JSON versionados Bajo `evals/scenarios/*.json`, ejecutables con `orchestrate agents test`. Cada scenario cubre un caso (happy path, edge case, failure). ### E3 — Smoke test en bash `evals/smoke-test.sh` cubre login → create → execute → poll → assert. Útil para CI y para reproducir bugs reportados. ### E4 — Direct backend probe `evals/direct-backend-probe.sh` golpea el backend directamente con curl + el token para aislar "¿es problema del agente o de la auth?". 200 con error de lógica → agent issue. 401 → connection/credentials. ## 🚦 Versionado del ADK ### V1 — Pin a `ibm-watsonx-orchestrate==2.1.0` Cambios entre minor versions han roto schemas. `requirements.txt` pinea exact version. ### V2 — `check-adk-version.sh` avisa de versiones nuevas Compara el pin con el último en PyPI y warneaa. **NO actualices sin probar todas las evals primero**. --- Para ver dónde se aplica cada regla, leé el código del linter: [`../evals/lint_wxo_yaml.py`](../evals/lint_wxo_yaml.py).