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,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY web/_default_fastapi_htmx/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY web/_default_fastapi_htmx/app/ ./app/
EXPOSE 8000
# Healthcheck con `wget -qO-` — NO `--spider` (issue I-008)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,141 @@
"""Web layer default — FastAPI + Jinja2 + HTMX.
Estructura mínima viable para demos / control planes. Incluye:
- / landing con embed WxO
- /traces timeline observable de tool calls
- /api/traces endpoint que recibe trazas del decorator @observable_tool
- /health healthcheck para Coolify/Traefik
Reemplazá lo que necesites o usá esto como base para extender.
"""
from __future__ import annotations
import json
import os
import sqlite3
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static"
TRACES_DB = os.environ.get("TRACES_DB_PATH", "/data/traces.db")
# WxO embed config (capturar tras deploy)
WXO_AGENT_ID = os.environ.get("WXO_AGENT_ID", "")
WXO_INSTANCE_URL = os.environ.get("WXO_INSTANCE_URL", "")
def _init_db():
"""Crea la tabla de trazas si no existe."""
Path(TRACES_DB).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(TRACES_DB)
conn.execute("""
CREATE TABLE IF NOT EXISTS traces (
trace_id TEXT PRIMARY KEY,
tool TEXT,
domain TEXT,
agent_caller TEXT,
correlation_id TEXT,
started_at TEXT,
duration_ms INTEGER,
payload TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS ix_started ON traces (started_at)")
conn.execute("CREATE INDEX IF NOT EXISTS ix_agent ON traces (agent_caller)")
conn.commit()
conn.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
_init_db()
yield
app = FastAPI(title="Boilerplate Web Layer", lifespan=lifespan)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# ─── Routes ─────────────────────────────────────────────────────────────────
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def landing(request: Request):
return templates.TemplateResponse("index.html", {
"request": request,
"wxo_agent_id": WXO_AGENT_ID,
"wxo_instance_url": WXO_INSTANCE_URL,
})
@app.get("/traces", response_class=HTMLResponse)
def traces_page(request: Request):
return templates.TemplateResponse("traces.html", {"request": request})
@app.get("/api/traces/recent")
def api_traces_recent(since: str | None = None, limit: int = 100):
"""JSON endpoint — devuelve las últimas N trazas, opcionalmente desde un ISO timestamp."""
conn = sqlite3.connect(TRACES_DB)
if since:
rows = conn.execute(
"SELECT payload FROM traces WHERE started_at > ? ORDER BY started_at DESC LIMIT ?",
(since, limit)
).fetchall()
else:
rows = conn.execute(
"SELECT payload FROM traces ORDER BY started_at DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return {"traces": [json.loads(r[0]) for r in rows]}
@app.post("/api/traces")
async def api_traces_post(request: Request):
"""Endpoint que recibe trazas del decorator @observable_tool con TRACE_SINK=http."""
trace: dict[str, Any] = await request.json()
conn = sqlite3.connect(TRACES_DB)
conn.execute(
"INSERT OR REPLACE INTO traces VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
trace.get("trace_id"), trace.get("tool"), trace.get("domain"),
trace.get("agent_caller"), trace.get("correlation_id"),
trace.get("started_at"), trace.get("duration_ms"),
json.dumps(trace),
),
)
conn.commit()
conn.close()
return {"ok": True}
@app.get("/partials/timeline", response_class=HTMLResponse)
def partial_timeline(request: Request):
"""HTMX partial — la tabla de trazas para refrescar via hx-trigger."""
conn = sqlite3.connect(TRACES_DB)
rows = conn.execute(
"SELECT payload FROM traces ORDER BY started_at DESC LIMIT 50"
).fetchall()
conn.close()
traces = [json.loads(r[0]) for r in rows]
return templates.TemplateResponse("_timeline_rows.html", {
"request": request, "traces": traces,
})

View File

@@ -0,0 +1,36 @@
{% if traces %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Agent</th>
<th>Tool</th>
<th>Domain</th>
<th>Duration</th>
<th>Status</th>
<th>Trace ID</th>
</tr>
</thead>
<tbody>
{% for t in traces %}
<tr>
<td><code>{{ t.started_at[:19] if t.started_at else "—" }}</code></td>
<td>{{ t.agent_caller or "—" }}</td>
<td><code>{{ t.tool }}</code></td>
<td>{{ t.domain or "—" }}</td>
<td>{{ t.duration_ms }} ms</td>
<td>
{% if t.error %}
<span class="badge badge--err">error</span>
{% else %}
<span class="badge badge--ok">ok</span>
{% endif %}
</td>
<td><code>{{ t.trace_id }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: #6b7280; padding: 20px; text-align: center;">No hay trazas aún. Ejecutá algo en el agente.</p>
{% endif %}

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}fit-boilerplate-wox{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #f7f8fa; color: #1a1d24; }
header { background: #0f172a; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 24px; }
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
header nav a { color: #cbd5e1; text-decoration: none; font-size: 14px; margin-right: 16px; }
header nav a:hover { color: white; }
main { max-width: 1200px; margin: 0 auto; padding: 24px; }
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; font-size: 13px; }
th { background: #f3f4f6; font-weight: 600; color: #374151; }
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; }
.badge--ok { background: #dcfce7; color: #166534; }
.badge--err { background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
<header>
<h1>fit-boilerplate-wox</h1>
<nav>
<a href="/">Landing</a>
<a href="/traces">Traces</a>
<a href="/health">Health</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Landing — fit-boilerplate-wox{% endblock %}
{% block content %}
<div class="card">
<h2>Boilerplate WxO + Web</h2>
<p>Esta es la landing por defecto del template. Reemplazala con tu UI específica.</p>
<ul>
<li><strong>WxO Agent ID:</strong> <code>{{ wxo_agent_id or "(no configurado)" }}</code></li>
<li><strong>WxO Instance:</strong> <code>{{ wxo_instance_url or "(no configurado)" }}</code></li>
</ul>
</div>
<div class="card">
<h3>Chat con el agente</h3>
{% if wxo_agent_id %}
<div id="wxo-chat-root"></div>
<script>
// REPLACE: pegá el snippet completo que devuelve `orchestrate channels create webchat`
// window.wxoChat = {
// apiKey: "...",
// agentId: "{{ wxo_agent_id }}",
// agentEnvironmentId: "REPLACE_ENV_UUID",
// locale: "es"
// };
</script>
<!-- <script src="https://CHANGEME/webchat.js" defer></script> -->
<p style="color: #6b7280; font-size: 13px;">
⚠ Acordate de activar <strong>Embed Security = Off</strong> en la consola WxO (issue I-007).
</p>
{% else %}
<p style="color: #6b7280;">El embed se activa cuando completes <code>WXO_AGENT_ID</code> en <code>.env</code>.</p>
{% endif %}
</div>
<div class="card">
<h3>Ver trazas de tool calls</h3>
<p><a href="/traces">→ /traces (timeline observable)</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Traces — fit-boilerplate-wox{% endblock %}
{% block content %}
<div class="card">
<h2>Tool call traces</h2>
<p style="color: #6b7280;">Auto-refresh cada 2 segundos. Las trazas vienen del decorator <code>@observable_tool</code>.</p>
</div>
<div class="card" hx-get="/partials/timeline" hx-trigger="load, every 2s" hx-swap="innerHTML">
<p>Cargando…</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,7 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
jinja2>=3.1.0
python-multipart>=0.0.6
sqlalchemy>=2.0.0
pydantic>=2.5.0
httpx>=0.26.0