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:
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user