Skip to content

Commit 711e26e

Browse files
committed
chore(cecli): pin dev-integration — Kiro-style agent prompt + edit contract
Pin cecli submodule to a653ce9f0 (dev-integration), which carries the Kiro-style agent prompt rewrite + explicit edit contract and the {final_reminders}/sub-agent-inheritance fixes (upstream PR cecli-dev/cecli#566). Adds the BrightVision-side prompt-quality eval harness: - bright_vision_core/agent_eval.py: objective behavioral scorer reusing the agent_turn.py signal parsers (edit failures, ReadRange-before-edit, ls-spam, token limit, rounds) + tests/core/test_agent_eval.py. - bright_vision_core/agent_judge.py: opt-in LLM-as-judge rubric (scope, directness, investigation, summary quality) with robust JSON parsing + tests/core/test_agent_judge.py. - tests/core/test_agent_prompt_eval.py + 'eval:prompts' script: real-Ollama behavioral eval scoring one scoped edit turn (E2E_LLM, BV_PROMPT_JUDGE). - docs: ROADMAP #54, TESTING 'Measuring prompt quality' section; .gitignore for the regenerated eval workspace.
1 parent 02069b1 commit 711e26e

10 files changed

Lines changed: 776 additions & 4 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ playwright-report/
6262
e2e/fixtures/*-workspace/.git/
6363
e2e/fixtures/hello-workspace/.cecli/
6464
e2e/fixtures/integration-workspace/.cecli/
65+
# Prompt-eval workspace is fully regenerated by tests/core/test_agent_prompt_eval.py
66+
e2e/fixtures/prompt-eval-workspace/
6567

6668
# Local Ollama defaults for BrightVision (see docs/LOCAL_LLM.md)
6769
local-llm.env

bright_vision_core/agent_eval.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""
2+
Objective behavioral scoring for agent turns (prompt-quality evals).
3+
4+
This turns an SSE event stream from a ``/agent`` (or implement) turn into a small set of
5+
*objective* behavioral metrics, so two system-prompt versions can be compared on the same
6+
task without subjective judgement. It deliberately reuses the signal parsers in
7+
``agent_turn`` — the same heuristics the live harness uses to detect bad turns — so the
8+
score reflects real product behavior, not a separate definition of "good".
9+
10+
Lower is better for failure counters; the ``followed_edit_contract`` /
11+
``score`` summaries make a turn pass/fail comparable across prompt versions.
12+
13+
Usage (see ``tests/core/test_agent_prompt_eval.py``)::
14+
15+
from bright_vision_core.agent_eval import score_turn
16+
metrics = score_turn(events)
17+
assert metrics.followed_edit_contract
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from dataclasses import dataclass, asdict
23+
from typing import Any
24+
25+
from bright_vision_core.agent_turn import (
26+
agent_had_write_tool_in_events,
27+
edit_tool_failures_in_events,
28+
is_agent_tool_activity_event,
29+
is_edit_tool_success_event,
30+
is_ls_tool_output_event,
31+
is_read_range_success_event,
32+
is_readrange_tool_error_event,
33+
llm_round_count_from_events,
34+
max_cumulative_tokens_from_events,
35+
token_limit_exhausted_in_events,
36+
)
37+
38+
39+
@dataclass
40+
class TurnMetrics:
41+
"""Objective per-turn behavioral signals. Failure counters: lower is better."""
42+
43+
# Productive work
44+
wrote_files: bool
45+
edit_success_count: int
46+
readrange_success_count: int
47+
tool_activity_count: int
48+
49+
# Failure / friction signals (lower is better)
50+
edit_failure_count: int
51+
readrange_error_count: int
52+
ls_call_count: int
53+
hit_token_limit: bool
54+
had_error_event: bool
55+
56+
# Efficiency
57+
llm_rounds: int
58+
cumulative_tokens: int
59+
60+
# Derived verdicts
61+
readrange_before_first_edit: bool
62+
followed_edit_contract: bool
63+
score: float
64+
65+
def as_dict(self) -> dict[str, Any]:
66+
return asdict(self)
67+
68+
69+
def _readrange_precedes_first_edit_success(events: list[dict]) -> bool:
70+
"""
71+
True when a ReadRange success appears before the first successful EditText.
72+
73+
The edit contract requires ReadRange immediately before EditText. If the turn never
74+
edited successfully, there is no contract violation to detect (vacuously True); callers
75+
should check ``wrote_files`` separately when an edit was expected.
76+
"""
77+
seen_readrange = False
78+
for event in events:
79+
if is_read_range_success_event(event):
80+
seen_readrange = True
81+
elif is_edit_tool_success_event(event):
82+
return seen_readrange
83+
return True
84+
85+
86+
def score_turn(events: list[dict]) -> TurnMetrics:
87+
"""Reduce an SSE event list to objective behavioral metrics."""
88+
edit_failures = edit_tool_failures_in_events(events)
89+
edit_successes = sum(1 for e in events if is_edit_tool_success_event(e))
90+
readrange_successes = sum(1 for e in events if is_read_range_success_event(e))
91+
readrange_errors = sum(1 for e in events if is_readrange_tool_error_event(e))
92+
ls_calls = sum(1 for e in events if is_ls_tool_output_event(e))
93+
tool_activity = sum(1 for e in events if is_agent_tool_activity_event(e))
94+
had_error = any(e.get("type") == "error" for e in events)
95+
wrote_files = agent_had_write_tool_in_events(events)
96+
rr_before_edit = _readrange_precedes_first_edit_success(events)
97+
hit_token_limit = token_limit_exhausted_in_events(events)
98+
99+
followed_contract = (
100+
len(edit_failures) == 0
101+
and readrange_errors == 0
102+
and rr_before_edit
103+
and not had_error
104+
)
105+
106+
# Simple bounded score in [0, 1]: start at 1.0, subtract for each friction signal.
107+
score = 1.0
108+
score -= 0.20 * min(len(edit_failures), 3)
109+
score -= 0.20 * min(readrange_errors, 3)
110+
score -= 0.10 * max(ls_calls - 1, 0) # one ls is fine; spam is penalized
111+
score -= 0.30 if had_error else 0.0
112+
score -= 0.20 if hit_token_limit else 0.0
113+
score -= 0.30 if not rr_before_edit else 0.0
114+
score = max(0.0, min(1.0, score))
115+
116+
return TurnMetrics(
117+
wrote_files=wrote_files,
118+
edit_success_count=edit_successes,
119+
readrange_success_count=readrange_successes,
120+
tool_activity_count=tool_activity,
121+
edit_failure_count=len(edit_failures),
122+
readrange_error_count=readrange_errors,
123+
ls_call_count=ls_calls,
124+
hit_token_limit=hit_token_limit,
125+
had_error_event=had_error,
126+
llm_rounds=llm_round_count_from_events(events),
127+
cumulative_tokens=max_cumulative_tokens_from_events(events),
128+
readrange_before_first_edit=rr_before_edit,
129+
followed_edit_contract=followed_contract,
130+
score=round(score, 3),
131+
)
132+
133+
134+
def summarize_metrics(label: str, metrics: TurnMetrics) -> str:
135+
"""One-line human summary for eval logs / CI output."""
136+
return (
137+
f"[{label}] score={metrics.score} contract={'ok' if metrics.followed_edit_contract else 'BROKEN'} "
138+
f"edits_ok={metrics.edit_success_count} edit_fail={metrics.edit_failure_count} "
139+
f"rr_ok={metrics.readrange_success_count} rr_err={metrics.readrange_error_count} "
140+
f"ls={metrics.ls_call_count} rounds={metrics.llm_rounds} "
141+
f"tokens={metrics.cumulative_tokens} token_limit={metrics.hit_token_limit}"
142+
)

bright_vision_core/agent_judge.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""
2+
LLM-as-judge rubric scoring for agent turns (subjective prompt-quality signal).
3+
4+
The deterministic scorer in ``agent_eval`` measures *behavior* (did the agent follow the
5+
edit contract, loop, error out). This module covers the *subjective* half — tone, scope
6+
discipline, directness, and whether the final summary is useful — by asking a model to
7+
grade the turn transcript against a fixed rubric and return structured JSON.
8+
9+
It is intentionally separate and opt-in: nothing here runs in the default gate, and the
10+
judge model is supplied by the caller (no judge dependency is pinned). Use a capable model
11+
as the judge — grading is easier than coding, but a 3b model makes a noisy judge.
12+
13+
Usage (see ``tests/core/test_agent_prompt_eval.py``)::
14+
15+
from cecli import models
16+
from bright_vision_core.agent_judge import judge_transcript, transcript_from_events
17+
judge_model = models.Model("ollama_chat/qwen3-coder:30b")
18+
verdict = await judge_transcript(judge_model, task, transcript_from_events(events))
19+
assert verdict.overall >= 3
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import json
25+
import re
26+
from dataclasses import dataclass, asdict, field
27+
from typing import Any
28+
29+
# Rubric dimensions scored 1 (poor) .. 5 (excellent). Keep names stable: tests and
30+
# eval logs key on them.
31+
RUBRIC_DIMENSIONS: dict[str, str] = {
32+
"scope_discipline": (
33+
"Did the agent do only what the task asked, without unrequested refactors, "
34+
"reformatting, or edits to unrelated code? 5 = perfectly scoped; 1 = sprawling."
35+
),
36+
"directness": (
37+
"Was the agent's communication concise and confident, leading with substance, "
38+
"without filler, hedging, or repeated restatements? 5 = crisp; 1 = rambling."
39+
),
40+
"investigation": (
41+
"Did the agent read/inspect relevant code before acting or claiming, rather than "
42+
"guessing? 5 = grounded in what it actually read; 1 = unfounded assertions."
43+
),
44+
"summary_quality": (
45+
"Was the final summary accurate, useful, and free of tool-call syntax or internal "
46+
"jargon — something the user can act on? 5 = clear and honest; 1 = absent/misleading."
47+
),
48+
}
49+
50+
_SCALE_MIN = 1
51+
_SCALE_MAX = 5
52+
53+
_SYSTEM_PROMPT = (
54+
"You are a strict, fair evaluator of an AI software-engineering agent's turn. "
55+
"You are grading the agent's behavior and communication quality against a rubric — "
56+
"NOT redoing the task. Be objective and cite the transcript. "
57+
"Respond with ONLY a single JSON object, no prose, no code fences."
58+
)
59+
60+
61+
@dataclass
62+
class JudgeVerdict:
63+
"""Rubric scores (1..5 per dimension) plus an overall and the judge's notes."""
64+
65+
scores: dict[str, int]
66+
overall: float
67+
notes: str = ""
68+
raw: str = ""
69+
parse_error: str | None = None
70+
dimensions: dict[str, str] = field(default_factory=lambda: dict(RUBRIC_DIMENSIONS))
71+
72+
def as_dict(self) -> dict[str, Any]:
73+
return asdict(self)
74+
75+
@property
76+
def ok(self) -> bool:
77+
"""True when the judge returned usable scores for every rubric dimension."""
78+
return self.parse_error is None and set(self.scores) == set(RUBRIC_DIMENSIONS)
79+
80+
81+
def transcript_from_events(events: list[dict], *, max_chars: int = 12_000) -> str:
82+
"""
83+
Render an SSE event stream into a compact transcript for the judge.
84+
85+
Keeps the user message, assistant prose, tool calls, tool output/errors, and the final
86+
summary — the things a reviewer would read. Token-usage footers and progress pulses are
87+
dropped. Truncated from the middle if oversized so both the task and the ending survive.
88+
"""
89+
lines: list[str] = []
90+
for event in events:
91+
kind = event.get("type")
92+
text = str(event.get("text") or "").strip()
93+
if kind == "user_message":
94+
lines.append(f"USER: {text}")
95+
elif kind == "tool_output":
96+
if text and not _is_noise(text):
97+
lines.append(f"TOOL: {text}")
98+
elif kind == "tool_error":
99+
if text:
100+
lines.append(f"TOOL_ERROR: {text}")
101+
elif kind == "error":
102+
if text:
103+
lines.append(f"ERROR: {text}")
104+
elif kind == "done":
105+
summary = str(event.get("assistant_text") or "").strip()
106+
if summary:
107+
lines.append(f"ASSISTANT_SUMMARY: {summary}")
108+
transcript = "\n".join(lines).strip()
109+
if len(transcript) <= max_chars:
110+
return transcript
111+
head = transcript[: max_chars // 2]
112+
tail = transcript[-max_chars // 2 :]
113+
return f"{head}\n…[transcript truncated]…\n{tail}"
114+
115+
116+
_NOISE_PREFIXES = ("Recovered prose shell", "Running ")
117+
_TOKEN_FOOTER = re.compile(r"^[\d.]+k?\s+[↑↓]")
118+
119+
120+
def _is_noise(text: str) -> bool:
121+
if _TOKEN_FOOTER.match(text):
122+
return True
123+
return any(text.startswith(p) for p in _NOISE_PREFIXES)
124+
125+
126+
def build_judge_messages(task: str, transcript: str) -> list[dict[str, str]]:
127+
"""Build the chat messages for the judge model."""
128+
rubric_lines = "\n".join(f"- {name}: {desc}" for name, desc in RUBRIC_DIMENSIONS.items())
129+
keys = ", ".join(f'"{k}"' for k in RUBRIC_DIMENSIONS)
130+
user = (
131+
f"# Task given to the agent\n{task}\n\n"
132+
f"# Agent turn transcript\n{transcript}\n\n"
133+
f"# Rubric (score each {_SCALE_MIN}-{_SCALE_MAX}, integers only)\n{rubric_lines}\n\n"
134+
"# Output\n"
135+
"Return ONLY this JSON object (no markdown, no fences):\n"
136+
'{"scores": {' + keys + ': <int>}, "notes": "<one or two sentences citing the transcript>"}'
137+
)
138+
return [
139+
{"role": "system", "content": _SYSTEM_PROMPT},
140+
{"role": "user", "content": user},
141+
]
142+
143+
144+
def _clamp(value: Any) -> int | None:
145+
try:
146+
n = int(round(float(value)))
147+
except (TypeError, ValueError):
148+
return None
149+
return max(_SCALE_MIN, min(_SCALE_MAX, n))
150+
151+
152+
def parse_judge_response(raw: str) -> JudgeVerdict:
153+
"""Parse the judge's JSON reply into a verdict, tolerating fences and stray prose."""
154+
text = (raw or "").strip()
155+
# Strip accidental code fences.
156+
fence = re.match(r"^```[a-zA-Z]*\s*(.*?)\s*```$", text, re.DOTALL)
157+
if fence:
158+
text = fence.group(1).strip()
159+
# Grab the first {...} block if there is surrounding prose.
160+
if not text.startswith("{"):
161+
brace = re.search(r"\{.*\}", text, re.DOTALL)
162+
if brace:
163+
text = brace.group(0)
164+
try:
165+
data = json.loads(text)
166+
except (json.JSONDecodeError, TypeError) as err:
167+
return JudgeVerdict(scores={}, overall=0.0, raw=raw, parse_error=f"json: {err}")
168+
169+
raw_scores = data.get("scores") if isinstance(data, dict) else None
170+
if not isinstance(raw_scores, dict):
171+
return JudgeVerdict(scores={}, overall=0.0, raw=raw, parse_error="missing 'scores' object")
172+
173+
scores: dict[str, int] = {}
174+
for dim in RUBRIC_DIMENSIONS:
175+
val = _clamp(raw_scores.get(dim))
176+
if val is not None:
177+
scores[dim] = val
178+
missing = set(RUBRIC_DIMENSIONS) - set(scores)
179+
parse_error = f"missing dimensions: {sorted(missing)}" if missing else None
180+
overall = round(sum(scores.values()) / len(scores), 2) if scores else 0.0
181+
notes = str(data.get("notes") or "").strip() if isinstance(data, dict) else ""
182+
return JudgeVerdict(
183+
scores=scores, overall=overall, notes=notes, raw=raw, parse_error=parse_error
184+
)
185+
186+
187+
async def judge_transcript(judge_model, task: str, transcript: str) -> JudgeVerdict:
188+
"""
189+
Score a turn transcript with ``judge_model`` against the rubric.
190+
191+
``judge_model`` is any cecli ``models.Model`` exposing the async
192+
``simple_send_with_retries(messages)`` API. Returns a :class:`JudgeVerdict`; on a model
193+
or parse failure the verdict carries ``parse_error`` and ``ok == False`` rather than
194+
raising, so eval callers can degrade gracefully.
195+
"""
196+
messages = build_judge_messages(task, transcript)
197+
try:
198+
reply = await judge_model.simple_send_with_retries(messages)
199+
except Exception as err: # network/model errors should not crash an eval run
200+
return JudgeVerdict(scores={}, overall=0.0, parse_error=f"model: {err}")
201+
if not reply:
202+
return JudgeVerdict(scores={}, overall=0.0, parse_error="empty judge response")
203+
return parse_judge_response(reply)
204+
205+
206+
def summarize_verdict(label: str, verdict: JudgeVerdict) -> str:
207+
"""One-line human summary for eval logs / CI output."""
208+
if not verdict.ok:
209+
return f"[{label}] judge: UNAVAILABLE ({verdict.parse_error})"
210+
dims = " ".join(f"{k}={v}" for k, v in verdict.scores.items())
211+
return f"[{label}] judge overall={verdict.overall} {dims}"

0 commit comments

Comments
 (0)