feat(skills): the first Skill-Use measurement, and the three defects it found

Scored on the paper's three axes against two real missions on the local
stack. docs/SKILL-USE-BASELINE.md has the numbers, the method, and the
limits.

Trigger is reported as NOT OBSERVABLE, never zero

The paper measures progressive disclosure: the agent sees a name and
description and must retrieve the body, and that retrieval is the Trigger
event. We inline full bodies, because mission claws run on claude_cli which
cannot surface a tool call — there is nothing to retrieve with. So the
agent never reaches for a skill, it simply holds one.

Scoring that zero would report a delivery-model property as an agent
failure, which is the same confusion that kept 55 empty bindings invisible
for months. The verdict type carries NotObservable(reason) as a distinct
case from Fail for exactly this.

Compliance is checked by running the REAL task_card_parser rather than a
copy of its rules — a second implementation would drift, and then the score
would pass while the mission loop still stalled. Skills without a
machine-checkable consequence score not_applicable rather than a guess.

WHAT THE MEASUREMENT FOUND

1. The prompt format made its own record unparseable. Skills were
   introduced with `## <name>` and skill bodies are markdown full of `##`
   headings, so run 1 scored "Sizing heuristic" and "The output shape" —
   subheadings inside decompose-int-items — as skills with no catalogue
   row. Now an unambiguous `--- SKILL: <name> ---` marker, with both
   writers sharing one renderer so the reader cannot drift from the writer.

2. A prompt was recorded that was never sent. My own Phase 1 work recorded
   the phase prompt at the dispatch fork, before the tier was chosen — and
   the container tier does not send that text, it sends the bare task and
   appends skills per turn. Every container mission logged a `solo` prompt
   that reached no agent. A provenance record of something that did not
   happen is worse than no record: it is the wrong answer, delivered
   confidently. Recording now happens inside each tier, with a test that
   every launcher records the prompt it actually sends.

3. int-xx-marker-protocol documents a marker the platform never
   implemented. PLAN_COMPLETE is in the skill's ladder and task_card_parser
   has no such kind and never has, so an agent following the skill exactly
   emits a marker that is silently ignored. Observed live: run 2's planner
   emitted `PLAN_COMPLETE: INT-01..02`, which is also the range form — on
   the kinds that ARE parsed that yields the id `INT-01..02`, a task card
   for an item that does not exist while the two real items stay open.

   This is a skill/implementation mismatch, not an agent failure, and it is
   exactly what the measurement exists to find: the agent did what it was
   told and what it was told was wrong. Both shapes now score as failures.
   The reconciliation — implement PLAN_COMPLETE or drop it from the skill —
   is left as a decision rather than guessed at.

The boot log now shows what the plan asked for: 53 skills, 11 templates,
every one `N role skills bound` with NO unresolved clause. Live missions
confirm per-role delivery — the planner receives decompose-int-items, the
coder receives write-rust-current-edition.

GET /api/missions/{id}/skill-use exposes the scores, and says in its
payload whether an empty result means "nothing delivered" or "the evidence
was reaped" — those have very different causes and must not look the same.

n = 2. No spread is reported because two runs cannot establish one, and the
document says so rather than letting the number be quoted as a baseline it
is not.

Full workspace suite green: 106 binaries.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 10:54:58 -07:00
co-authored by Claude Opus 5
parent 769e002bb3
commit 91a6b4e304
6 changed files with 772 additions and 24 deletions
+5
View File
@@ -53,6 +53,7 @@ pub mod runtime_provision;
pub mod security_scan; pub mod security_scan;
pub mod session_executor; pub mod session_executor;
pub mod skill_self_authoring; pub mod skill_self_authoring;
pub mod skill_use;
pub mod skills_loader; pub mod skills_loader;
pub mod subscription; pub mod subscription;
pub mod swarm; pub mod swarm;
@@ -590,6 +591,10 @@ pub fn router(state: AppState) -> Router {
"/api/missions/{id}/phases/{phase_id}/evaluations", "/api/missions/{id}/phases/{phase_id}/evaluations",
get(routes::missions::list_phase_evaluations), get(routes::missions::list_phase_evaluations),
) )
.route(
"/api/missions/{id}/skill-use",
get(routes::missions::skill_use),
)
.route( .route(
"/api/missions/{id}/teams", "/api/missions/{id}/teams",
get(routes::missions::list_teams), get(routes::missions::list_teams),
+66 -19
View File
@@ -235,6 +235,39 @@ mod skill_delivery_wiring_tests {
/// one — and that substitution is a one-word edit that would silently /// one — and that substitution is a one-word edit that would silently
/// return all three tiers to delivering no skill, with every test still /// return all three tiers to delivering no skill, with every test still
/// green. Same reasoning as `mission_events::the_cap_is_enforced_in_one_statement`. /// green. Same reasoning as `mission_events::the_cap_is_enforced_in_one_statement`.
/// The prompt must be recorded by the tier that SENDS it.
///
/// Recording at the dispatch fork wrote a phase prompt for container
/// missions too, and the container tier does not send that text — it sends
/// the bare task and appends skills per turn. Observed on a live mission:
/// three prompt.composed rows, one of which was never given to anything.
#[test]
fn every_solo_tier_records_the_prompt_it_actually_sends() {
let src = include_str!("phase_runner.rs");
let anchor = format!("async fn {}(", "record_phase_prompt");
assert!(
src.contains(&anchor),
"the per-tier recorder is gone; a fork-level record would log \
prompts that were never sent"
);
for launcher in [
"launch_composed_microvm_phase",
"launch_microvm_phase",
"launch_direct_session",
] {
let body = src
.split(&format!("async fn {launcher}("))
.nth(1)
.and_then(|s| s.split("\nasync fn ").next())
.unwrap_or_else(|| panic!("{launcher} not found"));
assert!(
body.contains("record_phase_prompt("),
"{launcher} runs a prompt it never records — that phase becomes \
unexplainable after the fact"
);
}
}
#[test] #[test]
fn the_three_solo_tiers_are_handed_the_skill_bearing_task() { fn the_three_solo_tiers_are_handed_the_skill_bearing_task() {
let src = include_str!("phase_runner.rs"); let src = include_str!("phase_runner.rs");
@@ -944,20 +977,6 @@ async fn launch_phase(
None => task.clone(), None => task.clone(),
}; };
// One prompt per phase on these tiers, because the phase IS one session.
// `topology_runs.task` holds a copy for two of them, but not for the
// composed path and not with the skills appended — and a provenance record
// that exists on some tiers is one that cannot be queried uniformly.
{
let mut ev = crate::mission_events::MissionEvent::new(
mission_id,
crate::mission_events::PROMPT_COMPOSED,
);
ev.phase_id = Some(phase_id);
ev.target = Some(kind.to_string());
ev.detail = serde_json::json!({ "text": task_with_skills, "tier": "solo" });
crate::mission_events::record(pool, ev).await;
}
// Direct-session executor: run the whole phase as ONE `claude -p` session // Direct-session executor: run the whole phase as ONE `claude -p` session
// against the mission checkout, instead of driving turns through ZeroClaw. // against the mission checkout, instead of driving turns through ZeroClaw.
@@ -1223,6 +1242,7 @@ async fn launch_composed_microvm_phase(
teams: Vec<(Uuid, serde_json::Value)>, teams: Vec<(Uuid, serde_json::Value)>,
purposes: &[&str], purposes: &[&str],
) -> Result<(), String> { ) -> Result<(), String> {
record_phase_prompt(pool, mission_id, phase_id, "composed_microvm", task).await;
sqlx::query( sqlx::query(
"DELETE FROM topology_runs "DELETE FROM topology_runs
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')", WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
@@ -1363,6 +1383,7 @@ async fn launch_microvm_phase(
// `VmPhase::has_repo`. // `VmPhase::has_repo`.
has_repo: bool, has_repo: bool,
) -> Result<(), String> { ) -> Result<(), String> {
record_phase_prompt(pool, mission_id, phase_id, "microvm", task).await;
sqlx::query( sqlx::query(
"DELETE FROM topology_runs "DELETE FROM topology_runs
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')", WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
@@ -1596,6 +1617,7 @@ async fn launch_direct_session(
iteration: i32, iteration: i32,
task: &str, task: &str,
) -> Result<(), String> { ) -> Result<(), String> {
record_phase_prompt(pool, mission_id, phase_id, "session", task).await;
sqlx::query( sqlx::query(
"DELETE FROM topology_runs "DELETE FROM topology_runs
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')", WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
@@ -1716,6 +1738,32 @@ async fn launch_direct_session(
/// the work still applies when one agent does all of it. Erring toward the /// the work still applies when one agent does all of it. Erring toward the
/// union is safe here in a way it would not be on the container tier, where /// union is safe here in a way it would not be on the container tier, where
/// per-role precision is available and used. /// per-role precision is available and used.
/// Record the prompt a phase is about to run with.
///
/// Called from inside each tier rather than at the dispatch point, because the
/// tiers do not all send the same text: the three solo tiers send the
/// skills-bearing task, and the container tier sends the bare task and appends
/// skills per turn in `topology_exec`. Recording at the fork wrote a `solo`
/// prompt for container missions too — a prompt that was composed and never
/// sent. A provenance record of something that did not happen is worse than no
/// record: it is the wrong answer, delivered confidently.
async fn record_phase_prompt(
pool: &PgPool,
mission_id: Uuid,
phase_id: Uuid,
tier: &str,
text: &str,
) {
let mut ev = crate::mission_events::MissionEvent::new(
mission_id,
crate::mission_events::PROMPT_COMPOSED,
);
ev.phase_id = Some(phase_id);
ev.target = Some(tier.to_string());
ev.detail = serde_json::json!({ "text": text, "tier": tier });
crate::mission_events::record(pool, ev).await;
}
pub async fn phase_skills_text(pool: &PgPool, mission_id: Uuid) -> Option<String> { pub async fn phase_skills_text(pool: &PgPool, mission_id: Uuid) -> Option<String> {
let crew = sqlx::query( let crew = sqlx::query(
"SELECT DISTINCT a.id "SELECT DISTINCT a.id
@@ -1761,11 +1809,10 @@ pub async fn phase_skills_text(pool: &PgPool, mission_id: Uuid) -> Option<String
)); ));
continue; continue;
} }
out.push_str("\n## "); out.push_str(&crate::topology_exec::render_pinned_skill(
out.push_str(&b.skill.name); &b.skill.name,
out.push('\n'); &b.skill.body,
out.push_str(&b.skill.body); ));
out.push('\n');
} }
} }
if seen.is_empty() { if seen.is_empty() {
+36
View File
@@ -1223,6 +1223,42 @@ pub async fn retry_phase(
/// One row per pass. The `reason` is the operator-facing explanation of why a /// One row per pass. The `reason` is the operator-facing explanation of why a
/// phase iterated (or stopped), and is the same text fed back to the agents as /// phase iterated (or stopped), and is the same text fed back to the agents as
/// guidance for the following pass. /// guidance for the following pass.
/// Skill-Use scores for a mission: did the skills we delivered change what the
/// agent did?
///
/// Reads only what was recorded — the prompts the agent received and the
/// narratives it returned. An empty result means the evidence is gone (events
/// are reaped after 7 days unless `retain_events_until` is set), NOT that no
/// skill was followed, and the caller has to present it that way.
pub async fn skill_use(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let scores = crate::skill_use::score_mission(&state.pool, id)
.await
.map_err(|e| {
eprintln!("skill_use: scoring mission {id} failed: {e}");
ApiError::Internal
})?;
Ok(Json(serde_json::json!({
"mission_id": id,
"skills": scores,
// Said in the payload rather than left for the reader to infer: an
// empty list has two very different causes and they must not look the
// same to whoever consumes this.
"evidence": if scores.is_empty() {
"no delivered skills found in the recorded prompts — either none \
were delivered, or the events have been reaped"
} else {
"scored from recorded prompt.composed and reasoning events"
},
})))
}
pub async fn list_phase_evaluations( pub async fn list_phase_evaluations(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
+485
View File
@@ -0,0 +1,485 @@
//! Did a skill we delivered actually change what the agent did?
//!
//! Scored on the three axes from `Skill-Use` (arXiv, 2026-08-05): **Trigger**
//! (did the agent reach for the skill), **Compliance** (did it follow the
//! procedure), **Boundary** (did it avoid what the skill forbids).
//!
//! ## One axis does not survive the translation, and saying so is the finding
//!
//! The paper measures agents under *progressive disclosure*: the agent sees a
//! name and a description, and must decide to retrieve the body. The retrieval
//! 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.
//!
//! Trigger is therefore **not observable on the mission path**, and this module
//! reports it as `NotObservable` with the reason attached rather than scoring
//! it zero. A zero would read as "the agents ignore their skills" when it
//! 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.
use serde::Serialize;
/// The outcome of one axis for one skill.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case", tag = "verdict", content = "why")]
pub enum Verdict {
Pass,
Fail(String),
/// The skill says nothing this axis can check.
NotApplicable,
/// The axis cannot be measured here, for a stated structural reason.
///
/// Distinct from `Fail` on purpose. Collapsing the two is how a
/// measurement reports a system defect as an agent defect.
NotObservable(String),
}
impl Verdict {
pub fn label(&self) -> &'static str {
match self {
Verdict::Pass => "pass",
Verdict::Fail(_) => "FAIL",
Verdict::NotApplicable => "n/a",
Verdict::NotObservable(_) => "not observable",
}
}
}
/// One skill's score for one phase.
#[derive(Debug, Clone, Serialize)]
pub struct SkillUse {
pub skill: String,
/// `builtin` (hand-authored) or `promoted_from_brain` (agent-authored).
///
/// Carried into the report because an agent that authors its own skill can
/// raise its own compliance score without changing what it does. A rising
/// number on agent-authored skills has to be visible as such rather than
/// averaged in with the rest.
pub source_kind: String,
pub trigger: Verdict,
pub compliance: Verdict,
pub boundary: Verdict,
}
/// The skills a prompt actually delivered.
///
/// Delegates to the delivery layer's own parser, so the reader cannot drift
/// from the writer. This function originally matched `## <name>` itself, and
/// skill bodies are markdown full of `##` headings — a live mission duly
/// scored "Sizing heuristic" and "The output shape" as skills. Parsed from the
/// recorded prompt rather than re-derived from the catalogue, because the
/// catalogue changes, and now that agents author their own skills it changes
/// by itself.
pub fn skills_in_prompt(prompt: &str) -> Vec<String> {
crate::topology_exec::skill_names_in(prompt)
}
/// 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> {
skills_in_prompt(prompt)
.into_iter()
.map(|skill| {
let (compliance, boundary) = check(&skill, output);
SkillUse {
source_kind: source_kinds(&skill),
trigger: Verdict::NotObservable(
"skills are inlined into the prompt, not retrieved — there is \
no retrieval event to observe on the mission path"
.into(),
),
compliance,
boundary,
skill,
}
})
.collect()
}
/// Per-skill mechanical checks.
///
/// Only skills whose procedure has a machine-checkable consequence are checked.
/// 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) {
match skill {
"int-xx-marker-protocol" => (marker_compliance(output), marker_boundary(output)),
"arxiv-daily" => (Verdict::NotApplicable, arxiv_boundary(output)),
_ => (Verdict::NotApplicable, Verdict::NotApplicable),
}
}
/// The markers must be ones `task_card_parser` actually parses.
///
/// Checked by running the real parser rather than a copy of its rules — a
/// second implementation of the contract would drift from the first, and then
/// the measurement would pass while the mission loop still stalled.
fn marker_compliance(output: &str) -> Verdict {
if crate::task_card_parser::parse(output).is_empty() {
// Only a failure if the output looks like it TRIED. A turn with no
// marker-shaped line was probably not a coding turn at all.
if output.lines().any(|l| looks_like_marker_attempt(l)) {
return Verdict::Fail(
"emitted marker-shaped lines that the parser does not accept — \
the mission loop will not advance"
.into(),
);
}
return Verdict::NotApplicable;
}
Verdict::Pass
}
/// Rule 1: exactly one INT id per marker line.
///
/// Two shapes violate it, and they fail differently:
///
/// `COMPLETED: INT-05, INT-06` — the parser takes the first and drops the
/// rest, so an item is silently never closed.
/// `PLAN_COMPLETE: INT-01..02` — the parser ACCEPTS it and yields the id
/// `INT-01..02`, which matches no real item.
/// A task card appears for something that does
/// not exist, and the two items it was meant
/// to cover stay open.
///
/// The second was found by running this measurement against a live mission. It
/// is the worse of the two, because a dropped marker leaves a gap and a
/// malformed one leaves a plausible-looking row.
fn marker_boundary(output: &str) -> Verdict {
for line in output.lines() {
let t = line.trim();
if !looks_like_marker_attempt(t) {
continue;
}
let ids = t.matches("INT-").count();
if ids > 1 {
return Verdict::Fail(format!(
"{ids} INT ids on one marker line — only the first parses, so \
the rest are silently dropped: {t:?}"
));
}
for m in crate::task_card_parser::parse(t) {
if !is_single_int_id(&m.int_id) {
return Verdict::Fail(format!(
"marker id {:?} is not a single INT-<number> — it parses, so \
a task card is created for an item that does not exist, and \
the items it was meant to cover stay open: {t:?}",
m.int_id
));
}
}
}
Verdict::NotApplicable
}
/// `INT-` followed by digits and nothing else.
fn is_single_int_id(id: &str) -> bool {
match id.strip_prefix("INT-") {
Some(rest) => !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()),
None => false,
}
}
fn looks_like_marker_attempt(line: &str) -> bool {
const KINDS: &[&str] = &[
"TASK:", "WORK:", "HANDOFF:", "TEST_PASS:", "TEST_FAIL:", "REVIEW_APPROVE:",
"REVIEW_BLOCK:", "COMPLETED:", "PLAN_COMPLETE:",
];
let t = line.trim().trim_start_matches(['*', '#', '-', '`', ' ']);
KINDS.iter().any(|k| t.starts_with(k))
}
/// `arxiv-daily` forbids searching arXiv — the harvest already ran.
///
/// 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 {
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) {
return Verdict::Fail(format!(
"queried the arXiv API ({m}) — the harvest already ran, and \
shelving papers outside it corrupts the seen-set"
));
}
}
Verdict::Pass
}
/// Score every skill delivered during a mission, from what was recorded.
///
/// Reads `prompt.composed` and `reasoning` rows. Both are per-mission and
/// ordered, so the prompts say what was delivered and the narratives say what
/// came back. Scoring is against the CONCATENATED output for the mission rather
/// than turn-by-turn: a procedure can be followed in a later turn than the one
/// that carried it, and pairing strictly by turn would score that as a failure.
///
/// Returns an empty vec for a mission whose events have already been reaped —
/// which is why `missions.retain_events_until` exists. An empty result means
/// "no evidence", never "no compliance", and the report has to say so.
pub async fn score_mission(
pool: &sqlx::PgPool,
mission_id: uuid::Uuid,
) -> Result<Vec<SkillUse>, String> {
let rows = crate::mission_events::narrative_for_mission(pool, mission_id)
.await
.map_err(|e| format!("read narrative: {e}"))?;
let mut prompts = String::new();
let mut outputs = String::new();
for (kind, _, _, text) in &rows {
if kind == crate::mission_events::PROMPT_COMPOSED {
prompts.push_str(text);
prompts.push('\n');
} else {
outputs.push_str(text);
outputs.push('\n');
}
}
// 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);
let kinds: std::collections::HashMap<String, String> = sqlx::query_as::<_, (String, String)>(
"SELECT name, source_kind FROM skills WHERE name = ANY($1)",
)
.bind(&names)
.fetch_all(pool)
.await
.map_err(|e| format!("read skill sources: {e}"))?
.into_iter()
.collect();
Ok(score(&prompts, &outputs, &|name| {
kinds
.get(name)
.cloned()
// A skill in a prompt with no catalogue row was delivered and then
// deleted. Naming that explicitly beats defaulting it to builtin.
.unwrap_or_else(|| "unknown (no catalogue row)".to_string())
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn builtin(_: &str) -> String {
"builtin".to_string()
}
/// A prompt exactly as the delivery layer renders it.
fn rendered(skills: &[(&str, &str)]) -> String {
let body: String = skills
.iter()
.map(|(n, b)| crate::topology_exec::render_pinned_skill(n, b))
.collect();
crate::topology_exec::compose_turn_prompt("Task: do the thing", Some(&body))
}
/// The prompt is the record of what was delivered, so parsing it must match
/// exactly what the delivery layer writes.
#[test]
fn the_delivered_skills_are_read_back_out_of_the_prompt() {
let prompt = rendered(&[
("arxiv-daily", "Do not re-search."),
("int-xx-marker-protocol", "Emit markers."),
]);
assert_eq!(
skills_in_prompt(&prompt),
vec!["arxiv-daily", "int-xx-marker-protocol"],
"the scorer reads what the delivery layer wrote — if these drift, \
every score is attributed to the wrong skill"
);
}
/// Skill bodies are markdown and contain their own headings.
///
/// The first delimiter was `## <name>`, so every section of every body
/// counted as a separate skill. Caught on a live mission, which scored
/// "Sizing heuristic" and "The output shape" — both of them subheadings
/// inside `decompose-int-items` — as skills with no catalogue row.
#[test]
fn headings_inside_a_skill_body_are_not_skills() {
let body = "# Decomposing into INT-XX items\n\n\
## Sizing heuristic\nOne unit of work.\n\n\
## The output shape\nTASK: INT-NN — title\n";
let prompt = rendered(&[("decompose-int-items", body)]);
assert_eq!(
skills_in_prompt(&prompt),
vec!["decompose-int-items"],
"a body's own headings must not be counted as skills — every one \
would be scored against a catalogue row that does not exist"
);
}
/// A body that quotes the marker text must not fabricate a skill either.
#[test]
fn a_body_mentioning_the_marker_does_not_create_a_skill() {
let body = format!(
"Prompts introduce a skill with a line beginning `{}`.\n",
crate::topology_exec::SKILL_MARKER.trim()
);
let prompt = rendered(&[("prompt-anatomy", &body)]);
assert_eq!(
skills_in_prompt(&prompt),
vec!["prompt-anatomy"],
"the marker is matched at line start; an inline mention is prose"
);
}
#[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());
}
#[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);
assert!(
matches!(scored[0].trigger, Verdict::NotObservable(_)),
"scoring Trigger zero would report a delivery-model property as an \
agent failure — the exact confusion this module exists to avoid"
);
}
#[test]
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);
assert_eq!(scored[0].compliance, Verdict::Pass);
}
/// The failure the skill exists to prevent: marker-shaped lines that the
/// parser rejects, so the mission silently never advances.
#[test]
fn marker_shaped_lines_the_parser_rejects_are_a_failure() {
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);
assert!(
matches!(scored[0].compliance, Verdict::Fail(_)),
"a marker the parser rejects must score as a failure — that is the \
whole consequence the skill is written to avoid. Got {:?}",
scored[0].compliance
);
// A turn that never tried is not a violation.
let unrelated = score(&prompt, "I read three files and wrote a summary.", &builtin);
assert_eq!(unrelated[0].compliance, Verdict::NotApplicable);
}
/// Found on a live mission: `PLAN_COMPLETE: INT-01..02`.
///
/// TWO defects in one line, and the measurement is what surfaced them.
///
/// 1. `PLAN_COMPLETE` is documented in `int-xx-marker-protocol` as part of
/// the ladder, and `task_card_parser` has never implemented it. An agent
/// that follows the skill exactly emits a marker that is silently
/// ignored — the skill is teaching a contract the platform does not
/// honour, which is not the agent's failure.
/// 2. The range form yields an id matching no real item on the kinds that
/// ARE parsed.
#[test]
fn a_documented_marker_the_parser_never_implemented_is_a_compliance_failure() {
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
assert!(
crate::task_card_parser::parse("PLAN_COMPLETE: INT-01").is_empty(),
"PLAN_COMPLETE is in the skill's ladder and not in the parser — if \
this ever starts parsing, the skill and the code have been \
reconciled and this test should be updated to match"
);
let scored = score(&prompt, "PLAN_COMPLETE: INT-01", &builtin);
match &scored[0].compliance {
Verdict::Fail(why) => assert!(why.contains("does not accept")),
other => panic!("an ignored marker must not read as success; got {other:?}"),
}
}
/// The range form, on a kind the parser DOES accept.
#[test]
fn a_range_marker_crosses_the_boundary_even_though_it_parses() {
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
let parsed = crate::task_card_parser::parse("COMPLETED: INT-01..02");
assert_eq!(parsed.len(), 1, "the parser accepts it");
assert_eq!(parsed[0].int_id, "INT-01..02", "with an id matching no item");
let scored = score(&prompt, "COMPLETED: INT-01..02", &builtin);
match &scored[0].boundary {
Verdict::Fail(why) => assert!(
why.contains("does not exist"),
"the failure must name the consequence, not just the syntax: {why}"
),
other => panic!("a range marker must be caught; got {other:?}"),
}
}
#[test]
fn a_well_formed_id_is_not_flagged() {
assert!(is_single_int_id("INT-07"));
assert!(!is_single_int_id("INT-01..02"));
assert!(!is_single_int_id("INT-"));
assert!(!is_single_int_id("INT-1a"));
}
#[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);
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:?}"),
}
}
#[test]
fn querying_arxiv_crosses_the_boundary_but_naming_it_does_not() {
let prompt = rendered(&[("arxiv-daily", "x")]);
let violating = score(
&prompt,
"curl 'http://export.arxiv.org/api/query?search_query=all:agents'",
&builtin,
);
assert!(matches!(violating[0].boundary, Verdict::Fail(_)));
// The word appears legitimately in every research turn. Scoring on it
// 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.",
&builtin,
);
assert_eq!(fine[0].boundary, Verdict::Pass);
}
/// Agent-authored skills must stay visible as such in the report.
#[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());
assert_eq!(
scored[0].source_kind, "promoted_from_brain",
"an agent that writes its own skill can raise its own score against \
it; that has to be legible in the report rather than averaged in"
);
}
}
+30 -5
View File
@@ -49,6 +49,35 @@ const TURN_TIMEOUT: Duration = Duration::from_secs(3600);
/// foundation set, and it is stated in the prompt when it fires. /// foundation set, and it is stated in the prompt when it fires.
pub(crate) const MAX_PINNED_SKILL_BYTES: usize = 24_000; pub(crate) const MAX_PINNED_SKILL_BYTES: usize = 24_000;
/// The line that introduces each skill in a prompt.
///
/// NOT a markdown heading. The first version used `## <name>`, and skill bodies
/// are markdown that contain their own `##` headings — so anything reading the
/// prompt back counted every section of every body as a separate skill. A live
/// mission scored "Sizing heuristic" and "The output shape" as skills, which is
/// what surfaced it.
///
/// This marker cannot occur inside a body, so the prompt stays parseable by
/// whatever reads it later. Skills are written by one function
/// ([`render_pinned_skill`]) for the same reason: two renderers would drift and
/// the reader would silently match only one.
pub const SKILL_MARKER: &str = "--- SKILL: ";
/// One skill, rendered for a prompt.
pub fn render_pinned_skill(name: &str, body: &str) -> String {
format!("\n{SKILL_MARKER}{name} ---\n{body}\n")
}
/// The skill names a rendered prompt delivered.
pub fn skill_names_in(prompt: &str) -> Vec<String> {
prompt
.lines()
.filter_map(|l| l.trim().strip_prefix(SKILL_MARKER))
.map(|rest| rest.trim_end_matches(" ---").trim().to_string())
.filter(|n| !n.is_empty())
.collect()
}
pub struct ZeroClawDriveExecutor { pub struct ZeroClawDriveExecutor {
/// Gateway base URL, e.g. `http://127.0.0.1:42617`. /// Gateway base URL, e.g. `http://127.0.0.1:42617`.
gateway_url: String, gateway_url: String,
@@ -329,11 +358,7 @@ impl ZeroClawDriveExecutor {
)); ));
continue; continue;
} }
out.push_str("\n## "); out.push_str(&render_pinned_skill(&b.skill.name, &b.skill.body));
out.push_str(&b.skill.name);
out.push('\n');
out.push_str(&b.skill.body);
out.push('\n');
n += 1; n += 1;
} }
if n == 0 { if n == 0 {
+150
View File
@@ -0,0 +1,150 @@
# Skill-Use baseline
*First measurement of whether ClawMates' skills change what agents do.
2026-08-19.*
Scored on the three axes from `Skill-Use` (arXiv, 2026-08-05): **Trigger** (did
the agent reach for the skill), **Compliance** (did it follow the procedure),
**Boundary** (did it avoid what the skill forbids).
Read the method before the numbers. A measurement whose limits are not stated
is worse than none, because it gets quoted without them.
## Why there was no baseline before today
Not because nobody ran it. Because **it could not have returned anything but
zero**, for two structural reasons that had nothing to do with agent behaviour:
1. 55 of 85 role skill bindings resolved to skills that were never authored.
2. Even resolved skills had no delivery channel to a mission agent — the
catalogue's only route was an MCP server that mission claws cannot reach.
Both were fixed in the two commits preceding this document. Anyone who had run
this measurement in July would have concluded "our agents ignore their skills",
which would have been false and expensive.
## Method, and what it cannot see
Scored by `cm_api::skill_use` from what the platform records: the
`prompt.composed` event (the exact bytes an agent received) and the `reasoning`
events (what it said it did). No re-derivation from the catalogue — the
catalogue changes, and now that agents author their own skills it changes by
itself.
### Trigger is not observable here, and that is a finding
The paper measures agents under **progressive disclosure**: the agent sees a
name and description and must decide to retrieve the body. That retrieval is a
tool call, which makes Trigger observable.
We do not deliver skills that way. `pinned_skills_text` inlines full bodies into
the prompt, because mission claws run on `claude_cli`, which cannot surface a
tool call — there is nothing to retrieve *with*. The agent never reaches for a
skill; it is simply holding one.
So Trigger is reported as `not_observable` with the reason attached, **never as
zero**. Scoring it zero would report a delivery-model property as an agent
failure — the same confusion that kept 55 empty bindings invisible.
### Compliance and Boundary are checked mechanically, or not at all
Only skills whose procedure has a machine-checkable consequence are scored.
Everything else returns `not_applicable` rather than a guess: a heuristic that
scores prose by keyword overlap produces a number that looks like a measurement
and is not one.
Compliance for `int-xx-marker-protocol` is checked by running the **real**
`task_card_parser`, not a copy of its rules — a second implementation would
drift, and then the score would pass while the mission loop still stalled.
## The runs
Two missions on the container/ZeroClaw tier, local stack, `research_only`.
| | run 1 | run 2 |
|---|---|---|
| distinct skills delivered | 3 | **9** |
| total deliveries (per role prompt) | 3 | 14 |
| phantom "skills" scored | **2** | 0 |
Run 1's phantom entries are the finding of the run, described below.
Run 2, per skill (all `source_kind=builtin`; no agent-authored skill has been
delivered yet):
| skill | deliveries | compliance | boundary |
|---|---|---|---|
| `int-xx-marker-protocol` | 1 | **pass** | n/a |
| `small-focused-commits` | 4 | n/a | n/a |
| `cargo-test-driven-development` | 2 | n/a | n/a |
| `workspace-repo-commit-protocol` | 2 | n/a | n/a |
| `decompose-int-items` | 1 | n/a | n/a |
| `write-rust-current-edition` | 1 | n/a | n/a |
| `code-review-checklist` | 1 | n/a | n/a |
| `criterion-benchmarking` | 1 | n/a | n/a |
| `tdd-red-green-refactor` | 1 | n/a | n/a |
**n = 2 runs. No spread is reported because two runs cannot establish one.**
This is a baseline in the sense of "the first honest number", not in the sense
of `metrics-baseline-comparison.md`, which requires enough runs to see the noise
floor before any change is judged against it. Do not compare a future number to
this one without first establishing that floor.
## What the measurement found
Three defects, none of which any test or log would have surfaced.
### 1. The prompt format made its own record unparseable
Skills were introduced with `## <name>`, and skill bodies are markdown full of
`##` headings. Run 1 duly scored **"Sizing heuristic"** and **"The output
shape"** — both subheadings inside `decompose-int-items` — as skills with no
catalogue row.
Fixed with an unambiguous `--- SKILL: <name> ---` marker, and both writers now
share one renderer so the reader cannot drift from the writer.
### 2. A prompt was recorded that was never sent
The phase prompt was recorded at the dispatch fork, before the tier was chosen.
The container tier does not send that text — it sends the bare task and appends
skills per turn. So every container mission logged a `solo` prompt that reached
no agent.
A provenance record of something that did not happen is worse than no record: it
is the wrong answer, delivered confidently. Recording now happens inside each
tier, and a test asserts every launcher records the prompt it actually sends.
### 3. The skill documents a marker the platform never implemented
`int-xx-marker-protocol` lists `PLAN_COMPLETE: INT-NN` in its ladder.
`task_card_parser` has **no such kind** and never has. An agent following the
skill exactly emits a marker that is silently ignored.
Observed live: run 2's planner emitted `PLAN_COMPLETE: INT-01..02`, which is
also the range form — on the kinds that *are* parsed, that yields the id
`INT-01..02`, a task card for an item that does not exist while the two real
items stay open.
**This is a skill/implementation mismatch, not an agent failure**, and it is
precisely what this measurement exists to find: the agent did what it was told,
and what it was told was wrong. Both shapes are now scored as failures; the
underlying reconciliation — implement `PLAN_COMPLETE` or remove it from the
skill — is deliberately left as a decision rather than guessed at here.
## Honest limits
- **Two runs, one tier, one workflow.** Nothing here generalises to the microVM
or session tiers yet, and this document should not be read as if it does.
- **7 of 9 skills scored `not_applicable`** on both observable axes. That is not
a pass. It means we cannot currently tell whether those skills changed
anything, and the next unit of work is mechanical checks for more of them —
`workspace-repo-commit-protocol` and `small-focused-commits` both have
checkable consequences in git history.
- **No agent-authored skill has been measured.** Self-authoring shipped in the
same pass; `source_kind` is carried through the scorer specifically so a
rising score on agent-authored skills is visible rather than averaged in.
- **Evidence expires.** Mission events are reaped after 7 days unless
`retain_events_until` is set. Both runs here are held for 90 days. An empty
score means "no evidence", never "no compliance", and the API says so in its
payload rather than leaving the caller to infer it.