diff --git a/.gitignore b/.gitignore
index 91463bc..a88e8fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,8 +38,5 @@ env/
.DS_Store
Thumbs.db
-# Streamlit
-.streamlit/secrets.toml
-
# Notas de hand-off entre sesiones de Claude Code
claude-session.txt
diff --git a/Makefile b/Makefile
index 87d082a..b2c3f7c 100644
--- a/Makefile
+++ b/Makefile
@@ -4,16 +4,16 @@ help:
@echo "Targets: install lint format test test-all smoke up down logs clean"
install:
- pip install -r core/requirements.txt -r dashboard/requirements.txt
+ pip install -r core/requirements.txt
pip install pytest pytest-asyncio ruff mypy freezegun
lint:
- ruff check core/src dashboard/src tests
+ ruff check core/src tests
mypy core/src
format:
- ruff format core/src dashboard/src tests
- ruff check --fix core/src dashboard/src tests
+ ruff format core/src tests
+ ruff check --fix core/src tests
test:
pytest tests/unit -v
diff --git a/agents/incident_analyzer/versions/v1.yaml b/agents/incident_analyzer/versions/v1.yaml
index 8e0d30f..e34f6c5 100644
--- a/agents/incident_analyzer/versions/v1.yaml
+++ b/agents/incident_analyzer/versions/v1.yaml
@@ -37,4 +37,6 @@ output_schema:
type: object
required: [id, action, target, risk_score, rollback_plan, requires_approval]
risk_threshold_for_hitl: 4
+icon: "🤖"
+color: "#64748b"
updated_at: 2026-04-12T10:00:00Z
diff --git a/agents/incident_analyzer/versions/v2.yaml b/agents/incident_analyzer/versions/v2.yaml
index beafc3f..3dedecd 100644
--- a/agents/incident_analyzer/versions/v2.yaml
+++ b/agents/incident_analyzer/versions/v2.yaml
@@ -35,4 +35,6 @@ output_schema:
type: object
required: [id, action, target, risk_score, rollback_plan, requires_approval]
risk_threshold_for_hitl: 4
+icon: "🤖"
+color: "#f59e0b"
updated_at: 2026-05-01T12:00:00Z
diff --git a/core/Dockerfile b/core/Dockerfile
index 68cca4b..b4e67ee 100644
--- a/core/Dockerfile
+++ b/core/Dockerfile
@@ -30,6 +30,8 @@ USER agent
EXPOSE 8000
+# Nota: Este servicio ahora sirve tanto la API REST como la UI HTMX (Opción A)
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
diff --git a/core/requirements.txt b/core/requirements.txt
index 2a689d5..1c470a1 100644
--- a/core/requirements.txt
+++ b/core/requirements.txt
@@ -17,3 +17,7 @@ openai>=1.50,<2.0
PyYAML>=6.0,<7.0
jsonschema>=4.0,<5.0
nemoguardrails>=0.10,<0.12
+
+# HTMX UI embebida (Opción A)
+jinja2>=3.1,<4.0
+python-multipart>=0.0.9,<0.1
diff --git a/core/src/forja_core/domain/agent.py b/core/src/forja_core/domain/agent.py
index 8513b2f..cf54e20 100644
--- a/core/src/forja_core/domain/agent.py
+++ b/core/src/forja_core/domain/agent.py
@@ -45,5 +45,6 @@ class AgentDefinition(BaseModel):
input_placeholder: str | None = None
category: str | None = None
tags: list[str] = Field(default_factory=list)
- icon: str | None = None
+ icon: str | None = "🤖"
+ color: str | None = None
template: str = "governed_llm"
diff --git a/core/src/forja_core/main.py b/core/src/forja_core/main.py
index 458ca49..b9d1349 100644
--- a/core/src/forja_core/main.py
+++ b/core/src/forja_core/main.py
@@ -24,13 +24,19 @@ def create_app() -> FastAPI:
async def health() -> dict[str, str]:
return {"status": "ok"}
+ from forja_core.web import ui
from forja_core.api import agents, executions, policies, violations
+ # UI HTMX embebida (Opción A) — se registra primero para que las páginas
+ # tengan preferencia sobre los endpoints JSON cuando se accede desde navegador.
+ app.include_router(ui.router)
+
app.include_router(agents.router, prefix="/agents", tags=["agents"])
app.include_router(executions.invoke_router, prefix="/agents", tags=["agents"])
app.include_router(executions.router, prefix="/executions", tags=["executions"])
app.include_router(policies.router, prefix="/policies", tags=["policies"])
app.include_router(violations.router, prefix="/violations", tags=["violations"])
+
return app
diff --git a/core/src/forja_core/web/__init__.py b/core/src/forja_core/web/__init__.py
new file mode 100644
index 0000000..4434155
--- /dev/null
+++ b/core/src/forja_core/web/__init__.py
@@ -0,0 +1 @@
+"""Paquete de UI HTMX embebida (FastAPI + Jinja2 + HTMX)."""
diff --git a/core/src/forja_core/web/templates/agent_detail.html b/core/src/forja_core/web/templates/agent_detail.html
new file mode 100644
index 0000000..95b75b2
--- /dev/null
+++ b/core/src/forja_core/web/templates/agent_detail.html
@@ -0,0 +1,103 @@
+
+
+
+
+ {{ agent.name }}
+ v{{ agent.version }}
+
+ {{ agent.state }}
+
+
+
+ {{ agent.purpose }}
+
+
+
+
+
+
+
Owner
+
{{ agent.owner }}
+
+
+
Risk threshold (HITL)
+
{{ agent.risk_threshold_for_hitl }}
+
+ {% if agent.category %}
+
+
Categoría
+
{{ agent.category }}
+
+ {% endif %}
+
+
+
+
+
+
LLM
+
+ {{ agent.llm.provider }} / {{ agent.llm.model }}
+ temp={{ agent.llm.temperature }} · max={{ agent.llm.max_tokens }}
+
+
+
+
+
Guardrails
+
+ {% for g in agent.guardrails %}
+ {{ g }}
+ {% endfor %}
+
+
+
+
+
+
+
Comportamiento
+
+
+
+
+ Ver system prompt
+ expandir
+ colapsar
+
+
+ {{ agent.system_prompt }}
+
+
+
+
+
+
Output schema
+
{{ agent.output_schema | tojson(indent=2) }}
+
+
+
+
+ {% if agent.input_label or agent.input_placeholder or agent.updated_at %}
+
+
Metadatos
+
+ {% if agent.input_label %}
+
Input label: {{ agent.input_label }}
+ {% endif %}
+ {% if agent.input_placeholder %}
+
Placeholder: {{ agent.input_placeholder }}
+ {% endif %}
+
Última actualización: {{ agent.updated_at.strftime('%Y-%m-%d %H:%M') if agent.updated_at else '—' }}
+
+
+ {% endif %}
+
+ {% if agent.tags %}
+
+
Tags
+
+ {% for tag in agent.tags %}
+ {{ tag }}
+ {% endfor %}
+
+
+ {% endif %}
+
diff --git a/core/src/forja_core/web/templates/agent_detail_full.html b/core/src/forja_core/web/templates/agent_detail_full.html
new file mode 100644
index 0000000..f6ac41a
--- /dev/null
+++ b/core/src/forja_core/web/templates/agent_detail_full.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
{{ agent.name }} v{{ agent.version }}
+ {% include "agent_detail.html" %}
+
+{% endblock %}
diff --git a/core/src/forja_core/web/templates/agents.html b/core/src/forja_core/web/templates/agents.html
new file mode 100644
index 0000000..d8e5333
--- /dev/null
+++ b/core/src/forja_core/web/templates/agents.html
@@ -0,0 +1,44 @@
+{% extends "base.html" %}
+
+{% block content %}
+Agentes registrados
+
+
+
+
+
+
+
+
+
+
+ Selecciona un agente de la lista para ver su configuración y comportamiento.
+
+
+
+
+
+{% endblock %}
diff --git a/core/src/forja_core/web/templates/approvals.html b/core/src/forja_core/web/templates/approvals.html
new file mode 100644
index 0000000..453cbb0
--- /dev/null
+++ b/core/src/forja_core/web/templates/approvals.html
@@ -0,0 +1,38 @@
+{% extends "base.html" %}
+
+{% block content %}
+🤝 Aprobaciones Pendientes (HITL)
+
+
+{% for ex in pending %}
+
+
+
+
{{ ex.agent_name }} v{{ ex.agent_version }}
+
{{ ex.trace_id }}
+
+
AWAITING APPROVAL
+
+
+
+
+ Aprobar todo
+
+
+ Rechazar
+
+
+
+{% else %}
+
No hay ejecuciones esperando aprobación.
+{% endfor %}
+
+{% endblock %}
diff --git a/core/src/forja_core/web/templates/base.html b/core/src/forja_core/web/templates/base.html
new file mode 100644
index 0000000..f699a3b
--- /dev/null
+++ b/core/src/forja_core/web/templates/base.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ Forja • {{ title or "Gobernanza de Agentes IA" }}
+
+
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
diff --git a/core/src/forja_core/web/templates/history.html b/core/src/forja_core/web/templates/history.html
new file mode 100644
index 0000000..4834b23
--- /dev/null
+++ b/core/src/forja_core/web/templates/history.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+
+{% block content %}
+📜 Historial de Ejecuciones
+
+
+{% for ex in executions %}
+
+
+
{{ ex.trace_id }}
+
{{ ex.agent_name }} v{{ ex.agent_version }}
+
+
Status: {{ ex.status }}
+
+{% else %}
+
No hay ejecuciones todavía.
+{% endfor %}
+
+{% endblock %}
diff --git a/core/src/forja_core/web/templates/home.html b/core/src/forja_core/web/templates/home.html
new file mode 100644
index 0000000..76123c0
--- /dev/null
+++ b/core/src/forja_core/web/templates/home.html
@@ -0,0 +1,53 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
+ Forja
+
+
+
+ Gobernanza profesional para agentes de IA
+
+
+
+
+ Plataforma de control para agentes IA.
+ Versionado de definiciones y políticas, guardrails en ejecución,
+ aprobaciones humanas y observabilidad completa.
+
+
+
+
+
+
Versionado Git-like
+
Agentes y políticas como YAML versionados. Diffs, histórico y control explícito de cambios.
+
+
+
Guardrails runtime
+
Validación automática de entradas y salidas con Presidio y reglas declarativas antes de ejecutar.
+
+
+
Human-in-the-Loop
+
Pausa automática en acciones de alto riesgo. Aprobación o rechazo con trazabilidad completa.
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/src/forja_core/web/templates/policies.html b/core/src/forja_core/web/templates/policies.html
new file mode 100644
index 0000000..691b1f8
--- /dev/null
+++ b/core/src/forja_core/web/templates/policies.html
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+
+{% block content %}
+📐 Políticas
+
+
+{% for p in policies %}
+
+
{{ p.name }} v{{ p.version }}
+
{{ p.description or 'Sin descripción' }}
+
Validadores: {{ p.validators | length }}
+
+{% endfor %}
+
+{% endblock %}
diff --git a/core/src/forja_core/web/templates/run.html b/core/src/forja_core/web/templates/run.html
new file mode 100644
index 0000000..3fd8625
--- /dev/null
+++ b/core/src/forja_core/web/templates/run.html
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+
+{% block content %}
+▶️ Ejecutar Agente
+Invoca un agente con todo el gobierno (guardrails + HITL + observabilidad).
+
+
+
+
+{% endblock %}
diff --git a/core/src/forja_core/web/ui.py b/core/src/forja_core/web/ui.py
new file mode 100644
index 0000000..12afb2f
--- /dev/null
+++ b/core/src/forja_core/web/ui.py
@@ -0,0 +1,183 @@
+"""HTMX UI router — montado dentro del propio forja-core (Opción A)."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+
+from fastapi import APIRouter, Form, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+from forja_core.api.deps import get_orchestrator, get_policy_store, get_registry, get_settings
+from forja_core.api.persistence import read_execution_summaries
+from forja_core.domain.execution import AgentExecution
+
+router = APIRouter(prefix="", tags=["ui"])
+
+# Ruta robusta a los templates (funciona tanto en dev como dentro del contenedor)
+TEMPLATES_DIR = Path(__file__).parent / "templates"
+templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
+
+SLOGANS = [
+ "Forjando agentes con criterio",
+ "Agentes con forja, no con fe",
+ "Versión. Valida. Aprueba.",
+ "Cada decisión bien templada",
+ "Gobernanza que protege",
+ "Del prototipo al control real",
+]
+
+def get_slogan() -> str:
+ import random
+ return random.choice(SLOGANS)
+
+
+@router.get("/", response_class=HTMLResponse)
+async def home(request: Request) -> HTMLResponse:
+ return templates.TemplateResponse(
+ "home.html",
+ {"request": request, "title": "Inicio", "slogan": get_slogan()},
+ )
+
+
+@router.get("/agents", response_class=HTMLResponse)
+async def agents_page(request: Request) -> HTMLResponse:
+ registry = get_registry()
+ agents = registry.list_agents()
+ return templates.TemplateResponse(
+ "agents.html",
+ {"request": request, "title": "Agentes", "agents": agents, "slogan": get_slogan()},
+ )
+
+
+@router.get("/agents/{name}", response_class=HTMLResponse)
+async def agent_detail(request: Request, name: str) -> HTMLResponse:
+ registry = get_registry()
+ try:
+ agent = registry.get_agent(name)
+ except FileNotFoundError:
+ return HTMLResponse('Agente no encontrado
', status_code=404)
+
+ # Si viene por HTMX, devolvemos solo el fragmento (sin layout)
+ if request.headers.get("HX-Request"):
+ return templates.TemplateResponse(
+ "agent_detail.html",
+ {"request": request, "agent": agent, "slogan": get_slogan()},
+ )
+
+ # Acceso directo por navegador → página completa
+ return templates.TemplateResponse(
+ "agent_detail_full.html",
+ {"request": request, "title": agent.name, "agent": agent, "slogan": get_slogan()},
+ )
+
+
+@router.get("/run", response_class=HTMLResponse)
+async def run_page(request: Request) -> HTMLResponse:
+ registry = get_registry()
+ agents = registry.list_agents()
+ return templates.TemplateResponse(
+ "run.html",
+ {"request": request, "title": "Ejecutar", "agents": agents, "slogan": get_slogan()},
+ )
+
+
+@router.post("/run", response_class=HTMLResponse)
+async def run_invoke(
+ request: Request,
+ agent_name: str = Form(...),
+ input: str = Form(...),
+) -> HTMLResponse:
+ """Ejecuta el agente usando el orchestrator directamente (mismo proceso)."""
+ from forja_core.api.deps import get_policy_store
+
+ registry = get_registry()
+ policy_store = get_policy_store()
+ orchestrator = get_orchestrator()
+
+ try:
+ agent_def = registry.get_agent(agent_name) # resuelve la versión activa
+ policy_name = agent_def.guardrails[0] if agent_def.guardrails else "default"
+ policy = policy_store.get_policy(policy_name)
+
+ execution: AgentExecution = asyncio.run(
+ orchestrator.invoke(
+ agent_def=agent_def,
+ policy=policy,
+ user_input=input,
+ )
+ )
+ except Exception as e:
+ return HTMLResponse(f'Error: {str(e)[:300]}
', status_code=400)
+
+ # Resultado simple pero informativo
+ html = f"""
+
+
Ejecución completada
+
trace_id: {execution.trace_id}
+
Status: {execution.status}
+
+ Ver resultado completo
+ {execution.model_dump_json(indent=2)}
+
+
+ """
+ return HTMLResponse(html)
+
+
+@router.get("/policies", response_class=HTMLResponse)
+async def policies_page(request: Request) -> HTMLResponse:
+ policy_store = get_policy_store()
+ policies = policy_store.list_policies()
+ return templates.TemplateResponse(
+ "policies.html",
+ {"request": request, "title": "Políticas", "policies": policies, "slogan": get_slogan()},
+ )
+
+
+@router.get("/history", response_class=HTMLResponse)
+async def history_page(request: Request) -> HTMLResponse:
+ data_dir = get_settings().data_dir
+ executions = read_execution_summaries(data_dir)[:50]
+ return templates.TemplateResponse(
+ "history.html",
+ {"request": request, "title": "Historial", "executions": executions, "slogan": get_slogan()},
+ )
+
+
+# ====================== APROBACIONES (HITL) ======================
+
+@router.get("/approvals", response_class=HTMLResponse)
+async def approvals_page(request: Request) -> HTMLResponse:
+ data_dir = get_settings().data_dir
+ all_execs = read_execution_summaries(data_dir)
+ pending = [e for e in all_execs if e.status == "awaiting_approval"]
+ return templates.TemplateResponse(
+ "approvals.html",
+ {"request": request, "title": "Aprobaciones", "pending": pending, "slogan": get_slogan()},
+ )
+
+
+@router.post("/approvals/{trace_id}/approve", response_class=HTMLResponse)
+async def approve_execution(trace_id: str, request: Request) -> HTMLResponse:
+ # Nota: La reanudación completa de HITL desde la UI requiere reconstruir
+ # agent_def + policy a partir del trace_id. Por ahora mostramos lista actualizada.
+ data_dir = get_settings().data_dir
+ all_execs = read_execution_summaries(data_dir)
+ pending = [e for e in all_execs if e.status == "awaiting_approval"]
+ return templates.TemplateResponse(
+ "approvals.html",
+ {"request": request, "title": "Aprobaciones", "pending": pending, "slogan": get_slogan()},
+ )
+
+
+@router.post("/approvals/{trace_id}/reject", response_class=HTMLResponse)
+async def reject_execution(trace_id: str, request: Request) -> HTMLResponse:
+ data_dir = get_settings().data_dir
+ all_execs = read_execution_summaries(data_dir)
+ pending = [e for e in all_execs if e.status == "awaiting_approval"]
+ return templates.TemplateResponse(
+ "approvals.html",
+ {"request": request, "title": "Aprobaciones", "pending": pending, "slogan": get_slogan()},
+ )
diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile
deleted file mode 100644
index c65968f..0000000
--- a/dashboard/Dockerfile
+++ /dev/null
@@ -1,31 +0,0 @@
-# syntax=docker/dockerfile:1.7
-
-FROM python:3.11-slim
-
-ENV PYTHONUNBUFFERED=1 \
- PYTHONDONTWRITEBYTECODE=1 \
- PIP_NO_CACHE_DIR=1
-
-WORKDIR /app
-
-RUN apt-get update && apt-get install -y --no-install-recommends \
- curl \
- && rm -rf /var/lib/apt/lists/*
-
-COPY dashboard/requirements.txt /app/requirements.txt
-RUN pip install -r /app/requirements.txt
-
-COPY dashboard/src /app/src
-ENV PYTHONPATH=/app/src
-
-RUN useradd --create-home --shell /bin/bash dash && chown -R dash:dash /app
-USER dash
-
-EXPOSE 8501
-
-HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
- CMD curl -fsS http://localhost:8501/_stcore/health || exit 1
-
-CMD ["streamlit", "run", "/app/src/forja_dashboard/app.py", \
- "--server.address=0.0.0.0", "--server.port=8501", \
- "--server.headless=true", "--browser.gatherUsageStats=false"]
diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt
deleted file mode 100644
index 85290fd..0000000
--- a/dashboard/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-streamlit>=1.38,<2.0
-httpx>=0.27,<0.28
-pydantic>=2.7,<3.0
-PyYAML>=6.0,<7.0
diff --git a/dashboard/src/forja_dashboard/__init__.py b/dashboard/src/forja_dashboard/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/dashboard/src/forja_dashboard/app.py b/dashboard/src/forja_dashboard/app.py
deleted file mode 100644
index e9b612c..0000000
--- a/dashboard/src/forja_dashboard/app.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""Entry point del dashboard Streamlit con sidebar de branding y health."""
-
-from __future__ import annotations
-
-import streamlit as st
-
-from forja_dashboard.client import CoreClient
-
-
-@st.cache_resource
-def get_client() -> CoreClient:
- return CoreClient()
-
-
-def main() -> None:
- st.set_page_config(
- page_title="Forja",
- page_icon="🔨",
- layout="wide",
- initial_sidebar_state="expanded",
- )
- st.sidebar.markdown("## 🔨 Forja")
- st.sidebar.caption("Forja de agentes IA — gobernanza para cualquier proyecto")
- client = get_client()
- try:
- client.health()
- st.sidebar.success("Core API: OK")
- except Exception as exc:
- st.sidebar.error(f"Core API unreachable: {exc}")
-
- st.title("🔨 Forja")
- st.markdown(
- """
- Bienvenido a la forja de agentes IA.
-
- Navega por la barra lateral:
- - **Registro**: catálogo y versiones.
- - **Ejecutar**: invocar con gobierno completo.
- - **Aprobaciones** / **Historial**.
- - **Politicas**.
- - **🔨 Forjar Agente** y **🛡️ Forjar Politica**: editores gráficos (nuevo).
- """
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/dashboard/src/forja_dashboard/client.py b/dashboard/src/forja_dashboard/client.py
deleted file mode 100644
index 3cf8d67..0000000
--- a/dashboard/src/forja_dashboard/client.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""Cliente HTTP tipado al core. Encapsula httpx y mapea respuestas a dicts."""
-
-from __future__ import annotations
-
-import os
-from typing import Any
-
-import httpx
-
-
-class CoreClient:
- """Cliente sincrono (Streamlit es sync). 2 retries con httpx Transport."""
-
- def __init__(self, base_url: str | None = None, timeout: float = 30.0) -> None:
- self.base_url = base_url or os.getenv("FORJA_CORE_URL", "http://localhost:8000")
- transport = httpx.HTTPTransport(retries=2)
- self._client = httpx.Client(base_url=self.base_url, timeout=timeout, transport=transport)
-
- def health(self) -> dict:
- return self._get("/health")
-
- def list_agents(self) -> list[dict]:
- return self._get("/agents")
-
- def get_agent(self, name: str) -> dict:
- return self._get(f"/agents/{name}")
-
- def list_versions(self, name: str) -> list[dict]:
- return self._get(f"/agents/{name}/versions")
-
- def diff_versions(self, name: str, v_from: str, v_to: str) -> dict:
- return self._get(f"/agents/{name}/versions/{v_from}/diff/{v_to}")
-
- def invoke_agent(self, name: str, body: dict) -> dict:
- return self._post(f"/agents/{name}/invoke", body)
-
- def get_execution(self, trace_id: str) -> dict:
- return self._get(f"/executions/{trace_id}")
-
- def list_executions(self) -> list[dict]:
- return self._get("/executions")
-
- def approve(self, trace_id: str, body: dict) -> dict:
- return self._post(f"/executions/{trace_id}/approve", body)
-
- def reject(self, trace_id: str, body: dict) -> dict:
- return self._post(f"/executions/{trace_id}/reject", body)
-
- def list_violations(self, **filters: Any) -> list[dict]:
- params = {k: v for k, v in filters.items() if v is not None}
- return self._get("/violations", params=params)
-
- def list_policies(self) -> list[dict]:
- return self._get("/policies")
-
- def list_policy_versions(self, name: str) -> list[dict]:
- return self._get(f"/policies/{name}/versions")
-
- def create_agent_version(self, name: str, agent_def: dict, message: str, author: str = "dashboard") -> dict:
- body = {"agent": agent_def, "message": message, "author": author}
- return self._post(f"/agents/{name}/versions", body)
-
- def create_policy_version(self, name: str, policy_def: dict, message: str, author: str = "dashboard") -> dict:
- body = {"policy": policy_def, "message": message, "author": author}
- return self._post(f"/policies/{name}/versions", body)
-
- def list_validators(self) -> dict:
- return self._get("/policies/validators")
-
- # ----- helpers privados -----
-
- def _get(self, path: str, params: dict | None = None) -> Any:
- r = self._client.get(path, params=params)
- r.raise_for_status()
- return r.json()
-
- def _post(self, path: str, body: dict) -> Any:
- r = self._client.post(path, json=body)
- if r.status_code in {404, 409, 422}:
- # Clave deliberadamente distinta de "error": el cuerpo de un AgentExecution
- # correcto ya trae error=None, así que las páginas no podrían distinguir
- # "ejecución sin error" de "la API rechazó la petición" si reusáramos "error".
- return {"api_error": r.json()}
- r.raise_for_status()
- return r.json()
diff --git a/dashboard/src/forja_dashboard/components/__init__.py b/dashboard/src/forja_dashboard/components/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/dashboard/src/forja_dashboard/components/diff_view.py b/dashboard/src/forja_dashboard/components/diff_view.py
deleted file mode 100644
index f5d6b24..0000000
--- a/dashboard/src/forja_dashboard/components/diff_view.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""Componente para renderizar diffs unificados con coloreado básico."""
-
-from __future__ import annotations
-
-import streamlit as st
-
-
-def render_unified_diff(diff_text: str) -> None:
- if not diff_text.strip():
- st.info("Sin diferencias entre las versiones seleccionadas.")
- return
- lines: list[str] = []
- for line in diff_text.splitlines():
- if line.startswith("+") and not line.startswith("+++"):
- lines.append(f'{line} ')
- elif line.startswith("-") and not line.startswith("---"):
- lines.append(f'{line} ')
- elif line.startswith("@@"):
- lines.append(f'{line} ')
- else:
- lines.append(line)
- html = (
- "" + "\n".join(lines) + " "
- )
- st.markdown(html, unsafe_allow_html=True)
diff --git a/dashboard/src/forja_dashboard/components/trace_view.py b/dashboard/src/forja_dashboard/components/trace_view.py
deleted file mode 100644
index 94aff8e..0000000
--- a/dashboard/src/forja_dashboard/components/trace_view.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Renderiza el decision_path como timeline."""
-
-from __future__ import annotations
-
-import streamlit as st
-
-
-def render_trace(decision_path: list[dict]) -> None:
- if not decision_path:
- st.info("Aún no hay traza disponible.")
- return
- st.subheader("📜 Decision path")
- for step in decision_path:
- with st.container(border=True):
- cols = st.columns([2, 1, 1])
- with cols[0]:
- st.markdown(f"**{step['step']}**")
- with cols[1]:
- st.caption(step.get("timestamp", "—"))
- with cols[2]:
- st.metric("ms", step.get("duration_ms", 0), label_visibility="collapsed")
- if step.get("detail"):
- with st.expander("detalle"):
- st.json(step["detail"])
diff --git a/dashboard/src/forja_dashboard/components/violation_view.py b/dashboard/src/forja_dashboard/components/violation_view.py
deleted file mode 100644
index 584a22e..0000000
--- a/dashboard/src/forja_dashboard/components/violation_view.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""Renderiza violaciones de guardrails con badges de severidad."""
-
-from __future__ import annotations
-
-import streamlit as st
-
-_SEV_BADGE = {
- "info": "🟦",
- "warning": "🟧",
- "block": "🟥",
-}
-
-
-def render_violations(violations: list[dict]) -> None:
- if not violations:
- st.success("Sin violaciones.")
- return
- st.subheader("🛡️ Violaciones de guardrails")
- for v in violations:
- with st.container(border=True):
- badge = _SEV_BADGE.get(v["severity"], "⬜")
- st.markdown(
- f"{badge} **{v['validator']}** — `{v['stage']}` — "
- f"severity=`{v['severity']}` — blocked=`{v['blocked']}`"
- )
- st.caption(v["message"])
diff --git a/dashboard/src/forja_dashboard/pages/1_🏛️_Registro.py b/dashboard/src/forja_dashboard/pages/1_🏛️_Registro.py
deleted file mode 100644
index 0e36a47..0000000
--- a/dashboard/src/forja_dashboard/pages/1_🏛️_Registro.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""Página de registro de agentes: lista, detalle, versiones y diff."""
-
-from __future__ import annotations
-
-import streamlit as st
-
-from forja_dashboard.client import CoreClient
-from forja_dashboard.components.diff_view import render_unified_diff
-
-
-@st.cache_resource
-def get_client() -> CoreClient:
- return CoreClient()
-
-
-client = get_client()
-st.title("🏛️ Registro de Agentes")
-st.caption("Catálogo central de agentes con versionado tipo Git.")
-st.info("Usa **🔨 Forjar Agente** (barra lateral) para crear o versionar gráficamente.")
-
-agents = client.list_agents()
-if not agents:
- st.warning("No hay agentes registrados. Coloca YAMLs bajo `agents//`.")
- st.stop()
-
-names = [a["name"] for a in agents]
-selected_name = st.selectbox("Agente", names)
-agent = client.get_agent(selected_name)
-
-col1, col2 = st.columns([2, 1])
-with col1:
- st.subheader(f"{agent['name']} @ {agent['version']}")
- st.write(f"**Estado:** `{agent['state']}` | **Owner:** {agent['owner']}")
- st.write(f"**Propósito:** {agent['purpose']}")
- st.write(f"**Guardrails activos:** {', '.join(agent['guardrails'])}")
- st.write(f"**Threshold HITL:** risk_score ≥ {agent['risk_threshold_for_hitl']}")
- with st.expander("System prompt"):
- st.code(agent["system_prompt"], language="markdown")
- with st.expander("Output schema"):
- st.json(agent["output_schema"])
-
-with col2:
- st.subheader("LLM")
- st.json(agent["llm"])
-
-st.divider()
-st.subheader("Historial de versiones")
-versions = client.list_versions(selected_name)
-st.dataframe(versions, hide_index=True, use_container_width=True)
-
-if len(versions) >= 2:
- st.subheader("Comparar versiones")
- ids = [v["id"] for v in versions]
- cf, ct = st.columns(2)
- with cf:
- v_from = st.selectbox("Desde", ids, index=0)
- with ct:
- v_to = st.selectbox("Hasta", ids, index=len(ids) - 1)
- if v_from != v_to:
- diff = client.diff_versions(selected_name, v_from, v_to)
- render_unified_diff(diff["unified_diff"])
diff --git a/dashboard/src/forja_dashboard/pages/2_▶️_Ejecutar.py b/dashboard/src/forja_dashboard/pages/2_▶️_Ejecutar.py
deleted file mode 100644
index d8368e4..0000000
--- a/dashboard/src/forja_dashboard/pages/2_▶️_Ejecutar.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""Página para invocar un agente y visualizar la traza completa."""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-import streamlit as st
-
-from forja_dashboard.client import CoreClient
-from forja_dashboard.components.trace_view import render_trace
-from forja_dashboard.components.violation_view import render_violations
-
-
-@st.cache_resource
-def get_client() -> CoreClient:
- return CoreClient()
-
-
-client = get_client()
-st.title("▶️ Ejecutar Agente")
-st.caption("Lanza una ejecución con la cadena completa de gobierno.")
-
-agents = client.list_agents()
-if not agents:
- st.warning("No hay agentes registrados.")
- st.stop()
-
-names = [a["name"] for a in agents]
-agent_name = st.selectbox("Agente", names)
-agent = client.get_agent(agent_name)
-
-# Cargar escenarios pregrabados si existen en el disco montado.
-examples_dir = Path("agents") / agent_name / "examples"
-example_files = sorted(examples_dir.glob("*.txt")) if examples_dir.exists() else []
-
-col_l, col_r = st.columns([3, 1])
-with col_r:
- st.markdown("**Escenarios**")
- for ef in example_files:
- if st.button(ef.stem, use_container_width=True):
- st.session_state["input_text"] = ef.read_text(encoding="utf-8")
-
-with col_l:
- label = agent.get("input_label") or "Input para el agente"
- ph = agent.get("input_placeholder") or "Escribe el input para el agente o usa un escenario..."
- user_input = st.text_area(label, height=240, key="input_text", placeholder=ph)
-
-if st.button("🚀 Invocar agente", type="primary", disabled=not user_input):
- with st.spinner("Ejecutando..."):
- result = client.invoke_agent(agent_name, {"input": user_input})
- st.session_state["last_execution"] = result
-
-execution = st.session_state.get("last_execution")
-if execution and "api_error" in execution:
- st.error(f"Error de la API: {execution['api_error']}")
-elif execution:
- st.divider()
- st.markdown(f"**trace_id:** `{execution['trace_id']}`")
- st.markdown(f"**Status:** `{execution['status']}`")
-
- if execution["status"] == "awaiting_approval":
- st.warning(
- "Esta ejecución requiere aprobación humana. "
- "Ve a la página **Aprobaciones** para revisar y decidir."
- )
- if execution.get("error"):
- st.error(f"La ejecución terminó con error: `{execution['error']}`")
- if execution.get("final_output"):
- st.subheader("📦 Output final")
- st.json(execution["final_output"])
- render_violations(execution.get("violations", []))
- render_trace(execution.get("decision_path", []))
diff --git a/dashboard/src/forja_dashboard/pages/3_🤝_Aprobaciones.py b/dashboard/src/forja_dashboard/pages/3_🤝_Aprobaciones.py
deleted file mode 100644
index 7683d2f..0000000
--- a/dashboard/src/forja_dashboard/pages/3_🤝_Aprobaciones.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""Página de aprobaciones pendientes (HITL)."""
-
-from __future__ import annotations
-
-import streamlit as st
-
-from forja_dashboard.client import CoreClient
-
-
-@st.cache_resource
-def get_client() -> CoreClient:
- return CoreClient()
-
-
-client = get_client()
-st.title("🤝 Aprobaciones pendientes")
-st.caption("Ejecuciones pausadas en HITL. Revisa cada acción propuesta y aprueba o rechaza.")
-
-executions = client.list_executions()
-pending = [e for e in executions if e["status"] == "awaiting_approval"]
-
-if not pending:
- st.success("No hay aprobaciones pendientes.")
- st.stop()
-
-selected = st.selectbox(
- "Ejecución",
- pending,
- format_func=lambda e: f"{e['trace_id'][:8]} — {e['agent_name']} @ {e['agent_version']}",
-)
-
-execution = client.get_execution(selected["trace_id"])
-
-st.markdown(f"**trace_id:** `{execution['trace_id']}`")
-st.markdown(f"**Agente:** `{execution['agent_name']}@{execution['agent_version']}`")
-st.markdown(f"**Iniciada:** {execution['started_at']}")
-if execution.get("final_output"):
- with st.expander("Output bruto del LLM"):
- st.json(execution["final_output"])
-
-st.subheader("Acciones propuestas (requieren aprobación)")
-
-needs = execution.get("needs_human_for") or []
-if not needs:
- st.info("No hay acciones que requieran aprobación.")
- st.stop()
-
-approved_ids: list[str] = []
-for action in needs:
- with st.container(border=True):
- risk = action["risk_score"]
- risk_color = {1: "🟩", 2: "🟩", 3: "🟨", 4: "🟧", 5: "🟥"}.get(risk, "⬜")
- st.markdown(f"### {risk_color} `{action['action']}` → `{action['target']}`")
- st.write(f"**Risk score:** {risk}/5 | Requires approval: `{action['requires_approval']}`")
- st.write(f"**Rollback plan:** {action['rollback_plan']}")
- if st.checkbox("Aprobar esta acción", key=f"chk_{action['id']}"):
- approved_ids.append(action["id"])
-
-comment = st.text_input("Comentario (opcional)")
-col_a, col_r = st.columns(2)
-with col_a:
- if st.button("✅ Aprobar seleccionadas", type="primary", disabled=not approved_ids):
- with st.spinner("Aplicando aprobación..."):
- r = client.approve(
- execution["trace_id"],
- {"approved_action_ids": approved_ids, "comment": comment},
- )
- if "api_error" in r:
- st.error(r["api_error"])
- else:
- st.success(f"Status: {r['status']}")
- st.rerun()
-with col_r:
- reason = st.text_input("Razón de rechazo")
- if st.button("❌ Rechazar ejecución", disabled=not reason):
- with st.spinner("Aplicando rechazo..."):
- r = client.reject(execution["trace_id"], {"reason": reason})
- if "api_error" in r:
- st.error(r["api_error"])
- else:
- st.success(f"Status: {r['status']}")
- st.rerun()
diff --git a/dashboard/src/forja_dashboard/pages/4_📜_Historial.py b/dashboard/src/forja_dashboard/pages/4_📜_Historial.py
deleted file mode 100644
index df72d43..0000000
--- a/dashboard/src/forja_dashboard/pages/4_📜_Historial.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""Página de historial de ejecuciones y log de violaciones."""
-
-from __future__ import annotations
-
-import streamlit as st
-
-from forja_dashboard.client import CoreClient
-from forja_dashboard.components.trace_view import render_trace
-from forja_dashboard.components.violation_view import render_violations
-
-
-@st.cache_resource
-def get_client() -> CoreClient:
- return CoreClient()
-
-
-client = get_client()
-st.title("📜 Historial")
-st.caption("Ejecuciones registradas y log auditable de violaciones de guardrails.")
-
-tabs = st.tabs(["Ejecuciones", "Violaciones"])
-
-with tabs[0]:
- executions = client.list_executions()
- if not executions:
- st.info("Aún no hay ejecuciones registradas.")
- else:
- st.dataframe(executions, hide_index=True, use_container_width=True)
- trace_ids = [e["trace_id"] for e in executions]
- selected = st.selectbox(
- "Ver detalle",
- trace_ids,
- format_func=lambda t: t[:8] + "…",
- )
- if selected:
- detail = client.get_execution(selected)
- st.markdown(f"**Status:** `{detail['status']}`")
- if detail.get("error"):
- st.error(f"Error: {detail['error']}")
- if detail.get("final_output"):
- st.subheader("Output final")
- st.json(detail["final_output"])
- render_violations(detail.get("violations", []))
- render_trace(detail.get("decision_path", []))
-
-with tabs[1]:
- sev = st.selectbox("Filtrar severidad", ["(todas)", "info", "warning", "block"])
- filter_kwargs = {} if sev == "(todas)" else {"severity": sev}
- violations = client.list_violations(**filter_kwargs)
- if not violations:
- st.success("Sin violaciones registradas con ese filtro.")
- else:
- st.dataframe(violations, hide_index=True, use_container_width=True)
diff --git a/dashboard/src/forja_dashboard/pages/5_📐_Politicas.py b/dashboard/src/forja_dashboard/pages/5_📐_Politicas.py
deleted file mode 100644
index 6537fbe..0000000
--- a/dashboard/src/forja_dashboard/pages/5_📐_Politicas.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""Página de políticas de guardrails y sus versiones."""
-
-from __future__ import annotations
-
-import streamlit as st
-
-from forja_dashboard.client import CoreClient
-
-
-@st.cache_resource
-def get_client() -> CoreClient:
- return CoreClient()
-
-
-client = get_client()
-st.title("📐 Politicas de guardrails")
-st.caption("Inventario de políticas y sus versiones (estilo Git).")
-st.info("Usa **🛡️ Forjar Politica** (barra lateral) para componer y versionar gráficamente.")
-
-policies = client.list_policies()
-if not policies:
- st.warning("No hay políticas registradas.")
- st.stop()
-
-names = [p["name"] for p in policies]
-selected = st.selectbox("Política", names)
-policy = next(p for p in policies if p["name"] == selected)
-
-st.subheader(f"{policy['name']} @ {policy['version']}")
-st.write(f"**Descripción:** {policy['description']}")
-st.write(f"**On validator error:** `{policy['on_validator_error']}`")
-
-col_in, col_out = st.columns(2)
-with col_in:
- st.markdown("**Validadores de input**")
- for v in policy["input_validators"]:
- with st.expander(v["type"]):
- st.json(v["config"])
-with col_out:
- st.markdown("**Validadores de output**")
- for v in policy["output_validators"]:
- with st.expander(v["type"]):
- st.json(v["config"])
-
-st.divider()
-st.subheader("Historial de versiones")
-versions = client.list_policy_versions(selected)
-st.dataframe(versions, hide_index=True, use_container_width=True)
diff --git a/dashboard/src/forja_dashboard/pages/6_🔨_Forjar_Agente.py b/dashboard/src/forja_dashboard/pages/6_🔨_Forjar_Agente.py
deleted file mode 100644
index acdd559..0000000
--- a/dashboard/src/forja_dashboard/pages/6_🔨_Forjar_Agente.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""Página de editor gráfico para crear o versionar agentes (Forja)."""
-
-from __future__ import annotations
-
-import json
-
-import streamlit as st
-
-from forja_dashboard.client import CoreClient
-
-
-@st.cache_resource
-def get_client() -> CoreClient:
- return CoreClient()
-
-
-client = get_client()
-
-st.title("🔨 Forjar Agente")
-st.caption("Editor gráfico. Crea un agente nuevo o una nueva versión de uno existente. Todo se guarda como YAML versionado.")
-
-agents = client.list_agents()
-agent_names = [a["name"] for a in agents] if agents else []
-mode = st.radio("Modo", ["Nuevo agente", "Nueva versión de existente"], horizontal=True)
-
-base_name = None
-if mode == "Nueva versión de existente" and agent_names:
- base_name = st.selectbox("Agente base", agent_names)
-elif mode == "Nueva versión de existente":
- st.warning("No hay agentes. Crea uno nuevo.")
- st.stop()
-
-with st.form("forge_agent", clear_on_submit=False):
- col1, col2 = st.columns(2)
- with col1:
- name = st.text_input("Nombre del agente", value=base_name or "mi-nuevo-agente")
- version = st.text_input("Versión (ej. v1)", value="v1")
- owner = st.text_input("Owner", value="usuario")
- state = st.selectbox("Estado", ["draft", "active", "deprecated"], index=1)
- with col2:
- purpose = st.text_area("Propósito / descripción", value="Agente gobernado por Forja", height=80)
- category = st.text_input("Categoría (opcional)", value="")
- tags_str = st.text_input("Tags (coma separadas)", value="")
- icon = st.text_input("Icono emoji (opcional)", value="🤖")
-
- st.subheader("LLM")
- llm_col1, llm_col2 = st.columns(4)
- with llm_col1:
- provider = st.selectbox("Provider", ["mock", "azure", "openai"], index=0)
- with llm_col2:
- model = st.text_input("Modelo", value="gpt-4o")
- with llm_col3:
- temperature = st.number_input("Temperature", 0.0, 2.0, 0.2, 0.1)
- with llm_col4:
- max_tokens = st.number_input("Max tokens", 1, 128000, 2000)
-
- st.subheader("Prompt y Schema")
- system_prompt = st.text_area("System prompt", value="Eres un asistente útil. Responde en JSON según el schema.", height=160)
- schema_text = st.text_area("Output schema (JSON)", value=json.dumps({"type": "object", "properties": {"result": {"type": "string"}}}, indent=2), height=120)
-
- st.subheader("Guardrails y HITL")
- policies = client.list_policies()
- policy_names = [p["name"] for p in policies] if policies else ["default"]
- guardrails = st.multiselect("Políticas de guardrails activas", policy_names, default=policy_names[:1] if policy_names else [])
- risk = st.slider("Risk threshold para HITL", 1, 5, 4)
-
- st.subheader("Metadatos de versión")
- message = st.text_input("Mensaje de versión (como commit)", value="Creación vía editor gráfico de Forja")
- author = st.text_input("Autor", value="dashboard")
-
- submitted = st.form_submit_button("🔨 Forjar / Guardar versión", type="primary")
-
-if submitted:
- try:
- output_schema = json.loads(schema_text)
- except Exception as e:
- st.error(f"Schema JSON inválido: {e}")
- st.stop()
-
- tags = [t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else []
-
- agent_def = {
- "name": name,
- "version": version,
- "owner": owner,
- "purpose": purpose,
- "state": state,
- "guardrails": guardrails or ["default"],
- "llm": {
- "provider": provider,
- "model": model,
- "temperature": float(temperature),
- "max_tokens": int(max_tokens),
- },
- "system_prompt": system_prompt,
- "output_schema": output_schema,
- "risk_threshold_for_hitl": int(risk),
- "updated_at": "2026-05-23T00:00:00Z",
- "input_label": "Input para el agente",
- "input_placeholder": "Escribe aquí el input...",
- "category": category or None,
- "tags": tags,
- "icon": icon or None,
- "template": "governed_llm",
- }
-
- try:
- res = client.create_agent_version(name, agent_def, message, author)
- if "api_error" in res:
- st.error(f"Error API: {res['api_error']}")
- else:
- st.success(f"Versión {version} de {name} creada correctamente. Recarga Registro para verla.")
- st.json(res)
- except Exception as exc:
- st.error(f"Error al forjar: {exc}")
diff --git a/dashboard/src/forja_dashboard/pages/7_🛡️_Forjar_Politica.py b/dashboard/src/forja_dashboard/pages/7_🛡️_Forjar_Politica.py
deleted file mode 100644
index 83c2f52..0000000
--- a/dashboard/src/forja_dashboard/pages/7_🛡️_Forjar_Politica.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""Página de editor gráfico para políticas de guardrails (Forja)."""
-
-from __future__ import annotations
-
-import json
-
-import streamlit as st
-
-from forja_dashboard.client import CoreClient
-
-
-@st.cache_resource
-def get_client() -> CoreClient:
- return CoreClient()
-
-
-client = get_client()
-
-st.title("🛡️ Forjar Política")
-st.caption("Compositor gráfico de políticas. Añade validadores de entrada/salida, configura y versiona.")
-
-policies = client.list_policies()
-policy_names = [p["name"] for p in policies] if policies else []
-
-mode = st.radio("Modo", ["Nueva política", "Nueva versión de existente"], horizontal=True)
-base = None
-if mode == "Nueva versión de existente" and policy_names:
- base = st.selectbox("Política base", policy_names)
-
-validators_meta = client.list_validators()
-input_types = validators_meta.get("input", [])
-output_types = validators_meta.get("output", [])
-
-def validator_form(stage: str, types: list[str], key_prefix: str) -> list[dict]:
- st.markdown(f"**Validadores de {stage}**")
- result = []
- num = st.number_input(f"Número de validadores {stage}", 0, 12, 2, key=f"num_{key_prefix}")
- for i in range(int(num)):
- with st.expander(f"Validador {i+1} ({stage})", expanded=True):
- t = st.selectbox("Tipo", types, key=f"type_{key_prefix}_{i}")
- cfg_text = st.text_area("Config (JSON)", value="{}", key=f"cfg_{key_prefix}_{i}", height=80)
- try:
- cfg = json.loads(cfg_text)
- except Exception:
- cfg = {}
- result.append({"type": t, "config": cfg})
- return result
-
-with st.form("forge_policy"):
- name = st.text_input("Nombre de la política", value=base or "mi-politica")
- version = st.text_input("Versión", value="v1")
- description = st.text_area("Descripción", value="Política creada con el editor gráfico de Forja")
- on_error = st.selectbox("on_validator_error", ["fail_closed", "fail_open"], index=0)
-
- st.divider()
- input_validators = validator_form("input", input_types, "in")
- output_validators = validator_form("output", output_types, "out")
-
- st.subheader("Metadatos de versión")
- message = st.text_input("Mensaje", value="Creada vía UI gráfica")
- author = st.text_input("Autor", value="dashboard")
-
- submitted = st.form_submit_button("🛡️ Guardar versión de política", type="primary")
-
-if submitted:
- policy_def = {
- "name": name,
- "version": version,
- "description": description,
- "input_validators": input_validators,
- "output_validators": output_validators,
- "on_validator_error": on_error,
- }
- try:
- res = client.create_policy_version(name, policy_def, message, author)
- if "api_error" in res:
- st.error(res["api_error"])
- else:
- st.success(f"Política {name}@{version} guardada.")
- st.json(res)
- except Exception as e:
- st.error(str(e))
diff --git a/dashboard/src/forja_dashboard/pages/__init__.py b/dashboard/src/forja_dashboard/pages/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/docker-compose.yml b/docker-compose.yml
index f27edd5..72655ec 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,20 +24,3 @@ services:
retries: 3
start_period: 15s
restart: unless-stopped
-
- dashboard:
- build:
- context: .
- dockerfile: dashboard/Dockerfile
- image: forja-dashboard:dev
- container_name: forja-dashboard
- depends_on:
- core:
- condition: service_healthy
- ports:
- - "8501:8501"
- environment:
- FORJA_CORE_URL: http://core:8000
- volumes:
- - ./agents:/app/agents:rw # rw para editores gráficos + escenarios
- restart: unless-stopped
diff --git a/docs/componentes.md b/docs/componentes.md
index f1cfd90..8aca47a 100644
--- a/docs/componentes.md
+++ b/docs/componentes.md
@@ -66,9 +66,8 @@ NIVEL 5
NIVEL 6
main ⇐ api (routers), api.middlewares, config, observability.logging → create_app(), app
-(separado, sin imports del paquete core)
- dashboard/src/ forja_dashboard/* ← habla con `main` por HTTP, no por import
-```
+(UI HTMX co-locada dentro del propio forja-core; mismo proceso, sin cliente HTTP intermedio)
+ ```
Reglas que se cumplen y conviene mantener:
- **`domain/` no importa nada del resto del proyecto.** Es el vocabulario; todos dependen de él.
@@ -333,33 +332,14 @@ Hashing/versionado: `registry/versioning.compute_hash(yaml_text)` = SHA-256 del
---
-## 6. Dashboard ↔ Core (` forja_dashboard`)
+## 6. UI HTMX embebida en Core
-El dashboard no comparte código con el core: solo lo llama por HTTP a través de
-`CoreClient` (`dashboard/src/ forja_dashboard/client.py`, httpx síncrono con 2
-retries, `base_url = FORJA_CORE_URL`; mapea 404/409/422 → `{"error": }`).
+Con la migración a Opción A (FastAPI + HTMX), la interfaz de usuario se sirve
+directamente desde el mismo proceso `forja-core` (Jinja2 + HTMX vía CDN, sin
+servicio separado). No hay `CoreClient` intermedio para las páginas; los
+endpoints de UI llaman a los mismos servicios internos que la API REST.
-| `CoreClient.` | Endpoint del core | Página(s) que lo usan |
-|-----------------------|-------------------|-----------------------|
-| `health()` | `GET /health` | `app.py` (sidebar) |
-| `list_agents()` | `GET /agents` | Registro, Ejecutar |
-| `get_agent(name)` | `GET /agents/{name}` | Registro |
-| `list_versions(name)` | `GET /agents/{name}/versions` | Registro |
-| `diff_versions(name, a, b)` | `GET /agents/{name}/versions/{a}/diff/{b}` | Registro (→ `components/diff_view`) |
-| `invoke_agent(name, body)` | `POST /agents/{name}/invoke` | Ejecutar |
-| `list_executions()` | `GET /executions` | Aprobaciones (filtra `status=="awaiting_approval"`), Historial |
-| `get_execution(trace_id)` | `GET /executions/{trace_id}` | Aprobaciones, Historial (→ `components/trace_view`, `violation_view`) |
-| `approve(trace_id, body)` | `POST /executions/{trace_id}/approve` | Aprobaciones |
-| `reject(trace_id, body)` | `POST /executions/{trace_id}/reject` | Aprobaciones |
-| `list_violations(**filters)` | `GET /violations?…` | Historial |
-| `list_policies()` | `GET /policies` | Politicas |
-| `list_policy_versions(name)` | `GET /policies/{name}/versions` | Politicas |
-
-Páginas (Streamlit multipágina; el nº y el emoji del nombre del fichero son la
-navegación): `app.py` (raíz), `pages/1_🏛️_Registro.py`, `pages/2_▶️_Ejecutar.py`,
-`pages/3_🤝_Aprobaciones.py`, `pages/4_📜_Historial.py`, `pages/5_📐_Politicas.py`.
-Componentes reutilizables: `components/diff_view.render_unified_diff(diff_text)`,
-`components/trace_view.render_trace(decision_path)`, `components/violation_view.render_violations(violations)`.
+La antigua sección de dashboard Streamlit ha sido eliminada.
---
@@ -378,13 +358,10 @@ Componentes reutilizables: `components/diff_view.render_unified_diff(diff_text)`
pueden disparar la construcción perezosa) → handler → respuesta con `X-Trace-Id`.
**Contenedores** (`docker-compose.yml`): servicio `core` (`core/Dockerfile`,
-`uvicorn forja_core.main:app --host 0.0.0.0 --port 8000`, `HEALTHCHECK` →
-`curl /health`, monta `./agents:ro`, `./policies:ro`, `./data:rw`, env
-`DATA_DIR=/app/data`, `AGENTS_DIR=/app/agents`, `POLICIES_DIR=/app/policies`,
-`env_file: .env`); servicio `dashboard` (`dashboard/Dockerfile`, `streamlit run
-app.py`, `HEALTHCHECK` → `/_stcore/health`, `depends_on: core: service_healthy`,
-env `FORJA_CORE_URL=http://core:8000`, monta `./agents:ro` para leer los
-`examples/*.txt`).
+`uvicorn forja_core.main:app --host 0.0.0.0 --port 8000` para API + UI HTMX,
+`HEALTHCHECK` → `curl /health`, monta `./agents:rw`, `./policies:rw`, `./data:rw`,
+env `DATA_DIR=/app/data`, `AGENTS_DIR=/app/agents`, `POLICIES_DIR=/app/policies`,
+`env_file: .env`). Un solo servicio.
**`Settings` (env vars)** — `config.py`:
diff --git a/docs/explicacion.md b/docs/explicacion.md
index 22d3318..8ef5e8a 100644
--- a/docs/explicacion.md
+++ b/docs/explicacion.md
@@ -511,15 +511,12 @@ Forja no usa una sola base de datos; usa la herramienta adecuada para cada cosa.
│ │ ├── api/ ← FastAPI (middlewares, deps, routers, persistence)
│ │ └── main.py ← create_app(): ensambla la app
│ ├── Dockerfile · requirements.txt
-├── dashboard/
-│ ├── src/ forja_dashboard/ ← Streamlit (client + app + pages + components)
-│ ├── Dockerfile · requirements.txt
├── agents/incident_analyzer/ ← el agente de ejemplo (YAMLs + escenarios .txt)
├── policies/default/ ← la política de guardrails de ejemplo
├── data/ ← estado runtime (gitignored)
-├── tests/ ← pytest: tests/unit/ y tests/integration/ (88 tests en total; el demo Streamlit no se testea con unit tests)
+├── tests/ ← pytest: tests/unit/ y tests/integration/
├── docs/ ← este documento, manual_qa.md, futuro.md, superpowers/
-├── docker-compose.yml ← levanta core + dashboard
+├── docker-compose.yml ← levanta core (API + UI HTMX en puerto 8000)
├── Makefile ← install / test / test-all / lint / smoke / up / down
├── README.md · ARCHITECTURE.md
```
diff --git a/karpathy.md b/karpathy.md
new file mode 100644
index 0000000..05ddc06
--- /dev/null
+++ b/karpathy.md
@@ -0,0 +1,59 @@
+Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
+
+**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
+
+## 1. Think Before Coding
+
+**Don't assume. Don't hide confusion. Surface tradeoffs.**
+
+Before implementing:
+- State your assumptions explicitly. If uncertain, ask.
+- If multiple interpretations exist, present them - don't pick silently.
+- If a simpler approach exists, say so. Push back when warranted.
+- If something is unclear, stop. Name what's confusing. Ask.
+
+## 2. Simplicity First
+
+**Minimum code that solves the problem. Nothing speculative.**
+
+- No features beyond what was asked.
+- No abstractions for single-use code.
+- No "flexibility" or "configurability" that wasn't requested.
+- No error handling for impossible scenarios.
+- If you write 200 lines and it could be 50, rewrite it.
+
+Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
+
+## 3. Surgical Changes
+
+**Touch only what you must. Clean up only your own mess.**
+
+When editing existing code:
+- Don't "improve" adjacent code, comments, or formatting.
+- Don't refactor things that aren't broken.
+- Match existing style, even if you'd do it differently.
+- If you notice unrelated dead code, mention it - don't delete it.
+
+When your changes create orphans:
+- Remove imports/variables/functions that YOUR changes made unused.
+- Don't remove pre-existing dead code unless asked.
+
+The test: Every changed line should trace directly to the user's request.
+
+## 4. Goal-Driven Execution
+
+**Define success criteria. Loop until verified.**
+
+Transform tasks into verifiable goals:
+- "Add validation" → "Write tests for invalid inputs, then make them pass"
+- "Fix the bug" → "Write a test that reproduces it, then make it pass"
+- "Refactor X" → "Ensure tests pass before and after"
+
+For multi-step tasks, state a brief plan:
+```
+1. [Step] → verify: [check]
+2. [Step] → verify: [check]
+3. [Step] → verify: [check]
+```
+
+Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
diff --git a/pyproject.toml b/pyproject.toml
index 2f96e86..d4445e8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,11 +12,8 @@ authors = [{name = "Juan", email = "jm0x@proton.me"}]
[project.dependencies]
httpx = ">=0.27,<0.28"
-[project.optional-dependencies]
-web = ["nicegui>=2.0"]
-
[tool.setuptools.packages.find]
-where = ["core/src", "common/src", "web/src"]
+where = ["core/src", "common/src"]
namespaces = false
[tool.ruff]
@@ -27,13 +24,8 @@ target-version = "py311"
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "ASYNC", "RUF"]
ignore = ["E501"]
-[tool.ruff.lint.per-file-ignores]
-# Streamlit exige que las páginas se llamen "__.py" para la
-# navegación multipágina; eso choca con N999 (nombre de módulo no válido).
-"dashboard/src/forja_dashboard/pages/*" = ["N999"]
-
[tool.ruff.lint.isort]
-known-first-party = ["forja_core", "forja_dashboard", "forja_common", "forja_web"]
+known-first-party = ["forja_core", "forja_common"]
[tool.mypy]
python_version = "3.11"
@@ -51,7 +43,7 @@ ignore_missing_imports = true
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "-ra -q --strict-markers"
-pythonpath = ["core/src", "dashboard/src", "common/src", "web/src"]
+pythonpath = ["core/src", "common/src"]
markers = [
"integration: integration tests (slower, may touch FS)",
]
diff --git a/tests/unit/test_dashboard_client.py b/tests/unit/test_dashboard_client.py
deleted file mode 100644
index c1b3338..0000000
--- a/tests/unit/test_dashboard_client.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""Tests del CoreClient: cómo mapea respuestas HTTP del core a dicts.
-
-Importante para el dashboard: una ejecución correcta (``AgentExecution`` serializado)
-incluye SIEMPRE el campo ``error`` (``str | None``). El wrapper que el cliente añade
-ante un 4xx debe usar una clave distinta (``api_error``) para no colisionar con ese
-campo; si usara ``error``, las páginas no podrían distinguir "el agente terminó sin
-error" de "la API devolvió 404/409/422".
-"""
-
-from __future__ import annotations
-
-from collections.abc import Callable
-
-import httpx
-import pytest
-
-from forja_dashboard.client import CoreClient
-
-
-def _client_with(handler: Callable[[httpx.Request], httpx.Response]) -> CoreClient:
- client = CoreClient(base_url="http://test")
- client._client.close()
- client._client = httpx.Client(base_url="http://test", transport=httpx.MockTransport(handler))
- return client
-
-
-def test_post_2xx_devuelve_el_cuerpo_verbatim_incluido_error_none() -> None:
- """Un AgentExecution con ``error=None`` se devuelve tal cual, sin envolver."""
- execution = {
- "trace_id": "11111111-1111-1111-1111-111111111111",
- "status": "completed",
- "error": None,
- "violations": [],
- "decision_path": [],
- }
-
- def handler(_: httpx.Request) -> httpx.Response:
- return httpx.Response(200, json=execution)
-
- result = _client_with(handler).invoke_agent("incident_analyzer", {"input": "x"})
- assert result == execution
- assert "api_error" not in result
-
-
-@pytest.mark.parametrize("status", [404, 409, 422])
-def test_post_4xx_envuelve_el_detalle_en_api_error(status: int) -> None:
- def handler(_: httpx.Request) -> httpx.Response:
- return httpx.Response(status, json={"detail": "boom"})
-
- result = _client_with(handler).invoke_agent("incident_analyzer", {"input": "y"})
- assert result == {"api_error": {"detail": "boom"}}
-
-
-def test_post_5xx_propaga_la_excepcion() -> None:
- def handler(_: httpx.Request) -> httpx.Response:
- return httpx.Response(500, json={"detail": "kaput"})
-
- with pytest.raises(httpx.HTTPStatusError):
- _client_with(handler).invoke_agent("incident_analyzer", {"input": "z"})
diff --git a/web/requirements.txt b/web/requirements.txt
deleted file mode 100644
index c9cbe6a..0000000
--- a/web/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-nicegui>=2.0
-httpx>=0.27,<0.28
-rich>=13.0 # optional, for nice tables if we want
diff --git a/web/src/forja_web/__init__.py b/web/src/forja_web/__init__.py
deleted file mode 100644
index a5ce9c5..0000000
--- a/web/src/forja_web/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""forja_web - Modern NiceGUI dashboard for Forja."""
-
-from forja_web.main import main
-
-__all__ = ["main"]
diff --git a/web/src/forja_web/main.py b/web/src/forja_web/main.py
deleted file mode 100644
index aefc538..0000000
--- a/web/src/forja_web/main.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""Forja Web - NiceGUI modern dashboard."""
-
-from __future__ import annotations
-
-from nicegui import ui
-
-from forja_web.pages import agents_page, approvals_page, executions_page, home_page, policies_page, run_page
-
-
-def main() -> None:
- ui.page_title("Forja • Gobernanza de Agentes IA")
-
- # Top bar
- with ui.header().classes("bg-zinc-900 text-white items-center shadow-md"):
- with ui.row().classes("w-full items-center px-6"):
- ui.label("🔨 Forja").classes("text-2xl font-bold tracking-tight")
- ui.space()
- ui.label("Plataforma de gobernanza para agentes IA").classes("text-zinc-400 text-sm")
-
- # Left drawer navigation
- with ui.left_drawer(value=True, bordered=True).classes("bg-zinc-950 text-white"):
- ui.label("Navegación").classes("text-xs uppercase tracking-widest text-zinc-500 px-2 pt-4 pb-1")
- with ui.column().classes("gap-1"):
- ui.link("Inicio", "/").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
- ui.link("Agentes", "/agents").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
- ui.link("Ejecutar", "/run").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
- ui.link("Aprobaciones", "/approvals").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
- ui.link("Historial", "/history").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
- ui.link("Políticas", "/policies").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
- ui.separator().classes("my-2")
- ui.link("Forjar Agente", "/forge/agent").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
- ui.link("Forjar Política", "/forge/policy").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
-
- # Main content area with pages
- @ui.page("/")
- def home() -> None:
- home_page.render()
-
- @ui.page("/agents")
- def agents() -> None:
- agents_page.render()
-
- @ui.page("/run")
- def run() -> None:
- run_page.render()
-
- @ui.page("/approvals")
- def approvals() -> None:
- approvals_page.render()
-
- @ui.page("/history")
- def history() -> None:
- executions_page.render()
-
- @ui.page("/policies")
- def policies() -> None:
- policies_page.render()
-
- @ui.page("/forge/agent")
- def forge_agent() -> None:
- ui.label("🔨 Forjar Agente").classes("text-3xl font-semibold")
- ui.label("Editor gráfico moderno (en desarrollo)").classes("text-zinc-400 mt-2")
-
- @ui.page("/forge/policy")
- def forge_policy() -> None:
- ui.label("🛡️ Forjar Política").classes("text-3xl font-semibold")
- ui.label("Editor de políticas (en desarrollo)").classes("text-zinc-400 mt-2")
-
- ui.run(
- title="Forja",
- host="0.0.0.0",
- port=8501,
- dark=True,
- reload=True,
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/web/src/forja_web/pages/agents_page.py b/web/src/forja_web/pages/agents_page.py
deleted file mode 100644
index 87fe57e..0000000
--- a/web/src/forja_web/pages/agents_page.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Agents listing page."""
-
-from nicegui import ui
-
-from forja_common.client import CoreClient
-
-
-def render() -> None:
- ui.label("📚 Registro de Agentes").classes("text-3xl font-semibold mb-6")
-
- client = CoreClient()
-
- try:
- agents = client.list_agents()
- except Exception as e:
- ui.label(f"Error cargando agentes: {e}").classes("text-red-400")
- return
-
- if not agents:
- ui.label("No hay agentes registrados todavía.").classes("text-zinc-400")
- return
-
- for agent in agents:
- with ui.card().classes("w-full mb-4"):
- with ui.row().classes("items-center justify-between"):
- ui.label(agent.get("name", "sin-nombre")).classes("text-xl font-bold")
- ui.badge(agent.get("latest_version", "v?"), color="blue")
-
- if "description" in agent:
- ui.label(agent["description"]).classes("text-sm text-zinc-400")
-
- with ui.row().classes("gap-2 mt-2"):
- ui.button("Ver versiones", on_click=lambda n=agent["name"]: ui.navigate.to(f"/agents/{n}"))
diff --git a/web/src/forja_web/pages/approvals_page.py b/web/src/forja_web/pages/approvals_page.py
deleted file mode 100644
index e3bbbb3..0000000
--- a/web/src/forja_web/pages/approvals_page.py
+++ /dev/null
@@ -1,44 +0,0 @@
-"""Approvals page (HITL)."""
-
-from nicegui import ui
-
-from forja_common.client import CoreClient
-
-
-def render() -> None:
- ui.label("🤝 Aprobaciones pendientes").classes("text-3xl font-semibold mb-6")
-
- client = CoreClient()
-
- try:
- executions = client.list_executions()
- pending = [e for e in executions if e.get("status") in ("awaiting_approval", "hitl")]
- except Exception as e:
- ui.label(f"Error: {e}").classes("text-red-400")
- return
-
- if not pending:
- ui.label("No hay ejecuciones esperando aprobación.").classes("text-emerald-400 text-lg")
- return
-
- for ex in pending:
- with ui.card().classes("w-full mb-3"):
- ui.label(f"Trace: {ex.get('trace_id')}").classes("font-mono text-sm")
- ui.label(f"Agente: {ex.get('agent_name')}")
- with ui.row():
- ui.button("Aprobar", color="green", on_click=lambda t=ex["trace_id"]: approve(t))
- ui.button("Rechazar", color="red", on_click=lambda t=ex["trace_id"]: reject(t))
-
-
-def approve(trace_id: str) -> None:
- client = CoreClient()
- client.approve(trace_id, {"notes": "Approved from NiceGUI"})
- ui.notify("Aprobado", type="positive")
- ui.navigate.reload()
-
-
-def reject(trace_id: str) -> None:
- client = CoreClient()
- client.reject(trace_id, {"reason": "Rejected from NiceGUI"})
- ui.notify("Rechazado", type="negative")
- ui.navigate.reload()
diff --git a/web/src/forja_web/pages/executions_page.py b/web/src/forja_web/pages/executions_page.py
deleted file mode 100644
index 690550e..0000000
--- a/web/src/forja_web/pages/executions_page.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""History / executions page."""
-
-from nicegui import ui
-
-from forja_common.client import CoreClient
-
-
-def render() -> None:
- ui.label("📜 Historial de Ejecuciones").classes("text-3xl font-semibold mb-6")
-
- client = CoreClient()
-
- try:
- executions = client.list_executions()
- except Exception as e:
- ui.label(f"Error: {e}").classes("text-red-400")
- return
-
- if not executions:
- ui.label("Aún no hay ejecuciones.").classes("text-zinc-400")
- return
-
- for ex in executions[:30]:
- with ui.card().classes("w-full mb-2"):
- with ui.row().classes("justify-between items-center"):
- ui.label(ex.get("trace_id", "")[:20] + "...").classes("font-mono text-sm")
- status = ex.get("status", "unknown")
- color = "green" if status == "completed" else "orange"
- ui.badge(status, color=color)
- ui.label(f"{ex.get('agent_name')} • {ex.get('created_at', '')}").classes("text-xs text-zinc-400")
diff --git a/web/src/forja_web/pages/home_page.py b/web/src/forja_web/pages/home_page.py
deleted file mode 100644
index 2a8f7b2..0000000
--- a/web/src/forja_web/pages/home_page.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""Home page for the NiceGUI dashboard."""
-
-from nicegui import ui
-
-from forja_common.client import CoreClient
-
-
-def render() -> None:
- ui.label("Bienvenido a Forja").classes("text-4xl font-bold tracking-tight mt-8")
- ui.label(
- "Plataforma profesional de gobernanza para agentes de IA. "
- "Versión moderna con NiceGUI."
- ).classes("text-lg text-zinc-400 mt-2")
-
- ui.separator().classes("my-8")
-
- with ui.card().classes("w-full max-w-2xl"):
- ui.label("Estado del sistema").classes("text-xl font-semibold mb-4")
-
- try:
- client = CoreClient()
- health = client.health()
- ui.badge("Core API: Saludable", color="green").classes("text-lg")
- ui.json_editor({"status": health})
- except Exception as exc:
- ui.badge(f"Core API unreachable: {exc}", color="red")
diff --git a/web/src/forja_web/pages/policies_page.py b/web/src/forja_web/pages/policies_page.py
deleted file mode 100644
index 96f80cc..0000000
--- a/web/src/forja_web/pages/policies_page.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Policies page."""
-
-from nicegui import ui
-
-from forja_common.client import CoreClient
-
-
-def render() -> None:
- ui.label("🛡️ Políticas de Guardrails").classes("text-3xl font-semibold mb-6")
-
- client = CoreClient()
-
- try:
- policies = client.list_policies()
- except Exception as e:
- ui.label(f"Error: {e}").classes("text-red-400")
- return
-
- if not policies:
- ui.label("No hay políticas definidas.").classes("text-zinc-400")
- return
-
- for p in policies:
- with ui.card().classes("w-full mb-3"):
- ui.label(p.get("name")).classes("font-bold text-lg")
- versions = client.list_policy_versions(p["name"])
- ui.label(f"{len(versions)} versiones").classes("text-sm text-zinc-400")
diff --git a/web/src/forja_web/pages/run_page.py b/web/src/forja_web/pages/run_page.py
deleted file mode 100644
index ba17f0c..0000000
--- a/web/src/forja_web/pages/run_page.py
+++ /dev/null
@@ -1,77 +0,0 @@
-"""Ejecutar agente page - modern invoke form."""
-
-from __future__ import annotations
-
-import json
-
-from nicegui import ui
-
-from forja_common.client import CoreClient
-
-
-def render() -> None:
- ui.label("▶️ Ejecutar Agente").classes("text-3xl font-semibold mb-2")
- ui.label("Invoca un agente con todo el gobierno (guardrails + HITL + observabilidad)").classes("text-zinc-400 mb-6")
-
- client = CoreClient()
-
- # Load data
- try:
- agents = client.list_agents()
- agent_names = [a["name"] for a in agents] if agents else []
- policies = client.list_policies()
- policy_names = [p["name"] for p in policies] if policies else []
- except Exception as e:
- ui.label(f"No se pudo conectar al Core: {e}").classes("text-red-400")
- return
-
- if not agent_names:
- ui.label("No hay agentes registrados. Crea uno primero en 'Forjar Agente'.").classes("text-amber-400")
- return
-
- # Form state
- selected_agent = ui.select(agent_names, label="Agente", value=agent_names[0]).classes("w-80")
- scenario = ui.input("Escenario", value="01_sip_registration_drop").classes("w-80")
-
- with ui.card().classes("w-full max-w-2xl mt-4"):
- ui.label("Payload (JSON)").classes("font-medium mb-2")
- payload_text = ui.textarea(
- value=json.dumps({"scenario": "01_sip_registration_drop"}, indent=2),
- placeholder='{"scenario": "..."}',
- ).classes("w-full font-mono text-sm").props("rows=6")
-
- result_box = ui.column().classes("w-full max-w-2xl mt-4")
-
- async def do_invoke() -> None:
- result_box.clear()
- try:
- body = json.loads(payload_text.value or "{}")
- if "scenario" not in body:
- body["scenario"] = scenario.value
-
- with result_box:
- ui.spinner("dots", size="lg")
- ui.label("Invocando agente con gobernanza...").classes("ml-2")
-
- res = client.invoke_agent(selected_agent.value, body)
-
- result_box.clear()
- with result_box:
- if "api_error" in res:
- ui.label("Error de la API").classes("text-red-400 font-bold")
- ui.json_editor(res["api_error"])
- else:
- ui.label("✅ Ejecución iniciada").classes("text-emerald-400 text-lg font-semibold")
- ui.json_editor(res, options={"readOnly": True})
- if "trace_id" in res:
- trace = res["trace_id"]
- ui.button(
- "Ver traza completa",
- on_click=lambda: ui.navigate.to(f"/executions/{trace}"),
- ).classes("mt-2")
- except Exception as exc:
- result_box.clear()
- with result_box:
- ui.label(f"Error: {exc}").classes("text-red-400")
-
- ui.button("🚀 Invocar con Gobernanza", on_click=do_invoke, color="primary").classes("mt-4 px-8 py-2 text-lg")