feat(skill-use): score Compliance and Boundary from actions, not narrative
The scorer read the concatenated `reasoning` text — the agent's own account of its turn, written by the thing being measured and silent about anything it did not think worth mentioning. `Evidence` now carries the recorded tool calls alongside that text and every check prefers them. What that changes, concretely: - `workspace-repo-commit-protocol` Boundary was a substring search for `/workspace/repo` in the narrative. An agent that wrote to the wrong root without narrating it scored a clean pass. It now reads the `Write` and `Edit` paths, and gained the skill's other hard prohibition — force-push — which leaves no trace anywhere else once it succeeds. - `arxiv-daily` Boundary reads the `curl` that ran rather than a URL in prose, which may be the agent explaining that it did NOT fetch it. - `tdd-red-green-refactor` and `cargo-test-driven-development` gain their first Compliance check: files written with no test command anywhere cannot have been red-green under any reading of the loop. - `small-focused-commits` gains a Boundary check on the exact subjects the skill names as never-merge, read out of `git commit -m`. Two verdicts changed for honesty rather than coverage. Silence used to score `Pass`: a mission with no evidence scored identically to one checked and found clean. It is now `NotObservable`. And a test that ran AFTER the first write is `NotObservable`, not a failure — a Rust unit test lives in the file under test, so that ordering is what following the skill most precisely looks like from here. Every tool-backed check is one-sided: it reports a violation it can see and never infers compliance from silence, because the recorded stream is capped per phase. The negative controls earned their keep — they caught `-f` inside a commit message scoring as a force-push, and `git commit -am` yielding no subject at all. Trigger stays `NotObservable`, and half its stated reason is now wrong. "`claude_cli` cannot surface a tool call" is false; we simply still inline. The blocker moved from the transport to the delivery model, and the module says so. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
This commit is contained in:
co-authored by
Claude Opus 5
parent
8cb38d1320
commit
1a6fdfc0e6
+598
-41
@@ -11,10 +11,8 @@
|
||||
//! is the Trigger event, and it is observable because it is a tool call.
|
||||
//!
|
||||
//! We do not deliver skills that way on the mission path. `pinned_skills_text`
|
||||
//! inlines full bodies into the prompt, because mission claws run on
|
||||
//! `claude_cli`, which cannot surface a tool call at all — there is nothing to
|
||||
//! retrieve *with*. So the agent never "reaches for" a skill; it is simply
|
||||
//! holding one.
|
||||
//! inlines full bodies into the prompt, so the agent never "reaches for" a
|
||||
//! skill; it is simply holding one.
|
||||
//!
|
||||
//! Trigger is therefore **not observable on the mission path**, and this module
|
||||
//! reports it as `NotObservable` with the reason attached rather than scoring
|
||||
@@ -22,8 +20,33 @@
|
||||
//! actually means "the question does not apply to how we deliver them" — the
|
||||
//! precise confusion that made 55 empty skill bindings invisible for months.
|
||||
//!
|
||||
//! Compliance and Boundary are observable, because they are properties of the
|
||||
//! output rather than of the retrieval.
|
||||
//! ### The reason changed, and only half of it went away
|
||||
//!
|
||||
//! Until 2026-08-21 the stated reason was that `claude_cli` "cannot surface a
|
||||
//! tool call at all — there is nothing to retrieve *with*". That half is now
|
||||
//! false: [`crate::container_tool_hooks`] installs `PostToolUse`, mission tool
|
||||
//! calls land in `mission_events`, and a retrieval would be as visible as any
|
||||
//! other call.
|
||||
//!
|
||||
//! The other half still holds, and it is the one that decides the verdict: we
|
||||
//! still **inline**. Trigger is now *instrumentable* and still not
|
||||
//! *observable*, and the blocker has moved from the transport to the delivery
|
||||
//! model. Making it real is one change — serve skills through the door
|
||||
//! (`docs/TOOL-CALL-ARCHITECTURE.md` §3) so retrieval becomes a tool call —
|
||||
//! and nothing else in this module has to change to score it.
|
||||
//!
|
||||
//! ## Compliance and Boundary are scored from ACTIONS where actions exist
|
||||
//!
|
||||
//! They were scored from the concatenated narrative, which is the agent's own
|
||||
//! account of its turn — written by the thing being measured, and silent about
|
||||
//! anything it did not think worth mentioning. [`Evidence`] carries the
|
||||
//! recorded tool calls alongside that text, and every check prefers them:
|
||||
//! `Bash` arguments say what ran, `Write` paths say where it landed.
|
||||
//!
|
||||
//! Every tool-backed check is **one-sided**. It reports a violation it can see
|
||||
//! and never infers compliance from silence — the recorded stream is capped
|
||||
//! per phase ([`crate::mission_events::PER_PHASE_CAP`]), so an absent call is
|
||||
//! not proof of an absent action.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -82,12 +105,55 @@ pub fn skills_in_prompt(prompt: &str) -> Vec<String> {
|
||||
crate::topology_exec::skill_names_in(prompt)
|
||||
}
|
||||
|
||||
/// What a mission left behind, for the checks to read.
|
||||
///
|
||||
/// Two sources, deliberately kept apart. `text` is what the agent *said* it
|
||||
/// did; `tools` is what it did. Where they disagree the tools win, and a check
|
||||
/// that can only consult `text` says so in its verdict rather than presenting a
|
||||
/// narrative reading as an observation.
|
||||
pub struct Evidence<'a> {
|
||||
/// Concatenated `reasoning` events for the mission.
|
||||
pub text: &'a str,
|
||||
/// Every `tool.call` recorded for the mission, in order.
|
||||
pub tools: &'a [crate::mission_events::ToolEvidence],
|
||||
}
|
||||
|
||||
/// Tools that modify a file. `target` on the recorded event is the tool name.
|
||||
const WRITE_TOOLS: [&str; 4] = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
||||
|
||||
impl<'a> Evidence<'a> {
|
||||
pub fn new(text: &'a str, tools: &'a [crate::mission_events::ToolEvidence]) -> Self {
|
||||
Evidence { text, tools }
|
||||
}
|
||||
|
||||
/// Narrative only — a mission whose events predate the tool tap.
|
||||
pub fn from_text(text: &'a str) -> Self {
|
||||
Evidence { text, tools: &[] }
|
||||
}
|
||||
|
||||
/// Every shell command the agent ran, in order.
|
||||
fn commands(&self) -> impl Iterator<Item = &str> {
|
||||
self.tools.iter().filter_map(|t| t.command())
|
||||
}
|
||||
|
||||
/// Every file-modifying call that named a path, in order.
|
||||
fn writes(&self) -> impl Iterator<Item = &crate::mission_events::ToolEvidence> {
|
||||
self.tools
|
||||
.iter()
|
||||
.filter(|t| WRITE_TOOLS.contains(&t.tool.as_str()) && t.path.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// Score every skill a phase's prompt delivered, against what the agent produced.
|
||||
pub fn score(prompt: &str, output: &str, source_kinds: &dyn Fn(&str) -> String) -> Vec<SkillUse> {
|
||||
pub fn score(
|
||||
prompt: &str,
|
||||
evidence: &Evidence<'_>,
|
||||
source_kinds: &dyn Fn(&str) -> String,
|
||||
) -> Vec<SkillUse> {
|
||||
skills_in_prompt(prompt)
|
||||
.into_iter()
|
||||
.map(|skill| {
|
||||
let (compliance, boundary) = check(&skill, output);
|
||||
let (compliance, boundary) = check(&skill, evidence);
|
||||
SkillUse {
|
||||
source_kind: source_kinds(&skill),
|
||||
trigger: Verdict::NotObservable(
|
||||
@@ -109,11 +175,19 @@ pub fn score(prompt: &str, output: &str, source_kinds: &dyn Fn(&str) -> String)
|
||||
/// Everything else returns `NotApplicable` rather than a guess: a heuristic
|
||||
/// that scores prose by keyword overlap produces a number that looks like a
|
||||
/// measurement and is not one.
|
||||
fn check(skill: &str, output: &str) -> (Verdict, Verdict) {
|
||||
fn check(skill: &str, ev: &Evidence<'_>) -> (Verdict, Verdict) {
|
||||
match skill {
|
||||
"int-xx-marker-protocol" => (marker_compliance(output), marker_boundary(output)),
|
||||
"arxiv-daily" => (Verdict::NotApplicable, arxiv_boundary(output)),
|
||||
"workspace-repo-commit-protocol" => (Verdict::NotApplicable, workspace_boundary(output)),
|
||||
"int-xx-marker-protocol" => (marker_compliance(ev.text), marker_boundary(ev.text)),
|
||||
"arxiv-daily" => (Verdict::NotApplicable, arxiv_boundary(ev)),
|
||||
"workspace-repo-commit-protocol" => (Verdict::NotApplicable, workspace_boundary(ev)),
|
||||
"small-focused-commits" => (Verdict::NotApplicable, commit_subject_boundary(ev)),
|
||||
// One check for both: `cargo-test-driven-development` is the Rust
|
||||
// flavour of the same loop, and its own text says so. Scoring them by
|
||||
// separate rules would mean two rules for one procedure, which is how
|
||||
// the microVM and container taps drifted apart.
|
||||
"tdd-red-green-refactor" | "cargo-test-driven-development" => {
|
||||
(red_before_green(ev), Verdict::NotApplicable)
|
||||
}
|
||||
_ => (Verdict::NotApplicable, Verdict::NotApplicable),
|
||||
}
|
||||
}
|
||||
@@ -192,20 +266,269 @@ fn looks_like_marker_attempt(line: &str) -> bool {
|
||||
/// prompt told the agent `/mission/repo`. An agent that obeyed the skill wrote
|
||||
/// into a directory nothing collects. Corrected 2026-08-19, and
|
||||
/// `skills_loader::contradiction_tests` now holds it.
|
||||
fn workspace_boundary(output: &str) -> Verdict {
|
||||
// Only paths that look like a REPO root the agent chose to work in. A
|
||||
// mention of /tmp is normal; `cd /workspace/repo` is not.
|
||||
const WRONG_ROOTS: &[&str] = &["/workspace/repo", "~/workspace/repo"];
|
||||
for root in WRONG_ROOTS {
|
||||
if output.contains(root) {
|
||||
fn workspace_boundary(ev: &Evidence<'_>) -> Verdict {
|
||||
// The recorded writes settle this directly. Before the tool tap the only
|
||||
// available evidence was the narrative, which answers a different question:
|
||||
// an agent that wrote to the wrong root without narrating it scored a
|
||||
// clean pass.
|
||||
let stray: Vec<&str> = ev
|
||||
.writes()
|
||||
.filter_map(|w| w.path.as_deref())
|
||||
.filter(|p| out_of_bounds(p))
|
||||
.collect();
|
||||
if let Some(first) = stray.first() {
|
||||
return Verdict::Fail(format!(
|
||||
"wrote to {first} ({} write(s) outside the checkout) — the mission \
|
||||
checkout is /mission/repo, so anything written there is never \
|
||||
collected and the phase delivers nothing",
|
||||
stray.len()
|
||||
));
|
||||
}
|
||||
|
||||
// `--force` is the skill's other hard prohibition, and unlike the path rule
|
||||
// it leaves no trace anywhere else: the push succeeds and the history it
|
||||
// overwrote is gone.
|
||||
if let Some(cmd) = ev.commands().find(|c| is_force_push(c)) {
|
||||
return Verdict::Fail(format!(
|
||||
"force-pushed ({cmd:?}) — the skill allows it only after an explicit \
|
||||
`HANDOFF: safe to force-push` from the reviewer, because rewriting \
|
||||
pushed history is the one thing here that cannot be undone"
|
||||
));
|
||||
}
|
||||
|
||||
// No violation seen. Whether that is a pass depends on whether there was
|
||||
// anything to see.
|
||||
if ev.writes().next().is_some() {
|
||||
return Verdict::Pass;
|
||||
}
|
||||
if ev.text.contains("/workspace/repo") {
|
||||
return Verdict::Fail(
|
||||
"the narrative describes working in /workspace/repo — the mission \
|
||||
checkout is /mission/repo, so anything written there is never \
|
||||
collected and the phase delivers nothing"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if ev.text.contains("/mission/repo") {
|
||||
return Verdict::Pass;
|
||||
}
|
||||
// Silence is not compliance. This returned `Pass` before, which is how a
|
||||
// mission with no evidence at all scored the same as one that was checked.
|
||||
Verdict::NotObservable(
|
||||
"no file-modifying tool call was recorded and the narrative names no \
|
||||
checkout — there is nothing here to place either inside or outside \
|
||||
/mission/repo"
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Is this write outside the mission checkout?
|
||||
///
|
||||
/// Relative paths are skipped: the working directory is not recorded, so
|
||||
/// `src/a.rs` could be either, and guessing would fail honest work.
|
||||
///
|
||||
/// The scratch roots are excluded because the skill itself blesses them —
|
||||
/// "anything else is scratch and will not be delivered" — and the hooks, the
|
||||
/// stop gate and claude's own state all live under `/root` by design.
|
||||
fn out_of_bounds(path: &str) -> bool {
|
||||
const SCRATCH: [&str; 5] = ["/tmp/", "/var/tmp/", "/root/", "/dev/", "/proc/"];
|
||||
path.starts_with('/')
|
||||
&& !path.starts_with("/mission/repo")
|
||||
&& !SCRATCH.iter().any(|s| path.starts_with(s))
|
||||
}
|
||||
|
||||
/// Matched on whole tokens, outside the commit message.
|
||||
///
|
||||
/// Both halves are load-bearing, and each was a live false positive before the
|
||||
/// negative control caught it:
|
||||
///
|
||||
/// - `cmd.contains("-f ")` fires on `--follow-tags`.
|
||||
/// - Tokenising the whole command fires on
|
||||
/// `git commit -m "document the -f flag" && git push`, where `-f` is prose.
|
||||
///
|
||||
/// A boundary check that fails honest work gets switched off, which costs more
|
||||
/// than the check was worth.
|
||||
fn is_force_push(cmd: &str) -> bool {
|
||||
const FORCE: [&str; 3] = ["--force", "-f", "--force-with-lease"];
|
||||
let stripped = match dash_m_value(cmd) {
|
||||
Some(msg) => cmd.replace(&msg, " "),
|
||||
None => cmd.to_string(),
|
||||
};
|
||||
let tokens = || stripped.split_whitespace();
|
||||
let pushes = stripped.contains("git") && tokens().any(|t| t == "push");
|
||||
pushes && tokens().any(|t| FORCE.contains(&t))
|
||||
}
|
||||
|
||||
/// The commit subjects the skill names as never-merge.
|
||||
///
|
||||
/// Matched exactly, after stripping an `INT-NN` prefix. A substring match would
|
||||
/// fail `fix the parser panic`, which is a perfectly good subject — the skill's
|
||||
/// objection is to `fix` as the WHOLE message, not to the word.
|
||||
fn commit_subject_boundary(ev: &Evidence<'_>) -> Verdict {
|
||||
const NEVER_MERGE: [&str; 7] = [
|
||||
"wip",
|
||||
"fix",
|
||||
"update",
|
||||
"updates",
|
||||
"update stuff",
|
||||
"stuff",
|
||||
"wip commit",
|
||||
];
|
||||
for subject in commit_subjects(ev) {
|
||||
let bare = strip_int_prefix(&subject).to_ascii_lowercase();
|
||||
let bare = bare.trim_matches(|c: char| !c.is_alphanumeric()).trim();
|
||||
if NEVER_MERGE.contains(&bare) {
|
||||
return Verdict::Fail(format!(
|
||||
"worked in {root} — the mission checkout is /mission/repo, so \
|
||||
anything written there is never collected and the phase \
|
||||
delivers nothing"
|
||||
"committed {subject:?} — the skill lists this exact message as \
|
||||
never-merge, because a reviewer and a bisect both need the \
|
||||
subject to stand alone in `git log --oneline`"
|
||||
));
|
||||
}
|
||||
}
|
||||
Verdict::Pass
|
||||
Verdict::NotApplicable
|
||||
}
|
||||
|
||||
fn strip_int_prefix(subject: &str) -> &str {
|
||||
let s = subject.trim().trim_start_matches(['<', '[', '(']);
|
||||
let Some(rest) = s.strip_prefix("INT-") else {
|
||||
return subject.trim();
|
||||
};
|
||||
let rest = rest.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||
rest.trim_start_matches(['>', ']', ')', ':', '-', '—', ' '])
|
||||
.trim()
|
||||
}
|
||||
|
||||
/// Did a test ever run, and did it run first?
|
||||
///
|
||||
/// One-sided by construction, because only one of the three answers is
|
||||
/// unambiguous:
|
||||
///
|
||||
/// - **Files written, no test command anywhere** — red-green did not happen.
|
||||
/// There is no reading of the loop under which it did.
|
||||
/// - **A test ran before the first write** — that is the RED step, observed.
|
||||
/// - **A test ran after the first write** — undecidable, and reported as such.
|
||||
/// In Rust the unit test lives in `#[cfg(test)] mod tests` *inside the file
|
||||
/// under test, so "wrote the file, then ran the test" is what writing the
|
||||
/// failing test first looks like from here. Scoring that as a failure would
|
||||
/// fail the agents who followed the skill most precisely.
|
||||
fn red_before_green(ev: &Evidence<'_>) -> Verdict {
|
||||
if ev.tools.is_empty() {
|
||||
return Verdict::NotObservable(
|
||||
"no tool calls were recorded for this mission — the order of test \
|
||||
runs and writes is what this check reads, and neither is in the \
|
||||
narrative"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let first_write = ev
|
||||
.tools
|
||||
.iter()
|
||||
.position(|t| WRITE_TOOLS.contains(&t.tool.as_str()) && t.path.is_some());
|
||||
let first_test = ev
|
||||
.tools
|
||||
.iter()
|
||||
.position(|t| t.command().is_some_and(is_test_command));
|
||||
match (first_write, first_test) {
|
||||
// Nothing was written, so there was no implementation to test-drive.
|
||||
(None, _) => Verdict::NotApplicable,
|
||||
(Some(_), None) => Verdict::Fail(format!(
|
||||
"wrote {} file(s) and never ran a test — the loop is red, green, \
|
||||
refactor, and a test that never ran cannot have been red",
|
||||
ev.writes().count()
|
||||
)),
|
||||
(Some(w), Some(t)) if t < w => Verdict::Pass,
|
||||
_ => Verdict::NotObservable(
|
||||
"a test ran, but after the first write — in Rust the unit test \
|
||||
lives in the file under test, so this ordering cannot separate \
|
||||
red-first from tests-added-after"
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that run a test suite, across the languages the skills name.
|
||||
fn is_test_command(cmd: &str) -> bool {
|
||||
const RUNNERS: [&str; 9] = [
|
||||
"cargo test",
|
||||
"cargo nextest",
|
||||
"cargo llvm-cov",
|
||||
"npm test",
|
||||
"yarn test",
|
||||
"pnpm test",
|
||||
"vitest",
|
||||
"pytest",
|
||||
"go test",
|
||||
];
|
||||
// `jest` on its own is too short to match safely — it appears inside
|
||||
// package names and paths — so it is matched as a command word.
|
||||
RUNNERS.iter().any(|r| cmd.contains(r))
|
||||
|| cmd.split_whitespace().any(|w| w == "jest" || w == "npx")
|
||||
&& cmd.contains("jest")
|
||||
}
|
||||
|
||||
/// The subject line of every commit the agent made.
|
||||
///
|
||||
/// Read out of the `-m` argument. A commit written with `-F` or a heredoc
|
||||
/// yields nothing, and the caller must treat an empty result as "no evidence"
|
||||
/// rather than "no commits" — which is why every commit check here returns
|
||||
/// `NotApplicable` on an empty list instead of a verdict.
|
||||
fn commit_subjects(ev: &Evidence<'_>) -> Vec<String> {
|
||||
ev.commands()
|
||||
.filter(|c| c.contains("git commit") || c.contains("git ci"))
|
||||
.filter_map(dash_m_value)
|
||||
.filter_map(|m| m.lines().next().map(|l| l.trim().to_string()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The value of the `-m` flag in a shell command.
|
||||
///
|
||||
/// Hand-scanned rather than split on whitespace: the value is the one argument
|
||||
/// guaranteed to contain spaces, and the commit template in
|
||||
/// `workspace-repo-commit-protocol` spans several lines.
|
||||
fn dash_m_value(cmd: &str) -> Option<String> {
|
||||
let b = cmd.as_bytes();
|
||||
for i in 0..b.len() {
|
||||
// Anchor on the `m`, not on `-m`: agents write `git commit -am …` and
|
||||
// anchoring on the dash misses the whole cluster form.
|
||||
if b[i] != b'm' {
|
||||
continue;
|
||||
}
|
||||
// `m` must END the cluster, so `--message` and `-mtime` are not it.
|
||||
if !matches!(
|
||||
b.get(i + 1),
|
||||
None | Some(b' ') | Some(b'\t') | Some(b'"') | Some(b'\'')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Walk back over the cluster's other short flags to its single dash.
|
||||
let mut j = i;
|
||||
while j > 0 && b[j - 1].is_ascii_alphabetic() {
|
||||
j -= 1;
|
||||
}
|
||||
if j == 0 || b[j - 1] != b'-' {
|
||||
continue;
|
||||
}
|
||||
let dash = j - 1;
|
||||
// One dash, starting a word — not `--m` and not the tail of a path.
|
||||
if dash > 0 && !b[dash - 1].is_ascii_whitespace() {
|
||||
continue;
|
||||
}
|
||||
let mut k = i + 1;
|
||||
while k < b.len() && b[k].is_ascii_whitespace() {
|
||||
k += 1;
|
||||
}
|
||||
if k >= b.len() {
|
||||
return None;
|
||||
}
|
||||
let rest = &cmd[k..];
|
||||
let quote = rest.chars().next().filter(|c| *c == '"' || *c == '\'');
|
||||
return match quote {
|
||||
Some(q) => rest[1..].split(q).next().map(str::to_string),
|
||||
None => rest.split_whitespace().next().map(str::to_string),
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `arxiv-daily` forbids searching arXiv — the harvest already ran.
|
||||
@@ -213,20 +536,36 @@ fn workspace_boundary(output: &str) -> Verdict {
|
||||
/// This is the one boundary we have watched an agent cross in production, so it
|
||||
/// is checked against the query endpoint rather than the word "arxiv", which
|
||||
/// appears legitimately all over a research turn.
|
||||
fn arxiv_boundary(output: &str) -> Verdict {
|
||||
fn arxiv_boundary(ev: &Evidence<'_>) -> Verdict {
|
||||
// The query endpoint, not the word "arxiv" — and not `arxiv.org/abs/`
|
||||
// either, which the same skill explicitly tells the agent to `curl`. A
|
||||
// check that could not tell those apart would fail agents for following
|
||||
// the paragraph directly under the one it is scoring.
|
||||
const QUERY_MARKERS: &[&str] = &[
|
||||
"export.arxiv.org/api/query",
|
||||
"arxiv.org/api/query",
|
||||
"http://export.arxiv.org",
|
||||
];
|
||||
for m in QUERY_MARKERS {
|
||||
if output.contains(m) {
|
||||
let hit = |s: &str| QUERY_MARKERS.iter().find(|m| s.contains(**m)).copied();
|
||||
|
||||
// Arguments first: a `curl` that ran is a fact, where a narrative that
|
||||
// mentions a URL may be the agent explaining that it did NOT fetch it.
|
||||
for t in ev.tools {
|
||||
let text = t.input.to_string();
|
||||
if let Some(m) = hit(&text) {
|
||||
return Verdict::Fail(format!(
|
||||
"queried the arXiv API ({m}) — the harvest already ran, and \
|
||||
shelving papers outside it corrupts the seen-set"
|
||||
"called {} against the arXiv API ({m}) — the harvest already \
|
||||
ran, and shelving papers outside it corrupts the seen-set",
|
||||
t.tool
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(m) = hit(ev.text) {
|
||||
return Verdict::Fail(format!(
|
||||
"queried the arXiv API ({m}) — the harvest already ran, and \
|
||||
shelving papers outside it corrupts the seen-set"
|
||||
));
|
||||
}
|
||||
Verdict::Pass
|
||||
}
|
||||
|
||||
@@ -261,6 +600,13 @@ pub async fn score_mission(
|
||||
}
|
||||
}
|
||||
|
||||
// What the agent DID, alongside what it said. A mission that ran before
|
||||
// the tool tap existed returns an empty list, and the checks degrade to
|
||||
// the narrative rather than reporting a violation they cannot see.
|
||||
let tools = crate::mission_events::tool_evidence_for_mission(pool, mission_id)
|
||||
.await
|
||||
.map_err(|e| format!("read tool evidence: {e}"))?;
|
||||
|
||||
// One lookup for every delivered name, so the report can separate
|
||||
// hand-authored skills from the ones agents wrote for themselves.
|
||||
let names = skills_in_prompt(&prompts);
|
||||
@@ -274,7 +620,7 @@ pub async fn score_mission(
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
Ok(score(&prompts, &outputs, &|name| {
|
||||
Ok(score(&prompts, &Evidence::new(&outputs, &tools), &|name| {
|
||||
kinds
|
||||
.get(name)
|
||||
.cloned()
|
||||
@@ -287,11 +633,35 @@ pub async fn score_mission(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mission_events::ToolEvidence;
|
||||
use serde_json::json;
|
||||
|
||||
fn builtin(_: &str) -> String {
|
||||
"builtin".to_string()
|
||||
}
|
||||
|
||||
/// Narrative-only evidence — a mission whose events predate the tool tap.
|
||||
fn narrative(text: &str) -> Evidence<'_> {
|
||||
Evidence::from_text(text)
|
||||
}
|
||||
|
||||
/// Evidence from recorded ACTIONS. `(tool, path, input)`.
|
||||
fn acted(calls: &[(&str, Option<&str>, serde_json::Value)]) -> Vec<ToolEvidence> {
|
||||
calls
|
||||
.iter()
|
||||
.map(|(tool, path, input)| ToolEvidence {
|
||||
tool: (*tool).to_string(),
|
||||
path: path.map(str::to_string),
|
||||
input: input.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One `Bash` call.
|
||||
fn ran(cmd: &str) -> (&'static str, Option<&'static str>, serde_json::Value) {
|
||||
("Bash", None, serde_json::json!({ "command": cmd }))
|
||||
}
|
||||
|
||||
/// A prompt exactly as the delivery layer renders it.
|
||||
fn rendered(skills: &[(&str, &str)]) -> String {
|
||||
let body: String = skills
|
||||
@@ -355,13 +725,13 @@ mod tests {
|
||||
#[test]
|
||||
fn a_prompt_with_no_skills_yields_no_scores() {
|
||||
assert!(skills_in_prompt("Task: do the thing").is_empty());
|
||||
assert!(score("Task: do the thing", "did it", &builtin).is_empty());
|
||||
assert!(score("Task: do the thing", &narrative("did it"), &builtin).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_is_reported_as_unobservable_not_as_a_failure() {
|
||||
let prompt = rendered(&[("arxiv-daily", "x")]);
|
||||
let scored = score(&prompt, "I read the manifest.", &builtin);
|
||||
let scored = score(&prompt, &narrative("I read the manifest."), &builtin);
|
||||
assert!(
|
||||
matches!(scored[0].trigger, Verdict::NotObservable(_)),
|
||||
"scoring Trigger zero would report a delivery-model property as an \
|
||||
@@ -373,7 +743,7 @@ mod tests {
|
||||
fn markers_the_real_parser_accepts_are_compliant() {
|
||||
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||
let good = "I implemented the parser.\nCOMPLETED: INT-07 — wire the loop";
|
||||
let scored = score(&prompt, good, &builtin);
|
||||
let scored = score(&prompt, &narrative(good), &builtin);
|
||||
assert_eq!(scored[0].compliance, Verdict::Pass);
|
||||
}
|
||||
|
||||
@@ -384,7 +754,7 @@ mod tests {
|
||||
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||
// Bold, which the skill explicitly forbids, and the parser will not take.
|
||||
let bad = "**COMPLETED: INT-07**";
|
||||
let scored = score(&prompt, bad, &builtin);
|
||||
let scored = score(&prompt, &narrative(bad), &builtin);
|
||||
assert!(
|
||||
matches!(scored[0].compliance, Verdict::Fail(_)),
|
||||
"a marker the parser rejects must score as a failure — that is the \
|
||||
@@ -393,7 +763,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// A turn that never tried is not a violation.
|
||||
let unrelated = score(&prompt, "I read three files and wrote a summary.", &builtin);
|
||||
let unrelated = score(&prompt, &narrative("I read three files and wrote a summary."), &builtin);
|
||||
assert_eq!(unrelated[0].compliance, Verdict::NotApplicable);
|
||||
}
|
||||
|
||||
@@ -417,7 +787,7 @@ mod tests {
|
||||
|
||||
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||
assert_eq!(
|
||||
score(&prompt, "PLAN_COMPLETE: INT-01", &builtin)[0].compliance,
|
||||
score(&prompt, &narrative("PLAN_COMPLETE: INT-01"), &builtin)[0].compliance,
|
||||
Verdict::Pass
|
||||
);
|
||||
}
|
||||
@@ -434,7 +804,7 @@ mod tests {
|
||||
|
||||
// And the agent is told, because the marker it emitted did nothing.
|
||||
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||
match &score(&prompt, "PLAN_COMPLETE: INT-01..02", &builtin)[0].compliance {
|
||||
match &score(&prompt, &narrative("PLAN_COMPLETE: INT-01..02"), &builtin)[0].compliance {
|
||||
Verdict::Fail(why) => assert!(why.contains("does not accept")),
|
||||
other => panic!("an ignored marker must not read as success; got {other:?}"),
|
||||
}
|
||||
@@ -443,7 +813,7 @@ mod tests {
|
||||
#[test]
|
||||
fn two_int_ids_on_one_marker_line_cross_the_boundary() {
|
||||
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||
let scored = score(&prompt, "COMPLETED: INT-05, INT-06", &builtin);
|
||||
let scored = score(&prompt, &narrative("COMPLETED: INT-05, INT-06"), &builtin);
|
||||
match &scored[0].boundary {
|
||||
Verdict::Fail(why) => assert!(why.contains("silently dropped")),
|
||||
other => panic!("the second id is silently dropped by the parser; got {other:?}"),
|
||||
@@ -456,7 +826,7 @@ mod tests {
|
||||
|
||||
let violating = score(
|
||||
&prompt,
|
||||
"curl 'http://export.arxiv.org/api/query?search_query=all:agents'",
|
||||
&narrative("curl 'http://export.arxiv.org/api/query?search_query=all:agents'"),
|
||||
&builtin,
|
||||
);
|
||||
assert!(matches!(violating[0].boundary, Verdict::Fail(_)));
|
||||
@@ -465,7 +835,7 @@ mod tests {
|
||||
// would make the metric fire constantly and mean nothing.
|
||||
let fine = score(
|
||||
&prompt,
|
||||
"I read the arXiv notes in the manifest and summarised three of them.",
|
||||
&narrative("I read the arXiv notes in the manifest and summarised three of them."),
|
||||
&builtin,
|
||||
);
|
||||
assert_eq!(fine[0].boundary, Verdict::Pass);
|
||||
@@ -478,7 +848,7 @@ mod tests {
|
||||
|
||||
let wrong = score(
|
||||
&prompt,
|
||||
"cd /workspace/repo && git add -A && git commit -m 'INT-01 done'",
|
||||
&narrative("cd /workspace/repo && git add -A && git commit -m 'INT-01 done'"),
|
||||
&builtin,
|
||||
);
|
||||
match &wrong[0].boundary {
|
||||
@@ -492,16 +862,203 @@ mod tests {
|
||||
|
||||
let right = score(
|
||||
&prompt,
|
||||
"cd /mission/repo && git add -A && git commit -m 'INT-01 done'",
|
||||
&narrative("cd /mission/repo && git add -A && git commit -m 'INT-01 done'"),
|
||||
&builtin,
|
||||
);
|
||||
assert_eq!(right[0].boundary, Verdict::Pass);
|
||||
}
|
||||
|
||||
/// The upgrade, stated as a test: the agent never said where it wrote.
|
||||
///
|
||||
/// Before the tool tap this scored a clean `Pass`, because the only
|
||||
/// evidence was the narrative and the narrative did not mention a path. The
|
||||
/// phase had still delivered nothing.
|
||||
#[test]
|
||||
fn a_write_outside_the_checkout_is_caught_even_when_the_narrative_is_silent() {
|
||||
let prompt = rendered(&[("workspace-repo-commit-protocol", "Work in /mission/repo.")]);
|
||||
let tools = acted(&[
|
||||
("Write", Some("/app/src/main.rs"), json!({"file_path": "/app/src/main.rs"})),
|
||||
]);
|
||||
let scored = score(&prompt, &Evidence::new("I implemented the feature.", &tools), &builtin);
|
||||
match &scored[0].boundary {
|
||||
Verdict::Fail(why) => assert!(why.contains("/app/src/main.rs"), "{why}"),
|
||||
other => panic!("the write is right there in the record; got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scratch is not a violation — the skill blesses it by name.
|
||||
#[test]
|
||||
fn scratch_and_the_checkout_both_stay_inside_the_boundary() {
|
||||
let prompt = rendered(&[("workspace-repo-commit-protocol", "Work in /mission/repo.")]);
|
||||
let tools = acted(&[
|
||||
("Write", Some("/tmp/scratch.txt"), json!({"file_path": "/tmp/scratch.txt"})),
|
||||
("Write", Some("/mission/repo/src/a.rs"), json!({"file_path": "/mission/repo/src/a.rs"})),
|
||||
]);
|
||||
let scored = score(&prompt, &Evidence::new("done", &tools), &builtin);
|
||||
assert_eq!(scored[0].boundary, Verdict::Pass);
|
||||
}
|
||||
|
||||
/// Nothing observed is not the same as nothing done.
|
||||
///
|
||||
/// This returned `Pass` before — a mission with no evidence scored
|
||||
/// identically to one that was checked and found clean, which is the exact
|
||||
/// collapse `Verdict::NotObservable` exists to prevent.
|
||||
#[test]
|
||||
fn silence_is_not_scored_as_compliance() {
|
||||
let prompt = rendered(&[("workspace-repo-commit-protocol", "Work in /mission/repo.")]);
|
||||
let scored = score(&prompt, &narrative("I thought about the problem."), &builtin);
|
||||
assert!(
|
||||
matches!(scored[0].boundary, Verdict::NotObservable(_)),
|
||||
"got {:?}",
|
||||
scored[0].boundary
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_force_push_crosses_the_boundary_and_a_quoted_flag_does_not() {
|
||||
let prompt = rendered(&[("workspace-repo-commit-protocol", "Work in /mission/repo.")]);
|
||||
|
||||
let forced = acted(&[
|
||||
("Write", Some("/mission/repo/a.rs"), json!({"file_path": "/mission/repo/a.rs"})),
|
||||
ran("cd /mission/repo && git push --force origin mission"),
|
||||
]);
|
||||
match &score(&prompt, &Evidence::new("", &forced), &builtin)[0].boundary {
|
||||
Verdict::Fail(why) => assert!(why.contains("force-push"), "{why}"),
|
||||
other => panic!("rewriting pushed history is the one unrecoverable act; got {other:?}"),
|
||||
}
|
||||
|
||||
// The flag inside a commit message rewrites nothing.
|
||||
let innocent = acted(&[
|
||||
("Write", Some("/mission/repo/a.rs"), json!({"file_path": "/mission/repo/a.rs"})),
|
||||
ran(r#"git commit -m "document the -f flag" && git push origin mission"#),
|
||||
]);
|
||||
assert_eq!(
|
||||
score(&prompt, &Evidence::new("", &innocent), &builtin)[0].boundary,
|
||||
Verdict::Pass
|
||||
);
|
||||
}
|
||||
|
||||
/// Writing code and never running a test cannot be red-green-refactor under
|
||||
/// any reading of the loop.
|
||||
#[test]
|
||||
fn code_written_with_no_test_run_fails_the_loop() {
|
||||
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
|
||||
let tools = acted(&[
|
||||
("Write", Some("/mission/repo/src/a.rs"), json!({"file_path": "/mission/repo/src/a.rs"})),
|
||||
ran("cargo build"),
|
||||
]);
|
||||
match &score(&prompt, &Evidence::new("Implemented it.", &tools), &builtin)[0].compliance {
|
||||
Verdict::Fail(why) => assert!(why.contains("never ran a test"), "{why}"),
|
||||
other => panic!("got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_test_run_before_the_first_write_is_red_first() {
|
||||
let prompt = rendered(&[("cargo-test-driven-development", "Red, green, refactor.")]);
|
||||
let tools = acted(&[
|
||||
ran("cargo nextest run parses_the_range_form"),
|
||||
("Write", Some("/mission/repo/src/a.rs"), json!({"file_path": "/mission/repo/src/a.rs"})),
|
||||
ran("cargo nextest run parses_the_range_form"),
|
||||
]);
|
||||
assert_eq!(
|
||||
score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance,
|
||||
Verdict::Pass
|
||||
);
|
||||
}
|
||||
|
||||
/// The ordering that cannot be read either way, reported as such.
|
||||
///
|
||||
/// A Rust unit test lives in `#[cfg(test)] mod tests` inside the file under
|
||||
/// test, so writing the failing test IS a write to the implementation file.
|
||||
/// Scoring this as a failure would fail the agents who followed the skill
|
||||
/// most precisely.
|
||||
#[test]
|
||||
fn a_test_run_after_the_first_write_is_undecidable_not_a_failure() {
|
||||
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
|
||||
let tools = acted(&[
|
||||
("Write", Some("/mission/repo/src/a.rs"), json!({"file_path": "/mission/repo/src/a.rs"})),
|
||||
ran("cargo test -p cm-api"),
|
||||
]);
|
||||
assert!(
|
||||
matches!(
|
||||
score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance,
|
||||
Verdict::NotObservable(_)
|
||||
),
|
||||
"the Rust unit test lives in the file under test — this ordering \
|
||||
cannot separate red-first from tests-added-after"
|
||||
);
|
||||
}
|
||||
|
||||
/// A phase that wrote nothing had no implementation to test-drive.
|
||||
#[test]
|
||||
fn a_phase_that_wrote_nothing_is_not_a_tdd_failure() {
|
||||
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
|
||||
let tools = acted(&[ran("ls /mission/repo"), ("Read", Some("/mission/repo/a.rs"), json!({}))]);
|
||||
assert_eq!(
|
||||
score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance,
|
||||
Verdict::NotApplicable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_never_merge_subjects_are_caught_and_a_real_one_is_not() {
|
||||
let prompt = rendered(&[("small-focused-commits", "One idea per commit.")]);
|
||||
|
||||
let bad = acted(&[ran(r#"git commit -m "wip""#)]);
|
||||
match &score(&prompt, &Evidence::new("", &bad), &builtin)[0].boundary {
|
||||
Verdict::Fail(why) => assert!(why.contains("never-merge"), "{why}"),
|
||||
other => panic!("got {other:?}"),
|
||||
}
|
||||
|
||||
// The objection is to `fix` as the whole message, not to the word.
|
||||
let good = acted(&[ran(r#"git commit -m "INT-04 fix the range form the parser rejects""#)]);
|
||||
assert_eq!(
|
||||
score(&prompt, &Evidence::new("", &good), &builtin)[0].boundary,
|
||||
Verdict::NotApplicable
|
||||
);
|
||||
}
|
||||
|
||||
/// The subject comes out of the shell command, in the shapes agents write.
|
||||
#[test]
|
||||
fn the_commit_subject_is_read_out_of_the_real_command_shapes() {
|
||||
assert_eq!(dash_m_value(r#"git commit -m "one line""#).as_deref(), Some("one line"));
|
||||
assert_eq!(dash_m_value("git commit -am 'clustered flag'").as_deref(), Some("clustered flag"));
|
||||
// The template in `workspace-repo-commit-protocol` spans several lines;
|
||||
// only the first is the subject.
|
||||
let multi = "cd /mission/repo && git commit -m \"INT-01 wire the loop\n\nWHY: it stalled.\n\nRefs: INT-01\n\"";
|
||||
assert_eq!(
|
||||
commit_subjects(&Evidence::new("", &acted(&[ran(multi)]))),
|
||||
vec!["INT-01 wire the loop"]
|
||||
);
|
||||
// `-F` gives up nothing, and that must read as no evidence.
|
||||
assert!(dash_m_value("git commit -F /tmp/msg").is_none());
|
||||
}
|
||||
|
||||
/// The skill tells the agent to `curl` the abstract page in the paragraph
|
||||
/// under the one that forbids the query API. A check that cannot tell them
|
||||
/// apart fails agents for obeying the skill.
|
||||
#[test]
|
||||
fn curling_an_abstract_is_allowed_where_querying_the_api_is_not() {
|
||||
let prompt = rendered(&[("arxiv-daily", "The harvest already ran.")]);
|
||||
|
||||
let allowed = acted(&[ran("curl -s https://arxiv.org/abs/2401.12345")]);
|
||||
assert_eq!(
|
||||
score(&prompt, &Evidence::new("", &allowed), &builtin)[0].boundary,
|
||||
Verdict::Pass
|
||||
);
|
||||
|
||||
let forbidden = acted(&[ran("curl -s 'http://export.arxiv.org/api/query?search_query=all:agents'")]);
|
||||
match &score(&prompt, &Evidence::new("", &forbidden), &builtin)[0].boundary {
|
||||
Verdict::Fail(why) => assert!(why.contains("seen-set"), "{why}"),
|
||||
other => panic!("got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_of_a_skill_is_carried_into_its_score() {
|
||||
let prompt = rendered(&[("self-made", "x")]);
|
||||
let scored = score(&prompt, "done", &|_| "promoted_from_brain".to_string());
|
||||
let scored = score(&prompt, &narrative("done"), &|_| "promoted_from_brain".to_string());
|
||||
assert_eq!(
|
||||
scored[0].source_kind, "promoted_from_brain",
|
||||
"an agent that writes its own skill can raise its own score against \
|
||||
|
||||
Reference in New Issue
Block a user