pantheon-opencode 1.5.1 → 1.6.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/CHANGELOG.md +22 -0
- package/README.md +86 -52
- package/README.pt-BR.md +63 -43
- package/commands/pantheon-optimize.md +2 -2
- package/package.json +13 -1
- package/plugin.json +1 -1
- package/pyproject.toml +8 -15
- package/scripts/build-agents-md.mjs +2 -2
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/code_mode_server.py +116 -24
- package/scripts/doctor.mjs +85 -28
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +2 -2
- package/scripts/install/health-check.mjs +6 -19
- package/scripts/install/interactive.mjs +6 -6
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/install/state.mjs +27 -2
- package/scripts/mcp_persistence_server.py +390 -76
- package/scripts/memory_mcp_server.py +370 -196
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +28 -11
- package/scripts/prune.mjs +2 -2
- package/scripts/sync-tui.mjs +11 -5
- package/scripts/test-opencode-v2-sandbox.sh +1759 -0
- package/scripts/validate-routing.mjs +40 -0
- package/skills-lock.json +9 -10
- package/src/agents/mnemosyne.md +15 -16
- package/src/agents/zeus.md +1 -2
- package/src/mcp/code_mode_server.py +116 -24
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/mcp_persistence_server.py +390 -76
- package/src/mcp/memory_mcp_server.py +371 -248
- package/src/mcp/pantheon_vision_server.py +136 -24
- package/src/mcp/requirements-mcp.txt +5 -3
- package/src/mcp/requirements-vision.txt +2 -0
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/vision.ts +1 -1
- package/src/plugin-v2.ts +134 -104
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +20 -20
- package/src/plugins/tui/package.json +12 -12
- package/src/routing.yml +0 -1
- package/src/skills/README.md +31 -36
- package/src/skills/auto-continue/SKILL.md +25 -75
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -21
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
|
@@ -104,27 +104,18 @@ Only expand to full cooldown when hitting a mandatory gate.
|
|
|
104
104
|
|
|
105
105
|
## Timeout & Retry Enforcement
|
|
106
106
|
|
|
107
|
-
When a delegated agent does not respond in time, enforce the timeout policy
|
|
108
|
-
|
|
109
|
-
### Behavior by Agent Role
|
|
110
|
-
|
|
111
|
-
| Agent Role | Timeout | Retry Policy | Fallback | Timeout Parcial? |
|
|
112
|
-
|------------|---------|-------------|----------|------------------|
|
|
113
|
-
| Explorer (apollo) | 60s | 2 retries, exponential backoff | athena | ✅ Yes — partial results OK |
|
|
114
|
-
| Implementer (hermes, aphrodite) | 180s | 3 retries, exponential backoff | talos | ❌ No — must produce artifact |
|
|
115
|
-
| Reviewer (themis) | 120s | 2 retries, exponential backoff | zeus | ❌ No — must produce verdict |
|
|
116
|
-
| Infrastructure (prometheus) | 300s | 2 retries, exponential backoff | hermes | ❌ No |
|
|
117
|
-
| Hotfix (talos) | 30s | 1 retry, no backoff | hermes | ✅ Yes — one-liner fix OK |
|
|
107
|
+
When a delegated agent does not respond in time, enforce the timeout policy defined in
|
|
108
|
+
`src/instructions/zeus-timeout-retry.instructions.md`.
|
|
118
109
|
|
|
110
|
+
**That file is the single authority** for per-role timeouts, retry counts, fallback chains, and
|
|
111
|
+
whether partial results are acceptable. It is force-fed into every session via `AGENTS.md`; this
|
|
112
|
+
skill is opt-in. Do not restate those values here — a copy in this skill is precisely what drifted
|
|
113
|
+
out of sync with them. If a dispatch times out, read the role's row from the instruction file.
|
|
119
114
|
|
|
120
115
|
### Timeout Parcial (Partial Results)
|
|
121
116
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
- ✅ @gaia (literature review) — can return partial findings
|
|
125
|
-
- ✅ @talos (hotfix confirmation) — can return without fix if timeout
|
|
126
|
-
- ❌ Never for implementers (@hermes, @aphrodite, @demeter) — must complete or fail
|
|
127
|
-
- ❌ Never for reviewers (@themis) — must produce a verdict
|
|
117
|
+
Whether an agent may return partial results is the **Partial Results OK?** column in that table.
|
|
118
|
+
Only agents marked ✅ there may return partial — never implementers, never reviewers.
|
|
128
119
|
|
|
129
120
|
**How to signal timeout parcial:**
|
|
130
121
|
When dispatching, set the expectation explicitly:
|
|
@@ -167,69 +158,28 @@ Ready for Phase 2? Waiting for go-ahead.
|
|
|
167
158
|
Should I now run the migration tests? [waiting]
|
|
168
159
|
```
|
|
169
160
|
|
|
170
|
-
## Session Heartbeat
|
|
171
|
-
|
|
172
|
-
Auto-save checkpoint every N turns (configurable, default 5).
|
|
173
|
-
|
|
174
|
-
### Heartbeat File
|
|
175
|
-
|
|
176
|
-
`.pantheon/deepwork/<slug>/heartbeat.json`
|
|
161
|
+
## Session Heartbeat & Checkpoints
|
|
177
162
|
|
|
178
|
-
|
|
163
|
+
Session state is **not** file-based. It lives in **pantheon-persistence** under the
|
|
164
|
+
`checkpoint:<slug>` namespace and is cleaned up automatically by a 4h TTL.
|
|
179
165
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
"turn_count": 10,
|
|
185
|
-
"current_phase": 2,
|
|
186
|
-
"status": "alive | warning | stalled | paused | completed",
|
|
187
|
-
"next_action": "brief description of next planned action"
|
|
188
|
-
}
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
### Heartbeat Rules
|
|
192
|
-
1. Update every 5 turns minimum (by default)
|
|
193
|
-
2. Contains NO context — only timestamps and counters
|
|
194
|
-
3. Single file, always overwritten (not versioned)
|
|
195
|
-
4. Used for quick "is this session alive?" checks
|
|
196
|
-
|
|
197
|
-
---
|
|
166
|
+
Write state with `context_save(slug, key, value, session_id=...)` and read it back with
|
|
167
|
+
`context_get(slug, "latest", session_id=...)`. Capture `session_id` from the first `context_save`
|
|
168
|
+
of the session and reuse it for the rest of the session, so the `latest` pointer survives
|
|
169
|
+
compaction.
|
|
198
170
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
Each deepwork task maintains:
|
|
204
|
-
|
|
205
|
-
```
|
|
206
|
-
.pantheon/deepwork/<slug>/
|
|
207
|
-
├── PLAN.md # Immutable plan (created at start)
|
|
208
|
-
├── STATUS.md # Human-readable current state (updated every phase)
|
|
209
|
-
├── heartbeat.json # Lightweight ping (updated every N turns)
|
|
210
|
-
├── checkpoint-<N>.json # Full state snapshot (created at phase boundaries)
|
|
211
|
-
├── session.json # Session metadata (created at start, updated on stop)
|
|
212
|
-
└── REVIEW.md # Final Themis review (created at end)
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
### Checkpoint JSON Schema
|
|
216
|
-
|
|
217
|
-
```json
|
|
218
|
-
{
|
|
219
|
-
"slug": "string — unique task identifier",
|
|
220
|
-
"phase": "integer — current phase number",
|
|
221
|
-
"turn_count": "integer — total turns elapsed",
|
|
222
|
-
"timestamp": "string — ISO 8601 timestamp",
|
|
223
|
-
"context_hash": "string — first 8 hex chars of SHA-256 of STATUS.md",
|
|
224
|
-
"version": "integer — schema version (current: 2)"
|
|
225
|
-
}
|
|
226
|
-
```
|
|
171
|
+
### Heartbeat Check
|
|
172
|
+
- If `context_get(slug, "heartbeat")` returns a checkin older than 300s, log a stall warning and resume
|
|
173
|
+
- Write a heartbeat after every anti-stall recovery action
|
|
227
174
|
|
|
228
175
|
### Checkpoint Rules
|
|
229
|
-
1. Save
|
|
230
|
-
2.
|
|
231
|
-
3.
|
|
232
|
-
|
|
176
|
+
1. Save a checkpoint before **every** delegate dispatch and after every agent returns
|
|
177
|
+
2. Include current phase, turn count, and remaining tasks
|
|
178
|
+
3. On resume: `context_get(slug, "latest")` restores the most recent checkpoint
|
|
179
|
+
|
|
180
|
+
> Do not create `heartbeat.json`, `checkpoint-<N>.json`, or `session.json` under
|
|
181
|
+
> `.pantheon/deepwork/`. That file-based mechanism is retired — TTL handles cleanup, and reading
|
|
182
|
+
> a file that nothing writes back returns stale state.
|
|
233
183
|
|
|
234
184
|
---
|
|
235
185
|
|
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# ---
|
|
3
|
-
# description: LLM judge for skill/agent quality — scores 4 dimensions (0-100) via an OpenAI-compatible endpoint
|
|
4
|
-
# timeout: 120
|
|
5
|
-
# ---
|
|
6
|
-
"""LLM judge layer of the plugin-eval certification pipeline.
|
|
7
|
-
|
|
8
|
-
Scores a skill/agent directory on 4 dimensions (correctness, maintainability,
|
|
9
|
-
security, practicality), each 0-100, via ONE structured prompt to an
|
|
10
|
-
OpenAI-compatible chat endpoint. Emits a single JSON document on stdout:
|
|
11
|
-
|
|
12
|
-
{name, dimensions: {correctness, maintainability, security, practicality},
|
|
13
|
-
overall, notes: {<dimension>: <justification>}}
|
|
14
|
-
|
|
15
|
-
Without explicit opt-in it emits a structured skipped result; missing credentials
|
|
16
|
-
or failed calls exit 2 with a clear message.
|
|
17
|
-
|
|
18
|
-
Env vars:
|
|
19
|
-
OPENAI_API_KEY required bearer token
|
|
20
|
-
OPENAI_BASE_URL optional endpoint base (default https://api.openai.com/v1)
|
|
21
|
-
EVAL_JUDGE_MODEL optional model id (default gpt-4o-mini)
|
|
22
|
-
|
|
23
|
-
Stdlib only (urllib.request) — no third-party dependencies.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
from __future__ import annotations
|
|
27
|
-
|
|
28
|
-
import argparse
|
|
29
|
-
import json
|
|
30
|
-
import os
|
|
31
|
-
import re
|
|
32
|
-
import sys
|
|
33
|
-
import urllib.error
|
|
34
|
-
import urllib.request
|
|
35
|
-
from pathlib import Path
|
|
36
|
-
from typing import Any
|
|
37
|
-
from urllib.parse import urlparse
|
|
38
|
-
|
|
39
|
-
DEFAULT_MODEL = "gpt-4o-mini"
|
|
40
|
-
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
41
|
-
REQUEST_TIMEOUT = 90.0
|
|
42
|
-
MAX_CONTENT_CHARS = 12_000
|
|
43
|
-
MAX_SUPPORTING_CHARS = 8_000
|
|
44
|
-
MAX_SUPPORTING_FILES = 6
|
|
45
|
-
DIMENSIONS = ("correctness", "maintainability", "security", "practicality")
|
|
46
|
-
|
|
47
|
-
JUDGE_PROMPT = """You are a rigorous quality auditor for AI agent skills and agent definitions used in an agentic coding framework. You decide whether a skill or agent definition is production-quality or AI slop.
|
|
48
|
-
|
|
49
|
-
Apply an explicit ANTI-SLOP bias: flag overengineering, unnecessary abstractions, persona proliferation (multiple fake personas/characters), unverifiable claims, hype language, and instructions that sound impressive but are not actionable.
|
|
50
|
-
|
|
51
|
-
Score the content on 4 dimensions, each 0-100:
|
|
52
|
-
1. correctness — instructions are accurate and internally consistent; no contradictions; no broken references (files, commands, or agents that do not exist or are misnamed).
|
|
53
|
-
2. maintainability — clear structure, single responsibility, no overengineering, YAGNI-respecting, easy to update.
|
|
54
|
-
3. security — no secrets or credentials; no prompt-injection vectors; no dangerous instructions (e.g. rm -rf, curl | bash, disabling safety checks).
|
|
55
|
-
4. practicality — solves a real problem, actionable, not AI slop or hype; a human would actually use it.
|
|
56
|
-
|
|
57
|
-
Return ONLY a JSON object with this exact shape:
|
|
58
|
-
{"correctness": <int 0-100>, "maintainability": <int 0-100>, "security": <int 0-100>, "practicality": <int 0-100>, "notes": {"correctness": "<1-2 sentences>", "maintainability": "<1-2 sentences>", "security": "<1-2 sentences>", "practicality": "<1-2 sentences>"}}
|
|
59
|
-
|
|
60
|
-
Content to evaluate:
|
|
61
|
-
--- BEGIN CONTENT ---
|
|
62
|
-
{content}
|
|
63
|
-
--- END CONTENT ---
|
|
64
|
-
"""
|
|
65
|
-
|
|
66
|
-
_NAME_RE = re.compile(r"^name:\s*[\"']?([^\"'\n]+?)[\"']?\s*$", re.MULTILINE)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class JudgeError(Exception):
|
|
70
|
-
"""Expected, user-facing judge failure."""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _frontmatter_name(text: str) -> str | None:
|
|
74
|
-
"""Extract the frontmatter `name` value, or None when absent."""
|
|
75
|
-
if not text.startswith("---"):
|
|
76
|
-
return None
|
|
77
|
-
end = text.find("\n---", 3)
|
|
78
|
-
if end == -1:
|
|
79
|
-
return None
|
|
80
|
-
match = _NAME_RE.search(text[3:end])
|
|
81
|
-
return match.group(1).strip() if match else None
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _discover_doc(path: Path) -> Path:
|
|
85
|
-
"""Return the primary doc (SKILL.md or agent .md) for a path."""
|
|
86
|
-
if path.is_file():
|
|
87
|
-
return path
|
|
88
|
-
skill = path / "SKILL.md"
|
|
89
|
-
if skill.is_file():
|
|
90
|
-
return skill
|
|
91
|
-
candidates = sorted(path.glob("*.md"))
|
|
92
|
-
for candidate in candidates:
|
|
93
|
-
try:
|
|
94
|
-
text = candidate.read_text(encoding="utf-8", errors="replace")
|
|
95
|
-
except OSError:
|
|
96
|
-
continue
|
|
97
|
-
if text.startswith("---") and text.find("\n---", 3) != -1:
|
|
98
|
-
return candidate
|
|
99
|
-
if candidates:
|
|
100
|
-
return candidates[0]
|
|
101
|
-
raise JudgeError(f"No SKILL.md or .md file found in {path}")
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _supporting_files(doc: Path) -> list[Path]:
|
|
105
|
-
"""Script files next to the doc or in its scripts/ subdir (capped)."""
|
|
106
|
-
roots = [doc.parent]
|
|
107
|
-
scripts_dir = doc.parent / "scripts"
|
|
108
|
-
if scripts_dir.is_dir():
|
|
109
|
-
roots.append(scripts_dir)
|
|
110
|
-
found: list[Path] = []
|
|
111
|
-
for root in roots:
|
|
112
|
-
for pattern in ("*.py", "*.sh", "*.js", "*.ts", "*.mjs"):
|
|
113
|
-
found.extend(sorted(root.glob(pattern)))
|
|
114
|
-
return [p for p in found if p.is_file() and p != doc][:MAX_SUPPORTING_FILES]
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _load_content(doc: Path) -> tuple[str, str]:
|
|
118
|
-
"""Return (name, content) with the doc plus small supporting files."""
|
|
119
|
-
try:
|
|
120
|
-
text = doc.read_text(encoding="utf-8", errors="replace")
|
|
121
|
-
except OSError as exc:
|
|
122
|
-
raise JudgeError(f"Cannot read {doc}: {exc}") from exc
|
|
123
|
-
name = _frontmatter_name(text) or doc.stem
|
|
124
|
-
parts = [f"# {name}\n", text[:MAX_CONTENT_CHARS]]
|
|
125
|
-
if len(text) > MAX_CONTENT_CHARS:
|
|
126
|
-
parts.append(f"\n[... truncated {len(text) - MAX_CONTENT_CHARS} chars ...]")
|
|
127
|
-
support: list[str] = []
|
|
128
|
-
total = 0
|
|
129
|
-
for sibling in _supporting_files(doc):
|
|
130
|
-
try:
|
|
131
|
-
data = sibling.read_text(encoding="utf-8", errors="replace")
|
|
132
|
-
except OSError:
|
|
133
|
-
continue
|
|
134
|
-
chunk = f"\n--- supporting file: {sibling.name} ---\n{data[:2000]}"
|
|
135
|
-
if total + len(chunk) > MAX_SUPPORTING_CHARS:
|
|
136
|
-
break
|
|
137
|
-
support.append(chunk)
|
|
138
|
-
total += len(chunk)
|
|
139
|
-
if support:
|
|
140
|
-
parts.append("\n".join(support))
|
|
141
|
-
return name, "".join(parts)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _call_llm(prompt: str, allow_external_llm: bool = False) -> str:
|
|
145
|
-
"""Send one chat completion request via urllib; return the assistant text."""
|
|
146
|
-
if not allow_external_llm:
|
|
147
|
-
raise JudgeError("External LLM calls require --allow-external-llm or PANTHEON_ALLOW_EXTERNAL_LLM=1")
|
|
148
|
-
key = os.getenv("OPENAI_API_KEY")
|
|
149
|
-
if not key:
|
|
150
|
-
raise JudgeError(
|
|
151
|
-
"No LLM available: set OPENAI_API_KEY "
|
|
152
|
-
"(optionally OPENAI_BASE_URL and EVAL_JUDGE_MODEL)."
|
|
153
|
-
)
|
|
154
|
-
base_url = os.getenv("OPENAI_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
|
|
155
|
-
parsed_url = urlparse(base_url)
|
|
156
|
-
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
|
157
|
-
raise JudgeError("OPENAI_BASE_URL must use an http or https URL")
|
|
158
|
-
endpoint = base_url + "/chat/completions"
|
|
159
|
-
model = os.getenv("EVAL_JUDGE_MODEL") or DEFAULT_MODEL
|
|
160
|
-
body = {
|
|
161
|
-
"model": model,
|
|
162
|
-
"messages": [{"role": "user", "content": prompt}],
|
|
163
|
-
"temperature": 0.2,
|
|
164
|
-
}
|
|
165
|
-
request = urllib.request.Request(
|
|
166
|
-
endpoint,
|
|
167
|
-
data=json.dumps(body).encode("utf-8"),
|
|
168
|
-
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
169
|
-
method="POST",
|
|
170
|
-
)
|
|
171
|
-
try:
|
|
172
|
-
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
173
|
-
payload = json.loads(response.read().decode("utf-8"))
|
|
174
|
-
except urllib.error.HTTPError as exc:
|
|
175
|
-
detail = exc.read().decode("utf-8", errors="replace")[:200].replace(key, "[redacted]")
|
|
176
|
-
raise JudgeError(f"LLM endpoint returned HTTP {exc.code}: {detail}") from exc
|
|
177
|
-
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
178
|
-
reason = getattr(exc, "reason", exc)
|
|
179
|
-
raise JudgeError(f"LLM request failed: {reason}") from exc
|
|
180
|
-
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
181
|
-
raise JudgeError(f"LLM returned invalid JSON payload: {exc}") from exc
|
|
182
|
-
choices = payload.get("choices") if isinstance(payload, dict) else None
|
|
183
|
-
message = choices[0].get("message") if choices else None
|
|
184
|
-
text = message.get("content") if isinstance(message, dict) else None
|
|
185
|
-
if not isinstance(text, str) or not text.strip():
|
|
186
|
-
raise JudgeError("LLM returned an empty response.")
|
|
187
|
-
return text.replace(key, "[redacted]")
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _parse_json_object(value: str) -> dict[str, Any] | None:
|
|
191
|
-
"""Parse strict JSON and the fenced JSON commonly returned by models."""
|
|
192
|
-
candidates = [value.strip()]
|
|
193
|
-
if candidates[0].startswith("```") and candidates[0].endswith("```"):
|
|
194
|
-
candidates.append(candidates[0].split("\n", 1)[-1][:-3].strip())
|
|
195
|
-
for candidate in candidates:
|
|
196
|
-
try:
|
|
197
|
-
parsed = json.loads(candidate)
|
|
198
|
-
except json.JSONDecodeError:
|
|
199
|
-
continue
|
|
200
|
-
if isinstance(parsed, dict):
|
|
201
|
-
return parsed
|
|
202
|
-
return None
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def _parse_scores(text: str) -> dict[str, Any]:
|
|
206
|
-
"""Validate the LLM JSON into the report's dimensions/overall/notes."""
|
|
207
|
-
parsed = _parse_json_object(text)
|
|
208
|
-
if parsed is None:
|
|
209
|
-
raise JudgeError("LLM did not return valid JSON.")
|
|
210
|
-
dimensions: dict[str, int] = {}
|
|
211
|
-
for dim in DIMENSIONS:
|
|
212
|
-
raw = parsed.get(dim)
|
|
213
|
-
try:
|
|
214
|
-
value = int(raw)
|
|
215
|
-
except (TypeError, ValueError) as exc:
|
|
216
|
-
raise JudgeError(f"LLM returned non-integer score for {dim}: {raw!r}") from exc
|
|
217
|
-
if not 0 <= value <= 100:
|
|
218
|
-
raise JudgeError(f"LLM returned out-of-range score for {dim}: {value}")
|
|
219
|
-
dimensions[dim] = value
|
|
220
|
-
raw_notes = parsed.get("notes")
|
|
221
|
-
notes = (
|
|
222
|
-
{dim: str(raw_notes.get(dim, "")) for dim in DIMENSIONS}
|
|
223
|
-
if isinstance(raw_notes, dict)
|
|
224
|
-
else {dim: "" for dim in DIMENSIONS}
|
|
225
|
-
)
|
|
226
|
-
overall = round(sum(dimensions.values()) / len(dimensions), 1)
|
|
227
|
-
return {"dimensions": dimensions, "overall": overall, "notes": notes}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def main(argv: list[str] | None = None) -> int:
|
|
231
|
-
"""CLI entry point: `python eval-llm-judge.py <skill-or-agent-dir>`."""
|
|
232
|
-
parser = argparse.ArgumentParser(
|
|
233
|
-
description="LLM judge for skill/agent quality (plugin-eval layer 2)."
|
|
234
|
-
)
|
|
235
|
-
parser.add_argument("path", help="Path to a skill/agent directory or doc file")
|
|
236
|
-
parser.add_argument("--allow-external-llm", action="store_true", help="Permit sending content to the configured LLM endpoint")
|
|
237
|
-
args = parser.parse_args(argv)
|
|
238
|
-
allow_external_llm = args.allow_external_llm or os.getenv("PANTHEON_ALLOW_EXTERNAL_LLM") == "1"
|
|
239
|
-
try:
|
|
240
|
-
doc = _discover_doc(Path(args.path))
|
|
241
|
-
name, content = _load_content(doc)
|
|
242
|
-
if not allow_external_llm:
|
|
243
|
-
print(json.dumps({"name": name, "skipped": True, "reason": "external LLM calls not opted in"}, ensure_ascii=False, indent=2))
|
|
244
|
-
return 0
|
|
245
|
-
raw = _call_llm(JUDGE_PROMPT.replace("{content}", content), allow_external_llm=True)
|
|
246
|
-
result = _parse_scores(raw)
|
|
247
|
-
except JudgeError as exc:
|
|
248
|
-
print(f"eval-llm-judge: {exc}", file=sys.stderr)
|
|
249
|
-
return 2
|
|
250
|
-
print(json.dumps({"name": name, **result}, ensure_ascii=False, indent=2))
|
|
251
|
-
return 0
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if __name__ == "__main__":
|
|
255
|
-
sys.exit(main())
|