2463 lines
92 KiB
Python
2463 lines
92 KiB
Python
"""Compare OpenSquilla meta-skills against an OpenClaw gateway.
|
|
|
|
The script defines seven fixed benchmark cases for the high-value meta-skill
|
|
scenarios and can run them end-to-end through both gateways.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import re
|
|
import textwrap
|
|
import time
|
|
import uuid
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
REPORT_DIR = Path(
|
|
os.environ.get("OPENSQUILLA_COMPARE_REPORT_DIR", ".reports/meta-skill-comparison")
|
|
)
|
|
JUDGE_SUBSCORE_RANGES: dict[str, tuple[int, int]] = {
|
|
"final_artifact_quality": (0, 40),
|
|
"task_completion": (0, 20),
|
|
"evidence_traceability": (0, 15),
|
|
"actionability": (0, 10),
|
|
"risk_boundary_safety": (0, 10),
|
|
"meta_skill_fit": (0, 5),
|
|
}
|
|
|
|
OPENCLAW_BASELINE_WARMUP = (
|
|
"本轮是任务评估会话。身份、称呼和初始化已经完成;后续请直接处理用户请求,"
|
|
"不要询问姓名、称呼、workspace/bootstrap/onboarding,也不要要求用户在 A/B "
|
|
"路径中选择。"
|
|
)
|
|
|
|
BENCHMARK_CONSTRAINTS = (
|
|
"Benchmark constraints: return the final deliverable inline in chat. "
|
|
"Do not create, edit, or write local files. If you would normally create "
|
|
"a PDF, DOCX, SKILL.md, patch, or other artifact, include artifact-ready "
|
|
"content inline instead. You may name verification commands, but do not "
|
|
"execute them.\n\n"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RubricCriterion:
|
|
name: str
|
|
description: str
|
|
patterns: tuple[str, ...]
|
|
weight: int = 1
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ComparisonCase:
|
|
case_id: str
|
|
skill_name: str
|
|
prompt: str
|
|
expected_advantage: str
|
|
optimization_if_not_better: str
|
|
scenario: str = "primary"
|
|
rubric: tuple[RubricCriterion, ...] = ()
|
|
failure_modes: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResponseScore:
|
|
total: int
|
|
dimensions: dict[str, int]
|
|
notes: list[str]
|
|
|
|
|
|
@dataclass
|
|
class EndpointResult:
|
|
endpoint: str
|
|
case_id: str
|
|
ok: bool
|
|
elapsed_s: float
|
|
response_text: str
|
|
score: dict[str, Any]
|
|
error: str | None = None
|
|
session_key: str | None = None
|
|
model: str | None = None
|
|
provider: str | None = None
|
|
event_count: int = 0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class JudgeResult:
|
|
winner: str
|
|
scores: dict[str, int]
|
|
confidence: float
|
|
rationale: str
|
|
risks: list[str]
|
|
raw: dict[str, Any]
|
|
model: str
|
|
|
|
|
|
def criterion(
|
|
name: str,
|
|
description: str,
|
|
*patterns: str,
|
|
weight: int = 1,
|
|
) -> RubricCriterion:
|
|
return RubricCriterion(name, description, tuple(patterns), weight)
|
|
|
|
|
|
SKILL_RUBRICS: dict[str, tuple[RubricCriterion, ...]] = {
|
|
"meta-paper-write": (
|
|
criterion(
|
|
"paper_sections",
|
|
"Includes canonical manuscript sections.",
|
|
r"abstract",
|
|
r"introduction",
|
|
r"method",
|
|
r"evaluation",
|
|
),
|
|
criterion(
|
|
"latex_ready", "Provides LaTeX or BibTeX-safe structure.", r"latex", r"\\begin", r"bib"
|
|
),
|
|
criterion(
|
|
"citation_integrity",
|
|
"Avoids fabricated citations by marking placeholders.",
|
|
r"placeholder",
|
|
r"citation",
|
|
r"reference",
|
|
),
|
|
criterion(
|
|
"length_plan",
|
|
"Explains how the draft scales to a full paper.",
|
|
r"page",
|
|
r"expand",
|
|
r"full version",
|
|
),
|
|
criterion(
|
|
"limitations", "Includes limitations and threats to validity.", r"limitation", r"threat"
|
|
),
|
|
),
|
|
"meta-pdf-intelligence": (
|
|
criterion(
|
|
"page_traceability", "Preserves page-level evidence.", r"page\s+\d+", r"p\.\s*\d+"
|
|
),
|
|
criterion(
|
|
"fact_digest",
|
|
"Extracts facts rather than generic summary.",
|
|
r"fact",
|
|
r"key finding",
|
|
r"digest",
|
|
),
|
|
criterion(
|
|
"open_questions",
|
|
"Lists open questions or missing evidence.",
|
|
r"open question",
|
|
r"unknown",
|
|
r"missing",
|
|
),
|
|
criterion(
|
|
"memory_index",
|
|
"Builds a reusable memory/index structure.",
|
|
r"memory index",
|
|
r"index",
|
|
r"tag",
|
|
),
|
|
criterion(
|
|
"no_hallucinated_pdf",
|
|
"Acknowledges missing document limits.",
|
|
r"provided excerpt",
|
|
r"cannot verify",
|
|
r"upload",
|
|
),
|
|
),
|
|
"meta-stack-trace-investigator": (
|
|
criterion(
|
|
"frame_parsing",
|
|
"Identifies failing frame, exception, and data shape.",
|
|
r"KeyError",
|
|
r"frame",
|
|
r"parse",
|
|
),
|
|
criterion(
|
|
"root_cause",
|
|
"Provides ranked root-cause hypotheses.",
|
|
r"root cause",
|
|
r"hypothesis",
|
|
r"likely",
|
|
),
|
|
criterion("repo_search", "Gives concrete repo search targets.", r"rg ", r"grep", r"search"),
|
|
criterion(
|
|
"reproduction",
|
|
"Gives a reproduction or focused check.",
|
|
r"repro",
|
|
r"fixture",
|
|
r"minimal",
|
|
),
|
|
criterion(
|
|
"verification", "Gives exact verification commands.", r"pytest", r"command", r"verify"
|
|
),
|
|
),
|
|
"meta-travel-planner": (
|
|
criterion(
|
|
"constraint_capture",
|
|
"Captures dates, party, pace, budget, and interests.",
|
|
r"assumption",
|
|
r"constraint",
|
|
r"budget",
|
|
),
|
|
criterion(
|
|
"geo_grouping",
|
|
"Groups activities by neighborhood/transit.",
|
|
r"neighborhood",
|
|
r"transit",
|
|
r"route",
|
|
),
|
|
criterion(
|
|
"daily_schedule", "Produces day-by-day itinerary.", r"day\s+1", r"day\s+2", r"schedule"
|
|
),
|
|
criterion(
|
|
"weather_backup",
|
|
"Includes rain or weather backup plan.",
|
|
r"rain",
|
|
r"weather",
|
|
r"backup",
|
|
),
|
|
criterion(
|
|
"variants", "Includes variants or alternatives.", r"variant", r"alternative", r"swap"
|
|
),
|
|
),
|
|
"meta-skill-creator": (
|
|
criterion("trigger_inputs", "Defines triggers and inputs.", r"trigger", r"input"),
|
|
criterion(
|
|
"step_graph",
|
|
"Defines a workflow graph or ordered steps.",
|
|
r"step",
|
|
r"graph",
|
|
r"workflow",
|
|
),
|
|
criterion(
|
|
"skill_preview", "Shows a SKILL.md-style preview.", r"SKILL\.md", r"```", r"name:"
|
|
),
|
|
criterion(
|
|
"collision_risk",
|
|
"Checks collisions with existing skills.",
|
|
r"collision",
|
|
r"overlap",
|
|
r"existing",
|
|
),
|
|
criterion(
|
|
"gates", "Defines lint, smoke, safety, or install gates.", r"gate", r"lint", r"smoke"
|
|
),
|
|
),
|
|
"meta-migration-assistant": (
|
|
criterion(
|
|
"migration_scope",
|
|
"Identifies source and target migration states.",
|
|
r"CommonJS",
|
|
r"ESM",
|
|
r"from",
|
|
r"to",
|
|
),
|
|
criterion(
|
|
"breaking_changes", "Names breaking changes.", r"breaking", r"interop", r"compat"
|
|
),
|
|
criterion(
|
|
"grep_patterns",
|
|
"Provides grep/search patterns.",
|
|
r"rg ",
|
|
r"grep",
|
|
r"require\(",
|
|
r"module\.exports",
|
|
),
|
|
criterion(
|
|
"validation_commands",
|
|
"Provides validation commands.",
|
|
r"test",
|
|
r"build",
|
|
r"command",
|
|
r"verify",
|
|
),
|
|
criterion(
|
|
"rollout_risk", "Includes staged rollout risks.", r"rollout", r"risk", r"rollback"
|
|
),
|
|
),
|
|
}
|
|
|
|
|
|
COMPARISON_CASES: list[ComparisonCase] = [
|
|
ComparisonCase(
|
|
case_id="paper_write",
|
|
skill_name="meta-paper-write",
|
|
prompt=(
|
|
"I'm preparing to draft a paper on meta-skill orchestration for AI "
|
|
"agents, but I only need the first pass today. Please produce an "
|
|
"academic manuscript plan and a compact LaTeX-ready draft skeleton. "
|
|
"Include abstract, introduction, method, evaluation design, expected "
|
|
"results, limitations, and at least 20 reference placeholders. Also "
|
|
"explain how the full version would reach 10+ pages."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should preserve paper structure, citation planning, length "
|
|
"gate, citation integrity gate, and LaTeX sanitization."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Make the paper workflow expose a short benchmark mode while keeping "
|
|
"the full 10-page gate for production paper requests."
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="pdf_intelligence",
|
|
skill_name="meta-pdf-intelligence",
|
|
prompt=(
|
|
"I don't have the PDF upload handy, but please treat this as a PDF "
|
|
"intelligence task from `agent-observability.pdf`: page 3 says "
|
|
"'Trace spans identify tool calls, model routing, and error recovery.' "
|
|
"Page 4 says 'missing provenance makes evaluation unreliable.' Return "
|
|
"a traceable digest with page evidence, key facts, open questions, "
|
|
"and a reusable memory index."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should classify the PDF task, preserve document/page "
|
|
"references, synthesize traceably, and create a memory index."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Add an inline-excerpt fallback path for PDF intelligence when the user "
|
|
"provides page excerpts instead of a file upload."
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="stack_trace_investigator",
|
|
skill_name="meta-stack-trace-investigator",
|
|
prompt=(
|
|
"Can you investigate this stack trace from our agent runtime?\n"
|
|
"Traceback (most recent call last):\n"
|
|
' File "src/agent/runtime.py", line 88, in run_step\n'
|
|
" payload = parse_tool_result(raw)\n"
|
|
' File "src/agent/tools.py", line 41, in parse_tool_result\n'
|
|
" return json.loads(raw)['result']\n"
|
|
"KeyError: 'result'\n\n"
|
|
"I need root-cause hypotheses, repo search targets, related checks, "
|
|
"and exact verification commands I can run next."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should classify the runtime, parse frames, search repo "
|
|
"symbols, inspect history/issues, and synthesize verification commands."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Improve degraded behavior when repo symbols are absent by producing "
|
|
"language-specific reproduction snippets and patch targets."
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="travel_planner",
|
|
skill_name="meta-travel-planner",
|
|
prompt=(
|
|
"My partner and I are visiting Tokyo for the first time in late June. "
|
|
"Could you build a 3-day travel plan with a balanced pace? We care "
|
|
"about food, transit-friendly neighborhood grouping, rain backups, "
|
|
"and a moderate budget. Please include your assumptions, a daily "
|
|
"schedule, weather-aware risks, a few variants, and budget notes."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should infer trip preferences, check weather/search results, "
|
|
"extract constraints, and append variants plus bad-weather backup."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Improve constraint extraction for dates, opening hours, neighborhood "
|
|
"grouping, and budget notes before itinerary drafting."
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="meta_skill_creator",
|
|
skill_name="meta-skill-creator",
|
|
prompt=(
|
|
"I want to compose a meta-skill for our analyst workflow. It should "
|
|
"combine web research, PDF intelligence, and a final docx export into "
|
|
"a reusable due-diligence brief workflow. Please include triggers, "
|
|
"inputs, the step graph, collision risks, gates, and a preview of the "
|
|
"SKILL.md."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should distinguish meta-skill vs normal skill, harvest "
|
|
"patterns, assemble a candidate, run collision/risk/lint/smoke gates, "
|
|
"and show a proposal preview."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Expose clearer preview sections and make collision/risk findings more "
|
|
"visible when the generated skill is only a draft."
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="migration_assistant",
|
|
skill_name="meta-migration-assistant",
|
|
prompt=(
|
|
"We're planning to migrate a small frontend package from CommonJS "
|
|
"to native ESM next sprint. Please give me a practical migration "
|
|
"checklist with breaking changes, grep patterns for files likely "
|
|
"affected, validation commands, and rollout risks. Assume this is "
|
|
"for the current repo, but don't make up files you cannot verify."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should classify the migration kind, route to an "
|
|
"authoritative guide source, optionally inspect current repo diff "
|
|
"context, and produce a concrete validation checklist."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Strengthen migration-kind classification, make repo-context use "
|
|
"more selective, and require explicit source/command evidence in "
|
|
"the final checklist."
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
COMPARISON_CASES.extend(
|
|
[
|
|
ComparisonCase(
|
|
case_id="paper_write_citation_boundary",
|
|
skill_name="meta-paper-write",
|
|
scenario="degraded",
|
|
prompt=(
|
|
"Draft a LaTeX-ready extended abstract about agent meta-skill "
|
|
"orchestration. Do not fabricate real citations; use BibTeX keys "
|
|
"like TODO:smith2026 until I provide a library. Include a related "
|
|
"work plan, evaluation table, limitations, and a path to a 10-page "
|
|
"paper."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should keep manuscript structure while enforcing "
|
|
"citation integrity rather than inventing references."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Make citation-integrity gating explicit for short paper drafts and "
|
|
"mark unresolved references as TODO placeholders."
|
|
),
|
|
failure_modes=(
|
|
"Invents real-looking citations.",
|
|
"Omits LaTeX-ready structure.",
|
|
"Does not explain expansion to full paper length.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="paper_write_scope_control",
|
|
skill_name="meta-paper-write",
|
|
scenario="boundary",
|
|
prompt=(
|
|
"I do not want the full paper yet. Produce a one-page manuscript "
|
|
"brief for a future paper on meta-skill evaluation: thesis, section "
|
|
"outline, evaluation design, risks to validity, and what evidence "
|
|
"must be collected before writing."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should respect the user's scope and produce a planning "
|
|
"artifact instead of a long manuscript."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Add scope-control checks so paper mode can return briefs, outlines, "
|
|
"or full drafts intentionally."
|
|
),
|
|
failure_modes=(
|
|
"Overproduces a full paper despite the request.",
|
|
"Omits evidence collection plan.",
|
|
"No limitations or validity risks.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="pdf_intelligence_missing_file_boundary",
|
|
skill_name="meta-pdf-intelligence",
|
|
scenario="boundary",
|
|
prompt=(
|
|
"I forgot to attach the PDF. The title is Observability for Agentic "
|
|
"Systems, but I do not remember the contents. Give me the intake "
|
|
"questions, extraction plan, evidence table schema, and what you "
|
|
"must not claim until the file is available."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should fail gracefully without hallucinating document "
|
|
"content and should prepare a traceable extraction workflow."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Improve missing-PDF handling with an intake template and explicit "
|
|
"non-claims section."
|
|
),
|
|
failure_modes=(
|
|
"Summarizes a PDF that was not provided.",
|
|
"No evidence table or page schema.",
|
|
"Does not ask for the file or page excerpts.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="pdf_intelligence_two_doc_compare",
|
|
skill_name="meta-pdf-intelligence",
|
|
scenario="degraded",
|
|
prompt=(
|
|
"Compare two provided PDF excerpts. Doc A page 2 says traces show "
|
|
"tool calls and model routing. Doc A page 7 says missing spans hide "
|
|
"retries. Doc B page 4 says cost attribution needs per-step usage. "
|
|
"Return a cross-document evidence matrix, conflicts, open questions, "
|
|
"and a memory index."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should compare page-grounded evidence across documents "
|
|
"and preserve provenance in the output."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Add a multi-document excerpt mode that renders evidence matrices before synthesis."
|
|
),
|
|
failure_modes=(
|
|
"Merges Doc A and Doc B without provenance.",
|
|
"Drops page numbers.",
|
|
"Omits conflicts or open questions.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="stack_trace_ambiguous_boundary",
|
|
skill_name="meta-stack-trace-investigator",
|
|
scenario="boundary",
|
|
prompt=(
|
|
"I only have this vague error: 'tool result parse failed after a "
|
|
"provider retry'. No stack trace yet. Give me the minimum data to "
|
|
"collect, repo search targets, likely failure classes, and commands "
|
|
"to narrow it down without pretending you know the exact root cause."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should avoid false certainty and produce a targeted "
|
|
"diagnostic collection plan."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Improve ambiguous-error mode with evidence requirements before root cause claims."
|
|
),
|
|
failure_modes=(
|
|
"Claims a single root cause without evidence.",
|
|
"No data collection checklist.",
|
|
"No concrete repo search commands.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="stack_trace_js_async",
|
|
skill_name="meta-stack-trace-investigator",
|
|
scenario="degraded",
|
|
prompt=(
|
|
"Investigate this Node stack trace:\n"
|
|
"TypeError: Cannot read properties of undefined (reading 'content')\n"
|
|
" at parseAssistantMessage (src/stream/consumer.ts:77:21)\n"
|
|
" at onDelta (src/stream/consumer.ts:141:9)\n"
|
|
" at processTicksAndRejections (node:internal/process/task_queues:95:5)\n"
|
|
"Include hypotheses, TypeScript grep targets, a minimal fixture, "
|
|
"and verification commands."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should adapt the stack-trace workflow to TypeScript "
|
|
"and produce concrete reproduction and verification targets."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Add language-aware stack parsing and fixture suggestions for "
|
|
"JavaScript/TypeScript traces."
|
|
),
|
|
failure_modes=(
|
|
"Treats it as Python.",
|
|
"No fixture or verification commands.",
|
|
"Does not identify undefined content shape.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="travel_planner_constraints",
|
|
skill_name="meta-travel-planner",
|
|
scenario="degraded",
|
|
prompt=(
|
|
"Plan 4 days in Kyoto for two adults and one parent with knee pain. "
|
|
"We are vegetarian, prefer rail/bus over taxis, need one rest block "
|
|
"per day, and have a tea ceremony booking at 15:00 on day 2 near "
|
|
"Gion. Include assumptions, neighborhood grouping, rain backups, "
|
|
"budget notes, and variants."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should preserve mobility, dietary, fixed-booking, "
|
|
"weather, budget, and transit constraints."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Strengthen constraint extraction and schedule feasibility checks "
|
|
"for accessibility and fixed events."
|
|
),
|
|
failure_modes=(
|
|
"Schedules high-walking days without rest blocks.",
|
|
"Ignores vegetarian constraint.",
|
|
"Misses fixed day-2 booking.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="travel_planner_missing_dates_boundary",
|
|
skill_name="meta-travel-planner",
|
|
scenario="boundary",
|
|
prompt=(
|
|
"I might visit Seoul sometime next year but I have no dates, budget, "
|
|
"or neighborhood preference yet. Give me a planning framework, the "
|
|
"questions you need answered, seasonal tradeoffs, and a sample 2-day "
|
|
"placeholder itinerary clearly marked as tentative."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should ask for missing constraints and mark any sample "
|
|
"itinerary as provisional."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Improve missing-constraint mode so tentative plans are clearly "
|
|
"separated from final itineraries."
|
|
),
|
|
failure_modes=(
|
|
"Presents a final itinerary despite missing dates.",
|
|
"No clarifying questions.",
|
|
"No seasonal tradeoffs.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="meta_skill_creator_collision_boundary",
|
|
skill_name="meta-skill-creator",
|
|
scenario="boundary",
|
|
prompt=(
|
|
"Before creating anything, evaluate whether a new 'due-diligence "
|
|
"brief' meta-skill would collide with existing web research, PDF "
|
|
"intelligence, and doc export skills. Return a collision matrix, "
|
|
"when not to create it, and a minimal SKILL.md preview only if it "
|
|
"is still justified."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should check whether composition is justified before "
|
|
"drafting a new meta-skill."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Add stronger no-new-skill and collision-first gates to the creator workflow."
|
|
),
|
|
failure_modes=(
|
|
"Creates a full skill without collision analysis.",
|
|
"Does not mention existing skill overlap.",
|
|
"No no-create criteria.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="meta_skill_creator_safety_gates",
|
|
skill_name="meta-skill-creator",
|
|
scenario="degraded",
|
|
prompt=(
|
|
"Design a meta-skill that can run repo search and browser research "
|
|
"but must never install dependencies, commit code, or publish files "
|
|
"without explicit user approval. Include triggers, inputs, step "
|
|
"graph, permission gates, failure modes, and a SKILL.md preview."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should encode safety and permission gates as first-class "
|
|
"workflow requirements."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Make generated meta-skills include explicit authority boundaries "
|
|
"and blocked actions by default."
|
|
),
|
|
failure_modes=(
|
|
"No explicit permission gates.",
|
|
"Allows install/commit/publish without approval.",
|
|
"No failure modes.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="migration_assistant_repo_boundary",
|
|
skill_name="meta-migration-assistant",
|
|
scenario="boundary",
|
|
prompt=(
|
|
"Assess whether this repo is ready for a CommonJS to ESM migration, "
|
|
"but do not assume files that you have not inspected. Give me a "
|
|
"repo-discovery checklist, grep patterns, decision gates, and what "
|
|
"would block migration."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should distinguish actual repo evidence from a generic "
|
|
"migration guide."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Improve repo-evidence gating and make unsupported assumptions show up as blockers."
|
|
),
|
|
failure_modes=(
|
|
"Invents repository files.",
|
|
"No discovery checklist.",
|
|
"No migration blockers or decision gates.",
|
|
),
|
|
),
|
|
ComparisonCase(
|
|
case_id="migration_assistant_incremental_rollout",
|
|
skill_name="meta-migration-assistant",
|
|
scenario="degraded",
|
|
prompt=(
|
|
"We need an incremental CommonJS to ESM migration plan for a package "
|
|
"used by downstream apps. Include dual-package hazards, package.json "
|
|
"exports changes, test/build commands, grep patterns, rollback plan, "
|
|
"and release sequencing."
|
|
),
|
|
expected_advantage=(
|
|
"OpenSquilla should produce a practical migration plan with rollout "
|
|
"risk controls instead of only syntax changes."
|
|
),
|
|
optimization_if_not_better=(
|
|
"Strengthen rollout-risk handling for migrations that affect downstream consumers."
|
|
),
|
|
failure_modes=(
|
|
"Only describes import/export syntax.",
|
|
"No rollback or release sequence.",
|
|
"No dual-package hazard discussion.",
|
|
),
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
def rubric_for_case(case: ComparisonCase) -> tuple[RubricCriterion, ...]:
|
|
return case.rubric or SKILL_RUBRICS.get(case.skill_name, ())
|
|
|
|
|
|
def score_response(text: str, case: ComparisonCase | None = None) -> ResponseScore:
|
|
if case is not None and rubric_for_case(case):
|
|
return score_case_response(text, case)
|
|
return score_generic_response(text)
|
|
|
|
|
|
def score_case_response(text: str, case: ComparisonCase) -> ResponseScore:
|
|
dimensions: dict[str, int] = {}
|
|
notes: list[str] = []
|
|
for item in rubric_for_case(case):
|
|
matched = any(re.search(pattern, text, flags=re.I | re.M) for pattern in item.patterns)
|
|
dimensions[item.name] = item.weight if matched else 0
|
|
if not matched:
|
|
notes.append(item.name)
|
|
return ResponseScore(total=sum(dimensions.values()), dimensions=dimensions, notes=notes)
|
|
|
|
|
|
def score_generic_response(text: str) -> ResponseScore:
|
|
lowered = text.lower()
|
|
dimensions = {
|
|
"structure": min(
|
|
5,
|
|
_count_matches(text, [r"^#", r"^\s*[-*]\s+", r"^\s*\d+[.)]\s+", r"\|.+\|"])
|
|
+ (1 if len(text) > 800 else 0),
|
|
),
|
|
"evidence": min(
|
|
5,
|
|
_count_matches(
|
|
text,
|
|
[
|
|
r"https?://",
|
|
r"\[[0-9]+\]",
|
|
r"\bpage\s+\d+\b",
|
|
r"\bsource\b",
|
|
r"\bcitation\b",
|
|
r"\bfile\s+\"?",
|
|
],
|
|
),
|
|
),
|
|
"artifact_readiness": min(
|
|
5,
|
|
_count_words(
|
|
lowered,
|
|
[
|
|
"artifact",
|
|
"docx",
|
|
"pptx",
|
|
"latex",
|
|
"bib",
|
|
"html",
|
|
"slide",
|
|
"report",
|
|
"manuscript",
|
|
"migration",
|
|
"checklist",
|
|
],
|
|
),
|
|
),
|
|
"actionability": min(
|
|
5,
|
|
_count_words(
|
|
lowered,
|
|
["command", "verify", "check", "next step", "schedule", "itinerary", "risk"],
|
|
),
|
|
),
|
|
"constraint_handling": min(
|
|
5,
|
|
_count_words(
|
|
lowered,
|
|
["assumption", "constraint", "budget", "audience", "limitation", "preference"],
|
|
),
|
|
),
|
|
"traceability": min(
|
|
5,
|
|
_count_words(
|
|
lowered,
|
|
["evidence", "source", "page", "reference", "trace", "reproduce", "gate"],
|
|
),
|
|
),
|
|
}
|
|
notes = [name for name, value in dimensions.items() if value <= 1]
|
|
return ResponseScore(total=sum(dimensions.values()), dimensions=dimensions, notes=notes)
|
|
|
|
|
|
def _count_matches(text: str, patterns: list[str]) -> int:
|
|
return sum(1 for pattern in patterns if re.search(pattern, text, flags=re.I | re.M))
|
|
|
|
|
|
def _count_words(text: str, words: list[str]) -> int:
|
|
return sum(1 for word in words if word in text)
|
|
|
|
|
|
def normalized_winner(value: str) -> str:
|
|
lowered = value.strip().lower()
|
|
if lowered in {"opensquilla", "openclaw", "tie"}:
|
|
return lowered
|
|
if lowered in {"a", "response_a", "candidate_a"}:
|
|
return "opensquilla"
|
|
if lowered in {"b", "response_b", "candidate_b"}:
|
|
return "openclaw"
|
|
return "tie"
|
|
|
|
|
|
def response_excerpt(text: str, *, max_chars: int = 12000) -> str:
|
|
stripped = text.strip()
|
|
if len(stripped) <= max_chars:
|
|
return stripped
|
|
head = max_chars // 2
|
|
tail = max_chars - head
|
|
return (
|
|
stripped[:head] + "\n\n[... middle truncated for judge prompt ...]\n\n" + stripped[-tail:]
|
|
)
|
|
|
|
|
|
def blind_product_names(text: str) -> str:
|
|
return (
|
|
text.replace("OpenSquilla", "the specialized meta-skill system")
|
|
.replace("opensquilla", "the specialized meta-skill system")
|
|
.replace("OpenClaw", "the baseline agent system")
|
|
.replace("openclaw", "the baseline agent system")
|
|
)
|
|
|
|
|
|
def build_judge_prompt(
|
|
case: ComparisonCase,
|
|
opensquilla: EndpointResult,
|
|
openclaw: EndpointResult,
|
|
) -> str:
|
|
rubric = "\n".join(f"- {item.name}: {item.description}" for item in rubric_for_case(case))
|
|
failure_modes = "\n".join(f"- {item}" for item in case.failure_modes) or "- None listed"
|
|
return textwrap.dedent(
|
|
f"""
|
|
You are judging a product-quality benchmark between two anonymous AI agent systems.
|
|
Judge only the two answers. Do not reward brand assumptions. The user asked:
|
|
|
|
{case.prompt}
|
|
|
|
Evaluation constraints:
|
|
- Judge the final user-visible answer, not hidden orchestration traces.
|
|
- Do not reward claims that tools, files, commands, or external checks were
|
|
completed unless the answer provides visible evidence.
|
|
- If the user asked for an artifact, reward artifact-ready content that can
|
|
be pasted or used directly.
|
|
|
|
Meta-skill under test: {case.skill_name}
|
|
Scenario: {case.scenario}
|
|
Expected advantage being tested: {blind_product_names(case.expected_advantage)}
|
|
|
|
Rubric dimensions:
|
|
{rubric}
|
|
|
|
Known failure modes to penalize:
|
|
{blind_product_names(failure_modes)}
|
|
|
|
Endpoint health:
|
|
- Candidate A ok={opensquilla.ok}, error={opensquilla.error}
|
|
- Candidate B ok={openclaw.ok}, error={openclaw.error}
|
|
|
|
JSON label mapping:
|
|
- Use "opensquilla" for Candidate A.
|
|
- Use "openclaw" for Candidate B.
|
|
These are opaque output labels for the two anonymous candidates, not
|
|
product facts. Do not infer quality from the labels.
|
|
|
|
Candidate A answer:
|
|
```text
|
|
{response_excerpt(opensquilla.response_text)}
|
|
```
|
|
|
|
Candidate B answer:
|
|
```text
|
|
{response_excerpt(openclaw.response_text)}
|
|
```
|
|
|
|
Prioritize the quality of the final user-visible deliverable. Meta-skill
|
|
orchestration is valuable only when it produces a better final artifact.
|
|
|
|
Score each candidate from 0 to 100 using these weights:
|
|
- final_artifact_quality: 40 points. The final deliverable is complete,
|
|
coherent, polished, directly usable, appropriately structured for the
|
|
requested artifact, and avoids distracting boilerplate.
|
|
- task_completion: 20 points. It directly solves the user's concrete
|
|
request, including all requested sub-parts.
|
|
- evidence_traceability: 15 points. It maps important claims to pasted
|
|
facts, source URLs, file/page evidence, or observed tool output where
|
|
applicable.
|
|
- actionability: 10 points. It gives concrete next steps, decisions,
|
|
owners, checks, or schedules that the user can execute without another
|
|
planning pass.
|
|
- risk_boundary_safety: 10 points. It flags legal, finance, medical,
|
|
security, privacy, or unknown-evidence boundaries instead of
|
|
over-claiming.
|
|
- meta_skill_fit: 5 points. It shows specialized workflow behavior
|
|
expected for this meta-skill, beyond a generic high-end chat answer.
|
|
|
|
The top-level scores MUST equal the sum of the six weighted subscores
|
|
for each candidate. Do not invent an independent overall score.
|
|
|
|
Use these cross-cutting checks while assigning the weighted score:
|
|
- endpoint_validity: only compare usable, non-empty answers from healthy endpoints.
|
|
Do not award a win because the other endpoint errored, timed out, or returned
|
|
an empty response; treat that row as inconclusive unless both usable answers exist.
|
|
- correctness_grounding: facts, constraints, citations, commands, and caveats are
|
|
plausible, internally consistent, and not invented.
|
|
- constraint_following: obeys inline-only/no-write/no-fake-execution constraints.
|
|
- fairness_control: do not reward brand assumptions, model reputation, verbosity,
|
|
or unrelated bootstrap/runtime/system commentary. Penalize unrelated bootstrap
|
|
notes or tool/runtime chatter that distracts from the user's deliverable.
|
|
- concision_efficiency: useful density without filler; do not reward verbosity alone.
|
|
|
|
Hard caps:
|
|
- timeout, empty response, or endpoint error: max 20 unless the other side also failed
|
|
- answer is mostly off-task: max 30
|
|
- fabricated source/file/tool execution: max 50
|
|
- violates no-write/no-execute constraint: max 70
|
|
|
|
Return strict JSON only with this schema:
|
|
{{
|
|
"winner": "opensquilla" | "openclaw" | "tie",
|
|
"scores": {{"opensquilla": 0-100, "openclaw": 0-100}},
|
|
"subscores": {{
|
|
"opensquilla": {{
|
|
"final_artifact_quality": 0-40,
|
|
"task_completion": 0-20,
|
|
"evidence_traceability": 0-15,
|
|
"actionability": 0-10,
|
|
"risk_boundary_safety": 0-10,
|
|
"meta_skill_fit": 0-5
|
|
}},
|
|
"openclaw": {{
|
|
"final_artifact_quality": 0-40,
|
|
"task_completion": 0-20,
|
|
"evidence_traceability": 0-15,
|
|
"actionability": 0-10,
|
|
"risk_boundary_safety": 0-10,
|
|
"meta_skill_fit": 0-5
|
|
}}
|
|
}},
|
|
"confidence": 0.0-1.0,
|
|
"rationale": "one short paragraph",
|
|
"risks": ["short risk or uncertainty"]
|
|
}}
|
|
"""
|
|
).strip()
|
|
|
|
|
|
def parse_judge_response(text: str, model: str) -> JudgeResult:
|
|
data = _load_json_object(text)
|
|
raw_scores = data.get("scores") if isinstance(data.get("scores"), dict) else {}
|
|
if not raw_scores and {"opensquilla", "openclaw"} <= set(data):
|
|
raw_scores = data
|
|
scores = {
|
|
"opensquilla": int(raw_scores.get("opensquilla", 0)),
|
|
"openclaw": int(raw_scores.get("openclaw", 0)),
|
|
}
|
|
winner = normalized_winner(str(data.get("winner", "")))
|
|
if winner not in {"opensquilla", "openclaw", "tie"}:
|
|
winner = "tie"
|
|
if winner == "tie" and scores["opensquilla"] != scores["openclaw"]:
|
|
winner = "opensquilla" if scores["opensquilla"] > scores["openclaw"] else "openclaw"
|
|
confidence_raw = data.get("confidence", 0)
|
|
try:
|
|
confidence = max(0.0, min(1.0, float(confidence_raw)))
|
|
except (TypeError, ValueError):
|
|
confidence = 0.0
|
|
risks_raw = data.get("risks") if isinstance(data.get("risks"), list) else []
|
|
risks = [str(item) for item in risks_raw[:5]]
|
|
return JudgeResult(
|
|
winner=winner,
|
|
scores=scores,
|
|
confidence=confidence,
|
|
rationale=str(data.get("rationale", "")).strip(),
|
|
risks=risks,
|
|
raw=data,
|
|
model=model,
|
|
)
|
|
|
|
|
|
def _load_json_object(text: str) -> dict[str, Any]:
|
|
candidates = [text.strip()]
|
|
candidates.extend(
|
|
match.group(1).strip()
|
|
for match in re.finditer(r"```(?:json)?\s*(.*?)```", text, flags=re.S | re.I)
|
|
)
|
|
match = re.search(r"\{.*\}", text, flags=re.S)
|
|
if match:
|
|
candidates.append(match.group(0))
|
|
decoder = json.JSONDecoder()
|
|
errors: list[str] = []
|
|
for candidate in candidates:
|
|
if not candidate:
|
|
continue
|
|
try:
|
|
data = json.loads(candidate)
|
|
except json.JSONDecodeError as exc:
|
|
errors.append(str(exc))
|
|
else:
|
|
if isinstance(data, dict):
|
|
return data
|
|
for index, char in enumerate(candidate):
|
|
if char != "{":
|
|
continue
|
|
try:
|
|
data, _ = decoder.raw_decode(candidate[index:])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(data, dict):
|
|
return data
|
|
fallback = _load_json_object_from_fields(text)
|
|
if fallback:
|
|
return fallback
|
|
excerpt = text.strip().replace("\n", " ")[:500]
|
|
raise ValueError(
|
|
f"judge response was not parseable JSON: {'; '.join(errors[:3])}; excerpt={excerpt!r}"
|
|
)
|
|
|
|
|
|
def _load_json_object_from_fields(text: str) -> dict[str, Any] | None:
|
|
winner_match = re.search(r'"winner"\s*:\s*"([^"]+)"', text, flags=re.I)
|
|
sq_match = re.search(r'"opensquilla"\s*:\s*([0-9]{1,3})', text, flags=re.I)
|
|
claw_match = re.search(r'"openclaw"\s*:\s*([0-9]{1,3})', text, flags=re.I)
|
|
if not (winner_match and sq_match and claw_match):
|
|
return None
|
|
confidence_match = re.search(r'"confidence"\s*:\s*([0-9.]+)', text, flags=re.I)
|
|
return {
|
|
"winner": winner_match.group(1),
|
|
"scores": {
|
|
"opensquilla": int(sq_match.group(1)),
|
|
"openclaw": int(claw_match.group(1)),
|
|
},
|
|
"confidence": float(confidence_match.group(1)) if confidence_match else 0.0,
|
|
"rationale": "",
|
|
"risks": ["Judge response was recovered from malformed JSON fields."],
|
|
}
|
|
|
|
|
|
def _extract_payload_texts(payload: dict[str, Any], *, include_delta: bool) -> list[str]:
|
|
texts: list[str] = []
|
|
message = payload.get("message")
|
|
if isinstance(message, dict) and message.get("role") == "assistant":
|
|
text = _content_to_text(message.get("content"))
|
|
if text:
|
|
texts.append(text)
|
|
data = payload.get("data")
|
|
if isinstance(data, dict) and isinstance(data.get("text"), str):
|
|
texts.append(data["text"])
|
|
keys = ("text", "content", "final", "response")
|
|
if include_delta:
|
|
keys = (*keys, "delta")
|
|
for key in keys:
|
|
value = payload.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
texts.append(value)
|
|
return [text.strip() for text in texts if text and text.strip()]
|
|
|
|
|
|
def _is_tool_or_meta_step_event(event: dict[str, Any], payload: dict[str, Any]) -> bool:
|
|
event_name = event.get("event")
|
|
if isinstance(event_name, str):
|
|
lowered = event_name.lower()
|
|
if "tool" in lowered or "meta.step" in lowered or "meta-step" in lowered:
|
|
return True
|
|
for key in ("tool_name", "tool_use_id", "tool_call_id", "toolResult", "tool_result"):
|
|
if payload.get(key):
|
|
return True
|
|
data = payload.get("data")
|
|
if isinstance(data, dict):
|
|
for key in ("tool_name", "tool_use_id", "tool_call_id", "toolResult", "tool_result"):
|
|
if data.get(key):
|
|
return True
|
|
role = data.get("role")
|
|
if role in {"tool", "function"}:
|
|
return True
|
|
message = payload.get("message")
|
|
if isinstance(message, dict) and message.get("role") in {"tool", "function"}:
|
|
return True
|
|
return False
|
|
|
|
|
|
def extract_text_from_events(events: list[dict[str, Any]]) -> str:
|
|
terminal_candidates: list[str] = []
|
|
assistant_candidates: list[str] = []
|
|
fallback_candidates: list[str] = []
|
|
delta_candidates: list[str] = []
|
|
|
|
for event in events:
|
|
payload = event.get("payload") if isinstance(event.get("payload"), dict) else event
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
event_name = event.get("event")
|
|
is_terminal = event_name == "session.event.done"
|
|
texts = _extract_payload_texts(payload, include_delta=False)
|
|
delta_texts = _extract_payload_texts(payload, include_delta=True)
|
|
if is_terminal:
|
|
terminal_candidates.extend(texts)
|
|
continue
|
|
is_toolish = _is_tool_or_meta_step_event(event, payload)
|
|
message = payload.get("message")
|
|
if isinstance(message, dict) and message.get("role") == "assistant" and not is_toolish:
|
|
assistant_candidates.extend(texts)
|
|
continue
|
|
if not is_toolish:
|
|
fallback_candidates.extend(texts)
|
|
delta_candidates.extend(text for text in delta_texts if text not in texts)
|
|
|
|
for candidates in (terminal_candidates, assistant_candidates, fallback_candidates):
|
|
if candidates:
|
|
return candidates[-1].strip()
|
|
if delta_candidates:
|
|
return max(delta_candidates, key=len).strip()
|
|
return ""
|
|
|
|
|
|
def extract_error_from_events(events: list[dict[str, Any]]) -> str | None:
|
|
for event in reversed(events):
|
|
payload = event.get("payload") if isinstance(event.get("payload"), dict) else event
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
event_name = event.get("event")
|
|
is_error_event = isinstance(event_name, str) and event_name.endswith(".error")
|
|
message = payload.get("message")
|
|
if isinstance(message, dict) and isinstance(message.get("errorMessage"), str):
|
|
return message["errorMessage"]
|
|
data = payload.get("data")
|
|
if isinstance(data, dict) and isinstance(data.get("error"), str):
|
|
return data["error"]
|
|
if isinstance(data, dict) and data.get("phase") == "error":
|
|
value = data.get("error")
|
|
if isinstance(value, str) and value.strip():
|
|
return value
|
|
if payload.get("state") == "error" or is_error_event:
|
|
for key in ("errorMessage", "error"):
|
|
value = payload.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
return value
|
|
return None
|
|
|
|
|
|
def _content_to_text(content: Any) -> str:
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for item in content:
|
|
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
parts.append(item["text"])
|
|
elif isinstance(item, str):
|
|
parts.append(item)
|
|
return "".join(parts)
|
|
return ""
|
|
|
|
|
|
async def _send_application_pings(ws: Any, interval_s: float = 45.0) -> None:
|
|
"""Keep gateway app-level receive loops alive during long benchmark turns."""
|
|
while True:
|
|
await asyncio.sleep(interval_s)
|
|
await ws.send('{"type":"ping"}')
|
|
|
|
|
|
def _slug_part(value: str, max_len: int = 64) -> str:
|
|
slug = re.sub(r"[^a-z0-9_-]+", "-", str(value).strip().lower()).strip("-")
|
|
return (slug or "default")[:max_len]
|
|
|
|
|
|
class OpenSquillaRunner:
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
token: str | None,
|
|
elevated: str | None = None,
|
|
agent_id: str = "main",
|
|
isolated_agent_per_case: bool = False,
|
|
run_id: str | None = None,
|
|
) -> None:
|
|
self.url = url
|
|
self.token = token
|
|
self.elevated = elevated
|
|
self.agent_id = agent_id
|
|
self.isolated_agent_per_case = isolated_agent_per_case
|
|
self.run_id = _slug_part(run_id or uuid.uuid4().hex[:8])
|
|
|
|
def _agent_id_for_case(self, case: ComparisonCase) -> str:
|
|
if not self.isolated_agent_per_case:
|
|
return self.agent_id
|
|
case_part = _slug_part(case.case_id.replace("_", "-"), max_len=32)
|
|
prefix = _slug_part(self.agent_id if self.agent_id != "main" else "meta-compare")
|
|
return _slug_part(f"{prefix}-{self.run_id}-{case_part}", max_len=64)
|
|
|
|
async def run(self, case: ComparisonCase, timeout_s: float) -> EndpointResult:
|
|
start = time.monotonic()
|
|
try:
|
|
result = await asyncio.wait_for(self._run(case), timeout=timeout_s)
|
|
result.elapsed_s = round(time.monotonic() - start, 2)
|
|
return result
|
|
except SystemExit as exc:
|
|
return _error_result("opensquilla", case.case_id, start, exc)
|
|
except Exception as exc:
|
|
return _error_result("opensquilla", case.case_id, start, exc)
|
|
|
|
async def _run(self, case: ComparisonCase) -> EndpointResult:
|
|
events: list[dict[str, Any]] = []
|
|
control_events: list[dict[str, Any]] = []
|
|
import websockets
|
|
|
|
headers = {"Authorization": f"Bearer {self.token}"} if self.token else None
|
|
async with websockets.connect(
|
|
self.url,
|
|
ping_interval=20,
|
|
ping_timeout=20,
|
|
additional_headers=headers,
|
|
) as ws:
|
|
first = json.loads(await ws.recv())
|
|
if first.get("event") != "connect.challenge":
|
|
raise RuntimeError(f"unexpected OpenSquilla handshake: {first}")
|
|
auth_params = {"auth": {"token": self.token}} if self.token else {}
|
|
await self._call(
|
|
ws,
|
|
"connect",
|
|
{
|
|
"minProtocol": 3,
|
|
"maxProtocol": 3,
|
|
"role": "operator",
|
|
"scopes": ["operator.admin"],
|
|
"client": {"name": "meta-skill-comparison"},
|
|
**auth_params,
|
|
},
|
|
control_events,
|
|
)
|
|
agent_id = self._agent_id_for_case(case)
|
|
if agent_id != "main":
|
|
try:
|
|
await self._call(
|
|
ws,
|
|
"agents.create",
|
|
{
|
|
"id": agent_id,
|
|
"name": f"Meta Compare {case.case_id}",
|
|
"description": "Isolated agent for one meta-skill comparison case.",
|
|
},
|
|
control_events,
|
|
)
|
|
except RuntimeError as exc:
|
|
if "agent.exists" not in str(exc):
|
|
raise
|
|
created = await self._call(
|
|
ws,
|
|
"sessions.create",
|
|
{
|
|
"agentId": agent_id,
|
|
"kind": "cli",
|
|
"displayName": f"meta compare {case.case_id}",
|
|
},
|
|
control_events,
|
|
)
|
|
session_key = str(created["key"])
|
|
await self._call(
|
|
ws,
|
|
"sessions.messages.subscribe",
|
|
{"key": session_key},
|
|
control_events,
|
|
)
|
|
source: dict[str, Any] = {
|
|
"caller_kind": "cli",
|
|
"channel_kind": "cli",
|
|
"channel_id": "cli:meta-skill-comparison",
|
|
"source_kind": "cli",
|
|
"source_name": "meta-skill-comparison",
|
|
}
|
|
if self.elevated in ("on", "bypass", "full"):
|
|
source["elevated"] = self.elevated
|
|
await self._call(
|
|
ws,
|
|
"sessions.send",
|
|
{
|
|
"key": session_key,
|
|
"message": benchmark_prompt(case),
|
|
"attachments": [],
|
|
"_source": source,
|
|
},
|
|
events,
|
|
session_key=session_key,
|
|
)
|
|
await self._read_stream(ws, events, session_key)
|
|
|
|
session_events = _events_for_session(events, session_key)
|
|
text = extract_text_from_events(session_events)
|
|
meta_final_text = _latest_opensquilla_meta_final_text(session_key)
|
|
if meta_final_text:
|
|
text = meta_final_text
|
|
transcript_text = await _wait_for_opensquilla_transcript_text(
|
|
session_key,
|
|
minimum_len=len(text),
|
|
)
|
|
if not meta_final_text and len(transcript_text) > len(text):
|
|
text = transcript_text
|
|
stream_error = extract_error_from_events(session_events)
|
|
score = score_response(text, case)
|
|
provider, model = _provider_model_from_events(session_events)
|
|
return EndpointResult(
|
|
endpoint="opensquilla",
|
|
case_id=case.case_id,
|
|
ok=bool(text) and stream_error is None,
|
|
elapsed_s=0.0,
|
|
response_text=text,
|
|
score=asdict(score),
|
|
error=stream_error,
|
|
session_key=session_key,
|
|
provider=provider,
|
|
model=model,
|
|
event_count=len(session_events),
|
|
)
|
|
|
|
async def _call(
|
|
self,
|
|
ws: Any,
|
|
method: str,
|
|
params: dict[str, Any],
|
|
events: list[dict[str, Any]],
|
|
session_key: str | None = None,
|
|
) -> dict[str, Any]:
|
|
req_id = str(uuid.uuid4())
|
|
await ws.send(json.dumps({"type": "req", "id": req_id, "method": method, "params": params}))
|
|
while True:
|
|
frame = json.loads(await ws.recv())
|
|
if frame.get("type") == "event":
|
|
if session_key is None or _event_session_key(frame) == session_key:
|
|
events.append(frame)
|
|
continue
|
|
if method == "connect" and frame.get("protocol") is not None:
|
|
return frame
|
|
if frame.get("type") == "res" and frame.get("id") == req_id:
|
|
if not frame.get("ok"):
|
|
raise RuntimeError(f"{method} failed: {frame.get('error')}")
|
|
payload = frame.get("payload")
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
async def _read_stream(
|
|
self,
|
|
ws: Any,
|
|
events: list[dict[str, Any]],
|
|
session_key: str,
|
|
) -> None:
|
|
keepalive = asyncio.create_task(_send_application_pings(ws))
|
|
try:
|
|
while True:
|
|
frame = json.loads(await ws.recv())
|
|
if frame.get("type") != "event":
|
|
continue
|
|
if _event_session_key(frame) != session_key:
|
|
continue
|
|
events.append(frame)
|
|
if frame.get("event") in ("session.event.done", "session.event.error"):
|
|
return
|
|
finally:
|
|
keepalive.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await keepalive
|
|
|
|
|
|
class OpenClawRunner:
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
token: str,
|
|
idle_timeout_s: float = 90.0,
|
|
state_dir: Path | None = None,
|
|
) -> None:
|
|
self.url = url
|
|
self.token = token
|
|
self.idle_timeout_s = idle_timeout_s
|
|
self.state_dir = state_dir
|
|
|
|
async def run(self, case: ComparisonCase, timeout_s: float) -> EndpointResult:
|
|
start = time.monotonic()
|
|
try:
|
|
result = await asyncio.wait_for(self._run(case), timeout=timeout_s)
|
|
result.elapsed_s = round(time.monotonic() - start, 2)
|
|
return result
|
|
except Exception as exc:
|
|
return _error_result("openclaw", case.case_id, start, exc)
|
|
|
|
async def _run(self, case: ComparisonCase) -> EndpointResult:
|
|
import websockets
|
|
|
|
control_events: list[dict[str, Any]] = []
|
|
events: list[dict[str, Any]] = []
|
|
started_at = time.time()
|
|
prompt = benchmark_prompt(case)
|
|
headers = {"Authorization": f"Bearer {self.token}"} if self.token else None
|
|
async with websockets.connect(
|
|
self.url,
|
|
ping_interval=None,
|
|
additional_headers=headers,
|
|
) as ws:
|
|
first = json.loads(await ws.recv())
|
|
if first.get("event") != "connect.challenge":
|
|
raise RuntimeError(f"unexpected OpenClaw handshake: {first}")
|
|
await self._call(
|
|
ws,
|
|
"connect",
|
|
{
|
|
"minProtocol": 1,
|
|
"maxProtocol": 3,
|
|
"role": "operator",
|
|
"scopes": ["operator.admin"],
|
|
"auth": {"token": self.token},
|
|
"client": {
|
|
"id": "openclaw-tui",
|
|
"mode": "cli",
|
|
"version": "0",
|
|
"platform": "linux",
|
|
},
|
|
},
|
|
control_events,
|
|
)
|
|
created = await self._call(
|
|
ws,
|
|
"sessions.create",
|
|
{"agentId": "main"},
|
|
control_events,
|
|
)
|
|
session_key = str(created["key"])
|
|
entry = created.get("entry") if isinstance(created.get("entry"), dict) else {}
|
|
session_file = entry.get("sessionFile")
|
|
await self._call(
|
|
ws,
|
|
"sessions.messages.subscribe",
|
|
{"key": session_key},
|
|
control_events,
|
|
)
|
|
warmup_events: list[dict[str, Any]] = []
|
|
await self._call(
|
|
ws,
|
|
"sessions.send",
|
|
{"key": session_key, "message": OPENCLAW_BASELINE_WARMUP},
|
|
warmup_events,
|
|
session_key=session_key,
|
|
)
|
|
await self._read_openclaw_stream(ws, warmup_events, session_key)
|
|
await self._call(
|
|
ws,
|
|
"sessions.send",
|
|
{"key": session_key, "message": prompt},
|
|
events,
|
|
session_key=session_key,
|
|
)
|
|
await self._read_openclaw_stream(ws, events, session_key)
|
|
session_paths: list[Path] = []
|
|
session_path = _resolve_openclaw_session_path(session_file, self.state_dir)
|
|
if session_path is not None and _is_openclaw_session_jsonl(session_path):
|
|
session_paths.append(session_path)
|
|
if self.state_dir is not None:
|
|
discovered = _discover_openclaw_session_file(
|
|
self.state_dir,
|
|
session_key=session_key,
|
|
prompt=prompt,
|
|
started_at=started_at,
|
|
)
|
|
if discovered is not None and discovered not in session_paths:
|
|
session_paths.append(discovered)
|
|
file_events = await _wait_for_openclaw_session_file_events(
|
|
session_paths,
|
|
session_key=session_key,
|
|
after_prompt=prompt,
|
|
timeout_s=self.idle_timeout_s,
|
|
)
|
|
events.extend(file_events)
|
|
session_events = _events_for_session(events, session_key)
|
|
text = extract_text_from_events(session_events)
|
|
stream_error = extract_error_from_events(session_events)
|
|
score = score_response(text, case)
|
|
provider, model = _provider_model_from_events(session_events)
|
|
return EndpointResult(
|
|
endpoint="openclaw",
|
|
case_id=case.case_id,
|
|
ok=bool(text) and stream_error is None,
|
|
elapsed_s=0.0,
|
|
response_text=text,
|
|
score=asdict(score),
|
|
error=stream_error,
|
|
session_key=session_key,
|
|
provider=provider,
|
|
model=model,
|
|
event_count=len(session_events),
|
|
)
|
|
|
|
async def _call(
|
|
self,
|
|
ws: Any,
|
|
method: str,
|
|
params: dict[str, Any],
|
|
events: list[dict[str, Any]],
|
|
session_key: str | None = None,
|
|
) -> dict[str, Any]:
|
|
req_id = str(uuid.uuid4())
|
|
await ws.send(json.dumps({"type": "req", "id": req_id, "method": method, "params": params}))
|
|
while True:
|
|
frame = json.loads(await ws.recv())
|
|
if frame.get("type") == "event":
|
|
if session_key is None or _event_session_key(frame) == session_key:
|
|
events.append(frame)
|
|
continue
|
|
if frame.get("type") == "res" and frame.get("id") == req_id:
|
|
if not frame.get("ok"):
|
|
raise RuntimeError(f"{method} failed: {frame.get('error')}")
|
|
payload = frame.get("payload")
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
async def _read_openclaw_stream(
|
|
self,
|
|
ws: Any,
|
|
events: list[dict[str, Any]],
|
|
session_key: str,
|
|
) -> None:
|
|
keepalive = asyncio.create_task(_send_application_pings(ws))
|
|
deadline = time.monotonic() + self.idle_timeout_s
|
|
try:
|
|
while True:
|
|
timeout = max(0.1, deadline - time.monotonic())
|
|
try:
|
|
frame = json.loads(await asyncio.wait_for(ws.recv(), timeout=timeout))
|
|
except TimeoutError:
|
|
return
|
|
if frame.get("type") != "event":
|
|
continue
|
|
if _event_session_key(frame) != session_key:
|
|
continue
|
|
events.append(frame)
|
|
if frame.get("event") == "chat":
|
|
payload = frame.get("payload") if isinstance(frame.get("payload"), dict) else {}
|
|
if payload.get("state") == "final":
|
|
return
|
|
if payload.get("state") == "error":
|
|
return
|
|
if frame.get("event") == "agent":
|
|
payload = frame.get("payload") if isinstance(frame.get("payload"), dict) else {}
|
|
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
|
if data.get("phase") == "error":
|
|
return
|
|
if frame.get("event") == "session.event.error":
|
|
return
|
|
finally:
|
|
keepalive.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await keepalive
|
|
|
|
|
|
class LLMJudge:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
model: str,
|
|
api_key: str | None,
|
|
base_url: str = "https://openrouter.ai/api/v1",
|
|
timeout_s: float = 120.0,
|
|
) -> None:
|
|
self.model = model
|
|
self.api_key = api_key
|
|
self.base_url = base_url.rstrip("/")
|
|
self.timeout_s = timeout_s
|
|
|
|
async def judge(
|
|
self,
|
|
case: ComparisonCase,
|
|
opensquilla: EndpointResult,
|
|
openclaw: EndpointResult,
|
|
) -> JudgeResult:
|
|
if not self.api_key:
|
|
raise RuntimeError("LLM judge requires OPENROUTER_API_KEY or --judge-api-key")
|
|
import httpx
|
|
|
|
prompt = build_judge_prompt(case, opensquilla, openclaw)
|
|
payload = {
|
|
"model": self.model,
|
|
"messages": [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"You are a strict benchmark judge. Return only valid JSON. "
|
|
"Do not mention hidden chain-of-thought."
|
|
),
|
|
},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
"temperature": 0,
|
|
"max_tokens": 1200,
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
headers = {
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
"Content-Type": "application/json",
|
|
"HTTP-Referer": "http://localhost/opensquilla-meta-skill-comparison",
|
|
"X-Title": "OpenSquilla Meta Skill Comparison",
|
|
}
|
|
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
|
|
response = await client.post(
|
|
f"{self.base_url}/chat/completions",
|
|
headers=headers,
|
|
json=payload,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
choices = data.get("choices") if isinstance(data, dict) else None
|
|
if not isinstance(choices, list) or not choices:
|
|
raise RuntimeError("judge response has no choices")
|
|
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
|
content = message.get("content") if isinstance(message, dict) else None
|
|
if not isinstance(content, str) or not content.strip():
|
|
raise RuntimeError("judge response has empty content")
|
|
return parse_judge_response(content, self.model)
|
|
|
|
|
|
def benchmark_prompt(case: ComparisonCase) -> str:
|
|
return case.prompt
|
|
|
|
|
|
def _event_session_key(event: dict[str, Any]) -> str | None:
|
|
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
|
session_key = payload.get("sessionKey")
|
|
if isinstance(session_key, str):
|
|
return session_key
|
|
session = payload.get("session")
|
|
if isinstance(session, dict) and isinstance(session.get("key"), str):
|
|
return session["key"]
|
|
return None
|
|
|
|
|
|
def _provider_model_from_events(events: list[dict[str, Any]]) -> tuple[str | None, str | None]:
|
|
for event in reversed(events):
|
|
payload = event.get("payload") if isinstance(event.get("payload"), dict) else event
|
|
message = payload.get("message") if isinstance(payload, dict) else None
|
|
if isinstance(message, dict):
|
|
provider = message.get("provider") or message.get("modelProvider")
|
|
model = message.get("model")
|
|
if provider or model:
|
|
return (
|
|
str(provider) if provider else None,
|
|
str(model) if model else None,
|
|
)
|
|
provider = payload.get("provider") if isinstance(payload, dict) else None
|
|
model = payload.get("model") if isinstance(payload, dict) else None
|
|
if provider or model:
|
|
return (str(provider) if provider else None, str(model) if model else None)
|
|
return None, None
|
|
|
|
|
|
def _events_for_session(events: list[dict[str, Any]], session_key: str) -> list[dict[str, Any]]:
|
|
filtered = [
|
|
event
|
|
for event in events
|
|
if (key := _event_session_key(event)) is None or key == session_key
|
|
]
|
|
return filtered or events
|
|
|
|
|
|
def _latest_opensquilla_transcript_text(session_key: str) -> str:
|
|
"""Return the persisted final assistant text for a local gateway session.
|
|
|
|
Some gateway streams emit only the assistant preface before a long-running
|
|
``meta_invoke`` while the complete final text is persisted to the transcript
|
|
after the DAG finishes. Prefer the persisted transcript when available so
|
|
the benchmark judges the actual user-visible final assistant message.
|
|
"""
|
|
|
|
if not session_key:
|
|
return ""
|
|
state_db = Path(os.environ.get("OPENSQUILLA_STATE_DB", "/root/.opensquilla/state/sessions.db"))
|
|
if not state_db.exists():
|
|
return ""
|
|
try:
|
|
import sqlite3
|
|
|
|
with sqlite3.connect(state_db) as conn:
|
|
rows = conn.execute(
|
|
"SELECT content FROM transcript_entries "
|
|
"WHERE session_key=? AND role='assistant' "
|
|
"ORDER BY id ASC",
|
|
(session_key,),
|
|
).fetchall()
|
|
except Exception:
|
|
return ""
|
|
texts = [str(row[0]).strip() for row in rows if row and row[0] and str(row[0]).strip()]
|
|
return texts[-1] if texts else ""
|
|
|
|
|
|
def _latest_opensquilla_meta_final_text(session_key: str) -> str:
|
|
if not session_key:
|
|
return ""
|
|
state_db = Path(os.environ.get("OPENSQUILLA_STATE_DB", "/root/.opensquilla/state/sessions.db"))
|
|
if not state_db.exists():
|
|
return ""
|
|
try:
|
|
import sqlite3
|
|
|
|
with sqlite3.connect(state_db) as conn:
|
|
rows = conn.execute(
|
|
"SELECT final_text FROM meta_skill_runs "
|
|
"WHERE session_key=? AND status='ok' "
|
|
"ORDER BY started_at_ms ASC",
|
|
(session_key,),
|
|
).fetchall()
|
|
except Exception:
|
|
return ""
|
|
texts = [str(row[0]).strip() for row in rows if row and row[0] and str(row[0]).strip()]
|
|
return texts[-1] if texts else ""
|
|
|
|
|
|
async def _wait_for_opensquilla_transcript_text(
|
|
session_key: str,
|
|
*,
|
|
minimum_len: int,
|
|
timeout_s: float = 5.0,
|
|
interval_s: float = 0.25,
|
|
) -> str:
|
|
"""Poll briefly for the final assistant transcript after stream completion."""
|
|
|
|
deadline = time.monotonic() + timeout_s
|
|
best = ""
|
|
while True:
|
|
text = _latest_opensquilla_transcript_text(session_key)
|
|
if len(text) > len(best):
|
|
best = text
|
|
if len(best) > minimum_len or time.monotonic() >= deadline:
|
|
return best
|
|
await asyncio.sleep(interval_s)
|
|
|
|
|
|
def _openclaw_session_file_events(
|
|
path: Path,
|
|
session_key: str,
|
|
*,
|
|
after_prompt: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
if not path.exists():
|
|
return []
|
|
events: list[dict[str, Any]] = []
|
|
prompt_seen = after_prompt is None
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
item = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
message = item.get("message") if isinstance(item, dict) else None
|
|
if item.get("type") == "message" and isinstance(message, dict):
|
|
if not prompt_seen:
|
|
if (
|
|
message.get("role") == "user"
|
|
and after_prompt
|
|
and after_prompt in _content_to_text(message.get("content"))
|
|
):
|
|
prompt_seen = True
|
|
continue
|
|
events.append(
|
|
{
|
|
"type": "event",
|
|
"event": "session.message",
|
|
"payload": {"sessionKey": session_key, "message": message},
|
|
}
|
|
)
|
|
return events
|
|
|
|
|
|
async def _wait_for_openclaw_session_file_events(
|
|
paths: list[Path],
|
|
*,
|
|
session_key: str,
|
|
after_prompt: str,
|
|
timeout_s: float = 90.0,
|
|
interval_s: float = 0.5,
|
|
stable_s: float = 5.0,
|
|
) -> list[dict[str, Any]]:
|
|
"""Poll OpenClaw's JSONL file because the WS stream may end before persistence."""
|
|
|
|
deadline = time.monotonic() + timeout_s
|
|
best: list[dict[str, Any]] = []
|
|
best_text = ""
|
|
last_change = time.monotonic()
|
|
while True:
|
|
for path in paths:
|
|
events = _openclaw_session_file_events(
|
|
path,
|
|
session_key,
|
|
after_prompt=after_prompt,
|
|
)
|
|
text = extract_text_from_events(events)
|
|
if text != best_text:
|
|
best = events
|
|
best_text = text
|
|
last_change = time.monotonic()
|
|
now = time.monotonic()
|
|
if best_text and now - last_change >= stable_s:
|
|
return best
|
|
if now >= deadline:
|
|
return best
|
|
await asyncio.sleep(interval_s)
|
|
|
|
|
|
def _resolve_openclaw_session_path(session_file: Any, state_dir: Path | None) -> Path | None:
|
|
if not isinstance(session_file, str) or not session_file.strip():
|
|
return None
|
|
if state_dir is not None and session_file.startswith("$OPENCLAW_STATE_DIR/"):
|
|
return state_dir / session_file.removeprefix("$OPENCLAW_STATE_DIR/")
|
|
path = Path(session_file)
|
|
if path.is_absolute() or state_dir is None:
|
|
return path
|
|
candidate = state_dir / path
|
|
return candidate if candidate.exists() else path
|
|
|
|
|
|
def _is_openclaw_session_jsonl(path: Path) -> bool:
|
|
return path.exists() and path.suffix == ".jsonl" and ".trajectory" not in path.name
|
|
|
|
|
|
def _discover_openclaw_session_file(
|
|
state_dir: Path,
|
|
*,
|
|
session_key: str,
|
|
prompt: str,
|
|
started_at: float,
|
|
) -> Path | None:
|
|
sessions_dir = state_dir / "agents" / "main" / "sessions"
|
|
if not sessions_dir.exists():
|
|
return None
|
|
candidates: list[tuple[float, Path]] = []
|
|
for path in sessions_dir.glob("*.jsonl"):
|
|
if ".trajectory" in path.name:
|
|
continue
|
|
try:
|
|
stat = path.stat()
|
|
if stat.st_mtime < started_at - 5:
|
|
continue
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
continue
|
|
trajectory_path = path.with_name(f"{path.stem}.trajectory.jsonl")
|
|
trajectory_text = ""
|
|
if trajectory_path.exists():
|
|
try:
|
|
trajectory_text = trajectory_path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
trajectory_text = ""
|
|
if (
|
|
prompt in text
|
|
or _openclaw_session_file_contains_prompt(path, prompt)
|
|
or session_key in trajectory_text
|
|
):
|
|
candidates.append((stat.st_mtime, path))
|
|
if not candidates:
|
|
return None
|
|
return max(candidates, key=lambda item: item[0])[1]
|
|
|
|
|
|
def _openclaw_session_file_contains_prompt(path: Path, prompt: str) -> bool:
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
except OSError:
|
|
return False
|
|
for line in lines:
|
|
try:
|
|
item = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
message = item.get("message") if isinstance(item, dict) else None
|
|
if not isinstance(message, dict) or message.get("role") != "user":
|
|
continue
|
|
text = _content_to_text(message.get("content"))
|
|
if prompt in text:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _event_session_key(event: dict[str, Any]) -> str | None:
|
|
payload = event.get("payload") if isinstance(event.get("payload"), dict) else event
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
for key_name in ("sessionKey", "session_key", "key"):
|
|
value = payload.get(key_name)
|
|
if isinstance(value, str):
|
|
return value
|
|
session = payload.get("session")
|
|
if isinstance(session, dict):
|
|
value = session.get("key")
|
|
if isinstance(value, str):
|
|
return value
|
|
return None
|
|
|
|
|
|
def _error_result(endpoint: str, case_id: str, start: float, exc: Exception) -> EndpointResult:
|
|
return EndpointResult(
|
|
endpoint=endpoint,
|
|
case_id=case_id,
|
|
ok=False,
|
|
elapsed_s=round(time.monotonic() - start, 2),
|
|
response_text="",
|
|
score=asdict(score_response("")),
|
|
error=f"{type(exc).__name__}: {exc}",
|
|
)
|
|
|
|
|
|
def read_openclaw_token(config_path: Path) -> str:
|
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
token = data.get("gateway", {}).get("auth", {}).get("token")
|
|
if not isinstance(token, str) or not token:
|
|
raise RuntimeError(f"OpenClaw token missing in {config_path}")
|
|
return token
|
|
|
|
|
|
def read_opensquilla_token() -> str | None:
|
|
for env_name in ("OPENSQUILLA_GATEWAY_TOKEN", "OPENSQUILLA_TOKEN"):
|
|
value = os.environ.get(env_name)
|
|
if value:
|
|
return value
|
|
token_file = os.environ.get("OPENSQUILLA_GATEWAY_TOKEN_FILE")
|
|
if token_file:
|
|
path = Path(token_file)
|
|
match = re.search(r'^TOKEN\s*=\s*"([^"]+)"', path.read_text(encoding="utf-8"), re.M)
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
def read_judge_api_key() -> str | None:
|
|
for env_name in ("OPENSQUILLA_JUDGE_API_KEY", "OPENROUTER_API_KEY"):
|
|
value = os.environ.get(env_name)
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
async def run_live(args: argparse.Namespace) -> list[dict[str, Any]]:
|
|
selected = _select_cases(args.case, scenario=args.scenario, skill=args.skill)
|
|
if not args.openclaw_config:
|
|
raise SystemExit("Pass --openclaw-config or set OPENCLAW_CONFIG.")
|
|
openclaw_token = read_openclaw_token(Path(args.openclaw_config))
|
|
opensquilla = OpenSquillaRunner(
|
|
args.opensquilla_url,
|
|
args.opensquilla_token,
|
|
elevated=args.opensquilla_elevated,
|
|
)
|
|
openclaw = OpenClawRunner(
|
|
args.openclaw_url,
|
|
openclaw_token,
|
|
args.openclaw_idle_timeout,
|
|
state_dir=Path(args.openclaw_config).parent,
|
|
)
|
|
judge = None
|
|
if args.judge_llm:
|
|
if not args.judge_model:
|
|
raise SystemExit("Pass --judge-model or set OPENSQUILLA_JUDGE_MODEL.")
|
|
judge = LLMJudge(
|
|
model=args.judge_model,
|
|
api_key=args.judge_api_key,
|
|
base_url=args.judge_base_url,
|
|
timeout_s=args.judge_timeout,
|
|
)
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
for case in selected:
|
|
print(f"running {case.case_id} ...", flush=True)
|
|
sq_result, claw_result = await asyncio.gather(
|
|
opensquilla.run(case, args.timeout),
|
|
openclaw.run(case, args.timeout),
|
|
)
|
|
row = compare_results(case, sq_result, claw_result)
|
|
if judge is not None:
|
|
try:
|
|
judge_result = await judge_with_retries(judge, case, sq_result, claw_result)
|
|
row = apply_judge_result(row, judge_result, case)
|
|
except Exception as exc:
|
|
row["judge_error"] = f"{type(exc).__name__}: {exc}"
|
|
rows.append(row)
|
|
print(
|
|
f"{case.case_id}: opensquilla={sq_result.score['total']} "
|
|
f"openclaw={claw_result.score['total']} winner={row['winner']}",
|
|
flush=True,
|
|
)
|
|
write_reports(rows, stamp=stamp)
|
|
write_reports(rows, stamp=stamp)
|
|
return rows
|
|
|
|
|
|
async def judge_existing(args: argparse.Namespace) -> list[dict[str, Any]]:
|
|
if not args.judge_jsonl:
|
|
raise SystemExit("Pass --judge-jsonl.")
|
|
if not args.judge_model:
|
|
raise SystemExit("Pass --judge-model or set OPENSQUILLA_JUDGE_MODEL.")
|
|
judge = LLMJudge(
|
|
model=args.judge_model,
|
|
api_key=args.judge_api_key,
|
|
base_url=args.judge_base_url,
|
|
timeout_s=args.judge_timeout,
|
|
)
|
|
rows = [
|
|
json.loads(line)
|
|
for line in Path(args.judge_jsonl).read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
judged_rows: list[dict[str, Any]] = []
|
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
for row in rows:
|
|
case = case_from_row(row)
|
|
opensquilla = endpoint_from_row(row, "opensquilla")
|
|
openclaw = endpoint_from_row(row, "openclaw")
|
|
row.setdefault("baseline_winner", row.get("winner", "tie"))
|
|
row.setdefault("score_basis", "deterministic")
|
|
try:
|
|
judge_result = await judge_with_retries(judge, case, opensquilla, openclaw)
|
|
judged = apply_judge_result(row, judge_result, case)
|
|
except Exception as exc:
|
|
judged = dict(row)
|
|
judged["judge_error"] = f"{type(exc).__name__}: {exc}"
|
|
judged_rows.append(judged)
|
|
print(f"judged {case.case_id}: winner={judged.get('winner')}", flush=True)
|
|
write_reports(judged_rows, stamp=stamp)
|
|
write_reports(judged_rows, stamp=stamp)
|
|
return judged_rows
|
|
|
|
|
|
def compare_results(
|
|
case: ComparisonCase,
|
|
opensquilla: EndpointResult,
|
|
openclaw: EndpointResult,
|
|
) -> dict[str, Any]:
|
|
sq_total = int(opensquilla.score["total"])
|
|
claw_total = int(openclaw.score["total"])
|
|
if not opensquilla.ok and openclaw.ok:
|
|
baseline_winner = "openclaw"
|
|
elif opensquilla.ok and not openclaw.ok:
|
|
baseline_winner = "opensquilla"
|
|
elif sq_total > claw_total:
|
|
baseline_winner = "opensquilla"
|
|
elif claw_total > sq_total:
|
|
baseline_winner = "openclaw"
|
|
else:
|
|
baseline_winner = "tie"
|
|
return {
|
|
"case": case_to_dict(case),
|
|
"opensquilla": asdict(opensquilla),
|
|
"openclaw": asdict(openclaw),
|
|
"baseline_winner": baseline_winner,
|
|
"winner": baseline_winner,
|
|
"score_basis": "deterministic",
|
|
"opensquilla_better": baseline_winner == "opensquilla",
|
|
"recommended_optimization": None
|
|
if baseline_winner == "opensquilla"
|
|
else case.optimization_if_not_better,
|
|
}
|
|
|
|
|
|
def apply_judge_result(
|
|
row: dict[str, Any],
|
|
judge_result: JudgeResult,
|
|
case: ComparisonCase,
|
|
) -> dict[str, Any]:
|
|
judge_result = normalize_weighted_judge_result(judge_result)
|
|
winner = judge_result.winner
|
|
updated = dict(row)
|
|
updated["judge"] = asdict(judge_result)
|
|
updated["winner"] = winner
|
|
updated["score_basis"] = "llm_judge"
|
|
updated["opensquilla_better"] = winner == "opensquilla"
|
|
updated["recommended_optimization"] = (
|
|
None if winner == "opensquilla" else case.optimization_if_not_better
|
|
)
|
|
updated.pop("judge_error", None)
|
|
return updated
|
|
|
|
|
|
async def judge_with_retries(
|
|
judge: LLMJudge,
|
|
case: ComparisonCase,
|
|
opensquilla: EndpointResult,
|
|
openclaw: EndpointResult,
|
|
*,
|
|
attempts: int = 3,
|
|
) -> JudgeResult:
|
|
errors: list[str] = []
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
result = await judge.judge(case, opensquilla, openclaw)
|
|
except Exception as exc:
|
|
errors.append(f"attempt {attempt}: {type(exc).__name__}: {exc}")
|
|
continue
|
|
try:
|
|
return normalize_weighted_judge_result(result)
|
|
except ValueError as exc:
|
|
errors.append(f"attempt {attempt}: {exc}")
|
|
raise RuntimeError("; ".join(errors))
|
|
|
|
|
|
def normalize_weighted_judge_result(judge_result: JudgeResult) -> JudgeResult:
|
|
if not judge_result.rationale.strip():
|
|
raise ValueError("judge response missing rationale")
|
|
raw = judge_result.raw if isinstance(judge_result.raw, dict) else {}
|
|
totals = weighted_judge_totals(raw)
|
|
if totals is None:
|
|
raise ValueError("judge response missing complete weighted subscores")
|
|
winner = "tie"
|
|
if totals["opensquilla"] > totals["openclaw"]:
|
|
winner = "opensquilla"
|
|
elif totals["openclaw"] > totals["opensquilla"]:
|
|
winner = "openclaw"
|
|
normalized_raw = dict(raw)
|
|
normalized_raw["scores"] = totals
|
|
normalized_raw["winner"] = winner
|
|
normalized_raw["score_source"] = "weighted_subscores"
|
|
return JudgeResult(
|
|
winner=winner,
|
|
scores=totals,
|
|
confidence=judge_result.confidence,
|
|
rationale=judge_result.rationale,
|
|
risks=judge_result.risks,
|
|
raw=normalized_raw,
|
|
model=judge_result.model,
|
|
)
|
|
|
|
|
|
def weighted_judge_totals(raw: dict[str, Any]) -> dict[str, int] | None:
|
|
subscores = raw.get("subscores") if isinstance(raw.get("subscores"), dict) else {}
|
|
totals: dict[str, int] = {}
|
|
for label in ("opensquilla", "openclaw"):
|
|
candidate = subscores.get(label)
|
|
if not isinstance(candidate, dict):
|
|
return None
|
|
total = 0
|
|
for name, (low, high) in JUDGE_SUBSCORE_RANGES.items():
|
|
if name not in candidate:
|
|
return None
|
|
try:
|
|
value = int(candidate[name])
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if value < low or value > high:
|
|
return None
|
|
total += value
|
|
totals[label] = total
|
|
return totals
|
|
|
|
|
|
def case_from_row(row: dict[str, Any]) -> ComparisonCase:
|
|
case_data = row.get("case") if isinstance(row.get("case"), dict) else {}
|
|
case_id = str(case_data.get("case_id", ""))
|
|
for case in COMPARISON_CASES:
|
|
if case.case_id == case_id:
|
|
return case
|
|
rubric_data = case_data.get("rubric") if isinstance(case_data.get("rubric"), list) else []
|
|
rubric = tuple(
|
|
RubricCriterion(
|
|
name=str(item.get("name", "")),
|
|
description=str(item.get("description", "")),
|
|
patterns=tuple(str(pattern) for pattern in item.get("patterns", ())),
|
|
weight=int(item.get("weight", 1)),
|
|
)
|
|
for item in rubric_data
|
|
if isinstance(item, dict)
|
|
)
|
|
return ComparisonCase(
|
|
case_id=case_id,
|
|
skill_name=str(case_data.get("skill_name", "")),
|
|
prompt=str(case_data.get("prompt", "")),
|
|
expected_advantage=str(case_data.get("expected_advantage", "")),
|
|
optimization_if_not_better=str(case_data.get("optimization_if_not_better", "")),
|
|
scenario=str(case_data.get("scenario", "primary")),
|
|
rubric=rubric,
|
|
failure_modes=tuple(str(item) for item in case_data.get("failure_modes", ())),
|
|
)
|
|
|
|
|
|
def endpoint_from_row(row: dict[str, Any], endpoint: str) -> EndpointResult:
|
|
data = row.get(endpoint) if isinstance(row.get(endpoint), dict) else {}
|
|
return EndpointResult(
|
|
endpoint=endpoint,
|
|
case_id=str(data.get("case_id", row.get("case", {}).get("case_id", ""))),
|
|
ok=bool(data.get("ok")),
|
|
elapsed_s=float(data.get("elapsed_s", 0.0)),
|
|
response_text=str(data.get("response_text", "")),
|
|
score=data.get("score") if isinstance(data.get("score"), dict) else {"total": 0},
|
|
error=str(data["error"]) if data.get("error") is not None else None,
|
|
session_key=str(data["session_key"]) if data.get("session_key") is not None else None,
|
|
model=str(data["model"]) if data.get("model") is not None else None,
|
|
provider=str(data["provider"]) if data.get("provider") is not None else None,
|
|
event_count=int(data.get("event_count", 0)),
|
|
)
|
|
|
|
|
|
def case_to_dict(case: ComparisonCase) -> dict[str, Any]:
|
|
data = asdict(case)
|
|
if not data["rubric"]:
|
|
data["rubric"] = [asdict(item) for item in rubric_for_case(case)]
|
|
return data
|
|
|
|
|
|
def write_reports(rows: list[dict[str, Any]], stamp: str | None = None) -> None:
|
|
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
|
if stamp is None:
|
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
jsonl_path = REPORT_DIR / f"openclaw_vs_opensquilla_meta_skill_{stamp}.jsonl"
|
|
md_path = REPORT_DIR / f"openclaw_vs_opensquilla_meta_skill_{stamp}.md"
|
|
prompts_path = REPORT_DIR / f"openclaw_vs_opensquilla_meta_skill_prompts_{stamp}.md"
|
|
with jsonl_path.open("w", encoding="utf-8") as fh:
|
|
for row in rows:
|
|
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
md_path.write_text(render_markdown(rows, jsonl_path), encoding="utf-8")
|
|
prompts_path.write_text(render_prompts_markdown(rows, jsonl_path), encoding="utf-8")
|
|
print(f"wrote {jsonl_path}")
|
|
print(f"wrote {md_path}")
|
|
print(f"wrote {prompts_path}")
|
|
|
|
|
|
def render_markdown(rows: list[dict[str, Any]], jsonl_path: Path) -> str:
|
|
total = len(rows)
|
|
sq_wins = sum(1 for row in rows if row["winner"] == "opensquilla")
|
|
claw_wins = sum(1 for row in rows if row["winner"] == "openclaw")
|
|
ties = sum(1 for row in rows if row["winner"] == "tie")
|
|
judged = [row for row in rows if row.get("score_basis") == "llm_judge"]
|
|
failed = [
|
|
row["case"]["case_id"]
|
|
for row in rows
|
|
if not row["opensquilla"]["ok"] or not row["openclaw"]["ok"]
|
|
]
|
|
lines = [
|
|
"# OpenClaw vs OpenSquilla Meta-Skill Comparison",
|
|
"",
|
|
f"Raw JSONL: `{jsonl_path}`",
|
|
"",
|
|
"## Conclusion",
|
|
"",
|
|
(
|
|
f"OpenSquilla won {sq_wins}/{total} cases; OpenClaw won "
|
|
f"{claw_wins}/{total}; ties: {ties}."
|
|
),
|
|
]
|
|
if judged:
|
|
lines.append(f"Final winner uses LLM judge for {len(judged)}/{total} rows.")
|
|
else:
|
|
lines.append(
|
|
"Final winner uses deterministic rubric scoring; no LLM judge rows are present."
|
|
)
|
|
if failed:
|
|
lines.append(f"Cases with endpoint errors/timeouts: {', '.join(failed)}.")
|
|
else:
|
|
lines.append("No endpoint errors or timeouts were recorded.")
|
|
if claw_wins or ties or failed:
|
|
lines.append("Rows that do not show an OpenSquilla win include an optimization note.")
|
|
else:
|
|
lines.append("All completed cases favored OpenSquilla under this rubric.")
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Score Table",
|
|
"",
|
|
"| Case | OpenSquilla | OpenClaw | Baseline | Judge | Winner | Optimization |",
|
|
"| --- | ---: | ---: | --- | --- | --- | --- |",
|
|
]
|
|
)
|
|
for row in rows:
|
|
opt = row["recommended_optimization"] or ""
|
|
judge = row.get("judge") if isinstance(row.get("judge"), dict) else None
|
|
judge_cell = ""
|
|
if judge:
|
|
scores = judge.get("scores") if isinstance(judge.get("scores"), dict) else {}
|
|
judge_cell = (
|
|
f"{scores.get('opensquilla', '')}-{scores.get('openclaw', '')} "
|
|
f"{judge.get('winner', '')}"
|
|
).strip()
|
|
lines.append(
|
|
"| {case} | {sq} | {claw} | {baseline} | {judge} | {winner} | {opt} |".format(
|
|
case=row["case"]["case_id"],
|
|
sq=row["opensquilla"]["score"]["total"],
|
|
claw=row["openclaw"]["score"]["total"],
|
|
baseline=row.get("baseline_winner", row.get("winner", "")),
|
|
judge=judge_cell,
|
|
winner=row["winner"],
|
|
opt=opt.replace("|", "/"),
|
|
)
|
|
)
|
|
lines.extend(["", "## Notes", ""])
|
|
for row in rows:
|
|
lines.append(f"### {row['case']['case_id']}")
|
|
lines.append("")
|
|
lines.append("Prompt:")
|
|
lines.append("")
|
|
lines.append("```text")
|
|
lines.append(row["case"]["prompt"])
|
|
lines.append("```")
|
|
lines.append(f"- Expected advantage: {row['case']['expected_advantage']}")
|
|
scenario = row["case"].get("scenario")
|
|
if scenario:
|
|
lines.append(f"- Scenario: {scenario}")
|
|
rubric = row["case"].get("rubric") or []
|
|
if rubric:
|
|
lines.append(
|
|
"- Rubric: "
|
|
+ ", ".join(
|
|
f"{item['name']}({item.get('weight', 1)})"
|
|
for item in rubric
|
|
if isinstance(item, dict)
|
|
)
|
|
)
|
|
failure_modes = row["case"].get("failure_modes") or []
|
|
if failure_modes:
|
|
lines.append("- Failure modes: " + "; ".join(failure_modes))
|
|
if row["recommended_optimization"]:
|
|
lines.append(f"- Optimize: {row['recommended_optimization']}")
|
|
lines.append(f"- Score basis: {row.get('score_basis', 'deterministic')}")
|
|
if row.get("baseline_winner") and row.get("baseline_winner") != row.get("winner"):
|
|
lines.append(f"- Baseline winner: {row['baseline_winner']}")
|
|
judge = row.get("judge") if isinstance(row.get("judge"), dict) else None
|
|
if judge:
|
|
scores = judge.get("scores") if isinstance(judge.get("scores"), dict) else {}
|
|
risks = judge.get("risks") if isinstance(judge.get("risks"), list) else []
|
|
lines.append(
|
|
(
|
|
"- Judge: winner={winner}, scores={sq}-{claw}, "
|
|
"confidence={confidence}, model={model}"
|
|
).format(
|
|
winner=judge.get("winner"),
|
|
sq=scores.get("opensquilla"),
|
|
claw=scores.get("openclaw"),
|
|
confidence=judge.get("confidence"),
|
|
model=judge.get("model"),
|
|
)
|
|
)
|
|
if judge.get("rationale"):
|
|
lines.append(f"- Judge rationale: {judge['rationale']}")
|
|
if risks:
|
|
lines.append("- Judge risks: " + "; ".join(str(item) for item in risks))
|
|
if row.get("judge_error"):
|
|
lines.append(f"- Judge error: {row['judge_error']}")
|
|
for endpoint in ("opensquilla", "openclaw"):
|
|
result = row[endpoint]
|
|
error = f", error={result['error']}" if result["error"] else ""
|
|
lines.append(
|
|
f"- {endpoint}: ok={result['ok']}, elapsed={result['elapsed_s']}s, "
|
|
f"events={result['event_count']}, provider={result['provider']}, "
|
|
f"model={result['model']}{error}"
|
|
)
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def render_prompts_markdown(rows: list[dict[str, Any]], jsonl_path: Path) -> str:
|
|
lines = [
|
|
"# OpenClaw vs OpenSquilla Meta-Skill Benchmark Prompts",
|
|
"",
|
|
f"Raw JSONL: `{jsonl_path}`",
|
|
"",
|
|
]
|
|
for row in rows:
|
|
case = row["case"]
|
|
lines.append(f"## {case['case_id']}")
|
|
lines.append("")
|
|
lines.append(f"- Meta-skill: `{case['skill_name']}`")
|
|
lines.append(f"- Expected advantage: {case['expected_advantage']}")
|
|
lines.append("")
|
|
lines.append("```text")
|
|
lines.append(case["prompt"])
|
|
lines.append("```")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _select_cases(
|
|
case_arg: str,
|
|
*,
|
|
scenario: str | None = None,
|
|
skill: str | None = None,
|
|
) -> list[ComparisonCase]:
|
|
if case_arg == "all":
|
|
selected = COMPARISON_CASES
|
|
else:
|
|
selected = [case for case in COMPARISON_CASES if case.case_id == case_arg]
|
|
if not selected:
|
|
valid = ", ".join(case.case_id for case in COMPARISON_CASES)
|
|
raise SystemExit(f"Unknown case {case_arg!r}. Valid: {valid}")
|
|
if scenario:
|
|
selected = [case for case in selected if case.scenario == scenario]
|
|
if skill:
|
|
selected = [case for case in selected if case.skill_name == skill]
|
|
if not selected:
|
|
raise SystemExit("No comparison cases matched the requested filters.")
|
|
return selected
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--run-live", action="store_true", help="Run both gateways.")
|
|
parser.add_argument(
|
|
"--judge-jsonl",
|
|
help="Judge an existing comparison JSONL without rerunning both gateways.",
|
|
)
|
|
parser.add_argument("--case", default="all", help="Case id or 'all'.")
|
|
parser.add_argument(
|
|
"--scenario",
|
|
choices=["primary", "degraded", "boundary"],
|
|
help="Optional scenario filter for case='all'.",
|
|
)
|
|
parser.add_argument("--skill", help="Optional meta-skill name filter for case='all'.")
|
|
parser.add_argument("--timeout", type=float, default=240.0)
|
|
parser.add_argument("--opensquilla-url", default="ws://127.0.0.1:8081/ws")
|
|
parser.add_argument("--opensquilla-token", default=read_opensquilla_token())
|
|
parser.add_argument(
|
|
"--opensquilla-elevated",
|
|
default="bypass",
|
|
choices=["off", "on", "bypass", "full"],
|
|
help="Gateway elevated mode for OpenSquilla tool calls.",
|
|
)
|
|
parser.add_argument("--openclaw-url", default="ws://127.0.0.1:18789/ws")
|
|
parser.add_argument("--openclaw-config", default=os.environ.get("OPENCLAW_CONFIG"))
|
|
parser.add_argument("--openclaw-idle-timeout", type=float, default=90.0)
|
|
parser.add_argument(
|
|
"--judge-llm",
|
|
action="store_true",
|
|
help="Use an LLM judge for the final winner after deterministic scoring.",
|
|
)
|
|
parser.add_argument(
|
|
"--judge-model",
|
|
default=os.environ.get("OPENSQUILLA_JUDGE_MODEL"),
|
|
help="OpenRouter model id for --judge-llm.",
|
|
)
|
|
parser.add_argument("--judge-api-key", default=read_judge_api_key())
|
|
parser.add_argument(
|
|
"--judge-base-url",
|
|
default=os.environ.get("OPENSQUILLA_JUDGE_BASE_URL", "https://openrouter.ai/api/v1"),
|
|
)
|
|
parser.add_argument("--judge-timeout", type=float, default=120.0)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
if args.judge_jsonl:
|
|
asyncio.run(judge_existing(args))
|
|
return
|
|
if not args.run_live:
|
|
for case in _select_cases(args.case, scenario=args.scenario, skill=args.skill):
|
|
print(json.dumps(case_to_dict(case), indent=2))
|
|
return
|
|
asyncio.run(run_live(args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|