Phase 6.3.17: measure the gate 5 baseline against production

Ran the full agent loop against vllm-qwen36 on port 8000 (read-only HTTP)
with the Penpot plugin live, before asking for any downtime. Without this
file "it improved" would be a claim rather than a measurement.

Result over the 8 graded prompts: mean score 14.9, zero prompts at or above
60, veto violated on 2 of 8, 66% of execute_code calls raised, and 7 of 8
prompts burned all 14 turns without producing a final message.

The plan predicted production would score near zero on distinct colours and
style richness while producing a high shape count - grey boxes. The shape
count is also zero. On a fresh page it creates nothing at all, so the
failure sits upstream of the grey boxes: the model invents a Figma-shaped
API wholesale and every call throws. From the captured turns:

  penpot.currentPage()                     is a property, not a function
  penpot.createRectangle(page, 200, 56)    takes no arguments
  penpot.createText(page, ...)             takes one, the text
  penpot.getPageById(...)                  lives on penpotUtils
  fills   = [{type:'solid', color:{r,g,b,a}}]   is {fillColor, fillOpacity}
  shadows = [{type:'drop', x, y, blur, ...}]    is {style, offsetX, offsetY}

It then spends the remaining turns querying penpot_api_info without
recovering. So the reported symptom understated it.

Two robustness fixes the run itself forced, both after losing a completed
run to them:
- A ConnectionError does not just drop the request, it can drop the MCP
  session, so retrying the same tools/call against a dead session fails
  identically every time - which is exactly what the first attempt showed,
  four retries and four identical ConnectionErrors. The client now redoes
  the handshake before retrying, and that recovered two drops in this run.
- Results are written after every prompt. The first attempt died on prompt
  4 and lost the three already measured, which is the expensive data
  precisely because it requires production to be up.
This commit is contained in:
2026-07-30 18:14:03 +00:00
parent c9792c5c40
commit 5f0ddd962c
3 changed files with 4582 additions and 18 deletions
+62 -18
View File
@@ -205,6 +205,7 @@ class PenpotMCP:
self.session_id = None
self.tool_names = []
self._next_id = 0
self._in_handshake = False
self._http = requests.Session()
# -- transporte -------------------------------------------------------------------
@@ -306,10 +307,21 @@ class PenpotMCP:
ultimo = e
if intento == MCP_MAX_RETRIES:
break
espera = MCP_RETRY_BACKOFF * intento
espera = MCP_RETRY_BACKOFF * (2 ** (intento - 1))
print(f"[MCP] fallo transitorio en '{method}' (intento {intento}/"
f"{MCP_MAX_RETRIES}): {e}. Reintento en {espera:.0f}s")
time.sleep(espera)
# Un ConnectionError no solo tira la request: puede tirar la SESION. Reintentar
# el mismo tools/call contra una sesion muerta falla siempre igual, que es lo
# que se vio en la primera corrida (4 intentos identicos, 4 ConnectionError).
# Hay que rehacer el handshake antes de reintentar.
if not self._in_handshake and method != "initialize":
try:
print("[MCP] rehaciendo el handshake (la sesion pudo haber muerto)")
self.session_id = None
self.handshake()
except Gate5Error as e2:
print(f"[MCP] el re-handshake tambien fallo: {e2}")
raise Gate5Error(f"el MCP de Penpot fallo {MCP_MAX_RETRIES} veces en '{method}': {ultimo}")
def _notify(self, method, params=None):
@@ -320,6 +332,13 @@ class PenpotMCP:
# -- protocolo --------------------------------------------------------------------
def handshake(self):
self._in_handshake = True
try:
return self._handshake_inner()
finally:
self._in_handshake = False
def _handshake_inner(self):
result = self._request("initialize", {
"protocolVersion": self.PROTOCOL_VERSION,
"capabilities": {},
@@ -1280,29 +1299,54 @@ def run_full(cfg, tools, system_prompt):
print(f"[MCP] sonda de conectividad del plugin: {sonda[:120]}")
png_dir = REPO_ROOT / "data" / f"gate5_png_{cfg['GATE5_TAG']}"
salida = REPO_ROOT / "data" / f"gate5_results_{cfg['GATE5_TAG']}.json"
def volcar(resultados_parciales, dt_parcial, veredicto_parcial=None, completo=False):
"""Escribe el JSON de resultados. Se llama DESPUES DE CADA PROMPT.
La primera corrida del baseline murio en el prompt 4 por una caida de conexion del MCP
y se perdio la medicion de los tres que ya habian terminado -- que era justamente el
dato caro, porque exige tener produccion arriba. Un fallo de transporte no puede costar
el trabajo ya hecho.
"""
salida.write_text(json.dumps({
"modo": "completo" if completo else "parcial",
"tag": cfg["GATE5_TAG"],
"modelo": cfg["GATE5_MODEL"],
"max_turnos": MAX_TURNS,
"max_tokens": MAX_TOKENS,
"timestamp": datetime.now(timezone.utc).isoformat(),
"duracion_s": round(dt_parcial, 1),
"prompts_completados": len(resultados_parciales),
"prompts_totales": len(prompts),
"metricas": [{"clave": k, "etiqueta": e, "comparador": c} for k, e, c in METRICS],
"veto": list(VETO_METRICS),
"umbrales": {pid: {k: (list(v) if isinstance(v, tuple) else v) for k, v in u.items()}
for pid, u in THRESHOLDS.items()},
"veredicto": veredicto_parcial,
"prompts": resultados_parciales,
}, ensure_ascii=False, indent=2), encoding="utf-8")
t0 = time.time()
resultados = [run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir) for fila in prompts]
resultados = []
for fila in prompts:
try:
resultados.append(run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir))
except PluginNotConnected:
volcar(resultados, time.time() - t0)
raise
except Gate5Error as e:
print(f"[ERROR] {fila['id']}: {e}")
print(f"[INFO] se guardan los {len(resultados)} prompt(s) ya medidos y se sigue con "
f"el siguiente; el JSON queda marcado como parcial")
resultados.append({"id": fila["id"], "error": str(e), "score": None})
volcar(resultados, time.time() - t0)
dt = time.time() - t0
aprueba, veredicto = resumen(resultados, cfg["GATE5_TAG"])
print(f"\n tiempo total: {dt / 60:.1f} min")
salida = REPO_ROOT / "data" / f"gate5_results_{cfg['GATE5_TAG']}.json"
salida.write_text(json.dumps({
"modo": "completo",
"tag": cfg["GATE5_TAG"],
"modelo": cfg["GATE5_MODEL"],
"max_turnos": MAX_TURNS,
"max_tokens": MAX_TOKENS,
"timestamp": datetime.now(timezone.utc).isoformat(),
"duracion_s": round(dt, 1),
"metricas": [{"clave": k, "etiqueta": e, "comparador": c} for k, e, c in METRICS],
"veto": list(VETO_METRICS),
"umbrales": {pid: {k: (list(v) if isinstance(v, tuple) else v) for k, v in u.items()}
for pid, u in THRESHOLDS.items()},
"veredicto": veredicto,
"prompts": resultados,
}, ensure_ascii=False, indent=2), encoding="utf-8")
volcar(resultados, dt, veredicto_parcial=veredicto, completo=True)
print(f" resultados en {salida.relative_to(REPO_ROOT)}")
print(f" PNGs en {png_dir.relative_to(REPO_ROOT)}/ (la puerta no borra nada: las paginas "
f"quedan para inspeccion humana)")