Skip to content
Merged
26 changes: 26 additions & 0 deletions .claude/rules/agente-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,32 @@ GC puede matar el job a mitad de camino).
en código.


## Motor del agente (F6a)

- **`services.py` no conoce `opencode`**: pide un `AgentEngine` a `engine_factory`
(`src/agents/engine.py`). `OpencodeEngine` (`opencode_engine.py`) junta pool de puertos,
`AgentSandbox`, proceso, password y sesión. El motor no toca git ni GitHub: clonar,
`validate_diff`, commit y PR siguen en `services.py`, iguales para cualquier motor.
- **Corte por inactividad**, no por reloj: cada evento SSE **de la sesión del job** reinicia
un reloj de `AGENT_IDLE_TIMEOUT_SECONDS` (10 min). `server.heartbeat`, `plugin.added` y
demás eventos globales no llevan sesión y **no cuentan**: siguen llegando con el agente
colgado. Una herramienta en `pending`/`running` pausa el reloj (una suite larga dentro de
`bash` no emite nada hasta acabar). El techo de 6 h queda de red de seguridad.
- **Forma de los eventos** (opencode 1.19.0): herramienta = `message.part.updated` con
`part.type == "tool"`, `part.tool`, `part.state.status`, `part.state.input.filePath`
**absoluta**; texto = `message.part.delta`; tokens y coste en `part.type == "step-finish"`.
A la UI solo va `{tool, path relativa}`: nunca texto del modelo ni rutas del servidor.
- **Un modelo que el runtime no conoce da un 500 genérico** en `POST /message`; el motivo
(«Model not found: …») solo llega por el evento `session.error`, y el motor lo usa como
mensaje del job (esperando hasta 5 s: el 500 vuelve **antes** que el evento). El caso
real fue un modelo `deprecated` en models.dev, que `opencode` oculta; el catálogo de Vesper
ya los filtra.
- **El SSE pasa por el proxy de Next sin bufferizar** (`text/event-stream`); con
`res.text()` la UI no recibía nada hasta el final.
- **Banco de evals**: `python -m evals.run --model <proveedor/modelo>` con
`VESPER_EVAL_CREDENTIALS="proveedor=key"`. Gasta saldo y ejecuta código que escribe el
modelo sobre el fixture: solo a mano, nunca en CI.

## Deuda conocida de este tema

- **El installation token sigue apareciendo en la línea de comandos del push.**
Expand Down
2 changes: 2 additions & 0 deletions .claude/rules/byok.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ en total. No mantenemos ninguna lista: models.dev es la misma fuente que usa
opencode, así que nunca ofrecemos un modelo que el runtime no sepa ejecutar
(`src/agents/catalog.py`, cacheado 24 h, con semilla si el servicio no
responde).
Los modelos `status: "deprecated"` se filtran: `opencode` los oculta y un job con
uno moría con «Model not found» (`deepseek-v4-flash`, renombrado a `deepseek-flash`).

**Los modelos van en formato `proveedor/modelo`** (`opencode-go/glm-5.2`,
`openai/gpt-5`). Sin el prefijo, `split_model` asume `opencode-go`. Defaults en
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/roadmap-fases.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ paths:
| F3 GitHub App | ✅ **Verificado con una App real** | Instalación **con verificación OAuth de propiedad**, enrollment, sync (42 repos reales). |
| F4 Agent runner | ✅ **Cerrada punta a punta** | Job real completo: clone con installation token → `opencode serve` → modelo del tenant → diff por git → validación → push → **PR abierto por el bot**. `opencode` se queda como primer harness (F6 lo pone detrás de una interfaz). En producción: PRs reales con `deepseek-v4-pro`. |
| F5 Triggers | ✅ **Validada punta a punta** | Los 4 disparos + cola Arq con worker aparte. Verificado contra `vesper-testbed` con payloads firmados de verdad: webhook → job `trigger=webhook`, debounce, anti-bucle, cron que avanza `next_run_at` sin duplicar, prompt libre y UI de reglas. |
| **F6 Motor del agente** | 🔨 **Replanteada el 2026-10-15** | **Ya no se construye un motor propio.** Harness intercambiable detrás de una interfaz de motor (`opencode` primero, ya en producción); el esfuerzo va al **contenedor por job** que corre tests y Playwright (PRs verificados y aislamiento entre tenants) y a **evals** para comparar harnesses (`pi`, DeepSeek Harness) con datos. La spec del 2026-08-04 queda superada en lo del motor propio; pendiente la spec revisada. |
| **F6 Motor del agente** | 🔨 **Replanteada el 2026-10-15** | **Ya no se construye un motor propio.** Harness intercambiable detrás de una interfaz de motor (`opencode` primero, ya en producción); el esfuerzo va al **contenedor por job** que corre tests y Playwright (PRs verificados y aislamiento entre tenants) y a **evals** para comparar harnesses (`pi`, DeepSeek Harness) con datos. La spec del 2026-08-04 queda superada en lo del motor propio; **F6a** (interfaz de motor, corte por inactividad, actividad en vivo, banco de evals) en `docs/superpowers/specs/2026-10-15-f6a-interfaz-de-motor-design.md`; después F6b (contenedor por job, servicio Docker aislado en el compose) y F6c (`pi`). |
| F9 Deploy | ✅ **En producción desde el 2026-10-15** | Compose único (backend, worker, frontend, Postgres, Redis), auto-deploy por webhook verificado, registro cerrado salvo la primera cuenta. Detalle en `produccion-deploy.md`. |
| F7 Reviewer · F8 PWA | ⬜ | Después de F6. La interfaz ya se rediseñó (ver `vesper-frontend/DESIGN.md`). |

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,6 @@ _next.log
# --- Preferencias personales de Claude Code (no se comparten) ---
CLAUDE.local.md
.claude/settings.local.json

# Resultados del banco de evals (se generan a mano y gastan saldo)
vesper-backend/evals/results/
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class AgentEngine(Protocol):
async def stop(self) -> None: ...
```

- `Activity` = `{kind, tool, path, message}`; todos opcionales salvo `kind`.
- `Activity` = `{tool, path}`; `path` relativa al workdir u opcional.
- **El motor no toca git ni GitHub.** Clonar, capturar el diff, `validate_diff`, commit,
push y PR siguen en `services.py`, iguales para todos los motores. Así la seguridad del
flujo (`validate_diff`, `EPHEMERAL_PATTERNS`, token fuera de `.git/config`) no depende
Expand Down Expand Up @@ -107,14 +107,16 @@ emite, este apartado se rediseña antes de escribir código.
## 5. Progreso visible

`on_activity` publica en el bus existente un evento SSE nuevo `activity`
(`{tool, path, message}`):
(`{tool, path}`):

- **Throttle**: como mucho uno cada 3 s por job; el reloj de inactividad se reinicia con
todos, el reenvío solo con los que pasan el throttle.
- **Redacción**: `message` pasa por `SECRET_PATTERNS` y se trunca a 200 caracteres. Las
rutas se dan relativas al workspace (nunca la ruta del servidor).
- **Sin texto libre**: solo el nombre de la herramienta y la ruta relativa al workspace
(`None` si cae fuera). Ni texto del modelo ni argumentos de búsqueda, así que no hay nada
que redactar. Las rutas nunca son las del servidor.
- **No se persiste** en base de datos: es progreso en vivo. La tabla de pasos de la spec
vieja queda fuera.
- El proxy de Next pasa los `text/event-stream` sin bufferizar.

Frontend: `job-thread` muestra la última actividad («Editando `src/app.py`») debajo del
estado mientras el job está `running`. Sin actividad recibida, se queda como hoy.
Expand Down Expand Up @@ -178,7 +180,7 @@ evals/
## 10. Criterios de cierre

1. Un job real en producción abre un PR pasando por `OpencodeEngine`.
2. `services.py` no importa nada de `runner.py` ni de `sandbox.py`.
2. `services.py` no importa `OpencodeRunner` ni `AgentSandbox` (solo `split_model`).
3. El corte por inactividad está cubierto por test y verificado con el `opencode` real
(la primera tarea del plan).
4. El hilo del job muestra actividad en vivo en producción.
Expand Down
Empty file.
3 changes: 3 additions & 0 deletions vesper-backend/evals/fixtures/repo-basico/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# repo-basico

Repo de prueba del banco de evals de Vesper. `calc.suma` tiene un bug a propósito.
6 changes: 6 additions & 0 deletions vesper-backend/evals/fixtures/repo-basico/calc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def suma(a, b):
return a - b


def resta(a, b):
return a - b
9 changes: 9 additions & 0 deletions vesper-backend/evals/fixtures/repo-basico/test_calc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from calc import resta, suma


def test_suma():
assert suma(2, 3) == 5


def test_resta():
assert resta(5, 3) == 2
5 changes: 5 additions & 0 deletions vesper-backend/evals/fixtures/repo-basico/test_texto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from texto import saluda


def test_saluda():
assert saluda("Ana") == "Hola, Ana"
2 changes: 2 additions & 0 deletions vesper-backend/evals/fixtures/repo-basico/texto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def saluda(nombre):
return f"Hola, {nombre}"
150 changes: 150 additions & 0 deletions vesper-backend/evals/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Banco de evals de Vesper (F6a). GASTA SALDO de la key: solo a mano.

VESPER_EVAL_CREDENTIALS="deepseek=sk-..." \\
python -m evals.run --model deepseek/deepseek-v4-pro [--task bug-suma]

Corre el motor directamente sobre una copia del fixture (sin GitHub ni base de
datos) y saca una tabla: tarea · modelo · motor · resultado · segundos · tokens.
"""

from __future__ import annotations

import argparse
import asyncio
import os
import shutil
import subprocess
import tempfile
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path

from evals.tasks import FIXTURE, TASKS, EvalTask
from src.agents.engine import Activity, AgentEngine, EngineResult
from src.agents.opencode_engine import OpencodeEngine
from src.review_jobs.constants import EPHEMERAL_PATTERNS

EngineFactory = Callable[[str], AgentEngine]


@dataclass
class Row:
task: str
model: str
engine: str
passed: bool
seconds: float
tokens: int
detail: str = ""


def _git(workdir: Path, *args: str) -> str:
return subprocess.run(
["git", "-c", "user.name=eval", "-c", "user.email=eval@vesper", *args],
cwd=workdir,
capture_output=True,
text=True,
check=True,
).stdout


async def run_task(
task: EvalTask,
model: str,
credentials: dict[str, str],
engine_factory: EngineFactory,
) -> Row:
base = Path(tempfile.mkdtemp(prefix="vesper-eval-"))
workdir = base / "repo"
shutil.copytree(FIXTURE, workdir)
_git(workdir, "init", "-q")
# Como el flujo real: lo que genera el entorno (.pyc, caches) no cuenta como cambio.
(workdir / ".git" / "info" / "exclude").write_text(
"\n".join(EPHEMERAL_PATTERNS) + "\n", encoding="utf-8"
)
_git(workdir, "add", "-A")
_git(workdir, "commit", "-q", "-m", "fixture")

engine = engine_factory(f"eval-{uuid.uuid4().hex[:8]}")
started = time.monotonic()
result, detail = EngineResult(), ""

async def ignore(_: Activity) -> None:
return None

try:
result = await engine.run(workdir, task.prompt, model, credentials, ignore)
except Exception as exc:
# Un motor que revienta suspende su tarea; no tumba el resto del banco.
detail = str(exc) or repr(exc)
finally:
await engine.stop()

passed = False
if not detail:
_git(workdir, "add", "-A")
diff = _git(workdir, "diff", "--cached")
passed = task.check(workdir, diff)
changed = _git(workdir, "diff", "--cached", "--name-only").split()
detail = "" if passed else f"no se cumplió; cambió: {', '.join(changed) or 'nada'}"
shutil.rmtree(base, ignore_errors=True)
return Row(
task=task.name,
model=model,
engine=type(engine).__name__,
passed=passed,
seconds=round(time.monotonic() - started, 1),
tokens=result.tokens_input + result.tokens_output,
detail=detail,
)


def table(rows: list[Row]) -> str:
lines = [
"| Tarea | Modelo | Motor | Resultado | s | Tokens | Detalle |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
for r in rows:
mark = "✅" if r.passed else "❌"
cells = [r.task, r.model, r.engine, mark, str(r.seconds), str(r.tokens), r.detail]
lines.append("| " + " | ".join(cells) + " |")
return "\n".join(lines)


def _credentials() -> dict[str, str]:
raw = os.environ.get("VESPER_EVAL_CREDENTIALS", "")
pairs = (item.split("=", 1) for item in raw.split(",") if "=" in item)
return {provider.strip(): key.strip() for provider, key in pairs}


async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--model", required=True, help="proveedor/modelo")
parser.add_argument("--task", action="append", help="solo estas tareas")
args = parser.parse_args()

credentials = _credentials()
if not credentials:
raise SystemExit('Falta VESPER_EVAL_CREDENTIALS="proveedor=key"')
tasks = [t for t in TASKS if not args.task or t.name in args.task]
root = Path(tempfile.mkdtemp(prefix="vesper-eval-runtime-"))
rows = []
for task in tasks:
rows.append(
await run_task(task, args.model, credentials, lambda jid: OpencodeEngine(root, jid))
)
print(table(rows[-1:]).splitlines()[-1], flush=True)

report = table(rows)
print("\n" + report)
out = Path(__file__).parent / "results"
out.mkdir(exist_ok=True)
stamp = datetime.now(UTC).strftime("%Y-%m-%d-%H%M")
(out / f"{stamp}.md").write_text(report + "\n", encoding="utf-8")


if __name__ == "__main__":
asyncio.run(main())
61 changes: 61 additions & 0 deletions vesper-backend/evals/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Tareas del banco de evals: un encargo y como se comprueba que salio bien.

Cada comprobacion mira el resultado (el diff, la suite del fixture), no lo que el
agente dice que hizo.
"""

from __future__ import annotations

import subprocess
import sys
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path

FIXTURE = Path(__file__).parent / "fixtures" / "repo-basico"


@dataclass(frozen=True)
class EvalTask:
name: str
prompt: str
check: Callable[[Path, str], bool] # (workdir, git diff) -> aprobado


def suite_passes(workdir: Path, *tests: str) -> bool:
# Ejecuta codigo que el modelo acaba de escribir: el banco solo se lanza a
# mano, sobre el fixture, nunca en CI ni contra repos de tenants.
result = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", *tests],
cwd=workdir,
capture_output=True,
timeout=120,
)
return result.returncode == 0


def _docstring_ok(workdir: Path, diff: str) -> bool:
texto = (workdir / "texto.py").read_text(encoding="utf-8")
return '"""' in texto and suite_passes(workdir, "test_texto.py")


TASKS = [
EvalTask(
name="bug-suma",
prompt="Los tests de calc.py fallan. Encuentra el bug y arréglalo sin tocar los tests.",
check=lambda workdir, diff: "test_calc.py" not in diff and suite_passes(workdir),
),
EvalTask(
name="docstrings",
prompt="Añade docstrings a las funciones de texto.py. No cambies su comportamiento.",
check=_docstring_ok,
),
EvalTask(
name="nada-que-hacer",
prompt=(
"Revisa texto.py y test_texto.py. Si son correctos, no cambies nada: "
"no hace falta mejorar nada que ya funcione."
),
check=lambda workdir, diff: diff.strip() == "",
),
]
10 changes: 9 additions & 1 deletion vesper-backend/src/agents/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,15 @@ def _trim(raw: dict) -> dict[str, dict]:
if not isinstance(provider, dict):
continue
modelos = provider.get("models") or {}
if not isinstance(modelos, dict) or not modelos:
if not isinstance(modelos, dict):
continue
# opencode oculta los obsoletos: ofrecerlos acababa en "Model not found".
modelos = {
model_id: m
for model_id, m in modelos.items()
if (m or {}).get("status") != "deprecated"
}
if not modelos:
continue
catalogo[provider_id] = {
"name": provider.get("name") or provider_id,
Expand Down
Loading
Loading