Trigger — did the agent reach for the skill when it applied? — cannot be measured while every body is inlined into the prompt. Nothing was reached for. `skill_use` has been reporting `NotObservable` for that reason, and it was right to. The skills door made retrieval possible; this makes it a delivery arm. `index` sends each pinned skill's name, description, `when_to_use` and the uri that returns its body, and the agent fetches what it judges relevant. `inline` is unchanged and stays the default. An A/B rather than a switch, because `index` can only cost Compliance: under `inline` the procedure sits in front of the model whether or not it noticed it applied. Trading a measured axis for an unmeasured regression in another is not an improvement, so both arms stay runnable and the arm is recorded on the mission row. Three things the mechanism refuses to do: - `index` without a door falls back to `inline`. An index names bodies and says how to fetch them; with no `clawmates_skills` server reachable that is a list of dead ends, and it fails as an agent ignoring its skills rather than as a missing config. `install_skills_door` now returns whether it installed, because the caller needs the answer and not just the log line. - The scorer reads the arm off the recorded PROMPT, not off the mission row. The row says what the mission is configured to do now; the score is being computed against a turn that ran then. - Under `index`, a skill that was offered and never read is a Fail, not the inline arm's `NotObservable` — but only where the skill had a checkable consequence in that phase. Reusing the inline text would have said "this skill was inlined into the prompt" about a skill whose body was never sent, and scoring a real miss as a structural blind spot is the failure this measurement already made once. The arm is per mission (`config.skill_delivery`), not only per deployment. Both arms run against one server process; restarting between them would put a confound in the comparison that the numbers would not show. 829 tests, 108 binaries, green. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
410 lines
15 KiB
Rust
410 lines
15 KiB
Rust
//! Do a mission agent's pinned skills actually reach its prompt?
|
|
//!
|
|
//! Before this test the honest answer was no, for every skill and every role.
|
|
//! The catalogue's only delivery channel was the `clawmates_skills` MCP server,
|
|
//! and a mission claw could not reach it: `provision_claw` wrote a constant
|
|
//! bundle list, the runtime config defines no such bundle, and mission claws
|
|
//! run on `claude_cli`, which is text-only and cannot surface a tool call.
|
|
//!
|
|
//! So the skills were authored, bound, listed in the boot log as bound — and
|
|
//! structurally unreadable. That is why this is a test and not a comment: the
|
|
//! failure produced no error anywhere, and every layer reported success.
|
|
|
|
use cm_api::skill_delivery::Mode;
|
|
use cm_api::topology_exec::{MissionTap, ZeroClawDriveExecutor};
|
|
use cm_domain::{Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId};
|
|
|
|
/// `agents.managed_by` is a real FK, so the owner has to exist.
|
|
async fn seed_user(pool: &sqlx::PgPool, ws: WorkspaceId) -> UserId {
|
|
let user = User {
|
|
id: UserId::new(),
|
|
workspace_id: ws,
|
|
email: format!("owner-{}@example.com", Uuid::now_v7().simple()),
|
|
role: Role::Owner,
|
|
display_name: "Owner".into(),
|
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
};
|
|
cm_db::repo::users::insert(pool, &user).await.unwrap();
|
|
user.id
|
|
}
|
|
use std::collections::HashMap;
|
|
use uuid::Uuid;
|
|
|
|
/// A claw with one template-bound, pinned skill. Returns its runtime alias.
|
|
async fn seed_claw_with_pinned_skill(pool: &sqlx::PgPool, body: &str) -> (String, WorkspaceId) {
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Skill Delivery Test".into(),
|
|
plan: "team".into(),
|
|
};
|
|
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
|
|
|
|
let user = seed_user(pool, ws.id).await;
|
|
let agent = Agent {
|
|
id: AgentId::new(),
|
|
workspace_id: ws.id,
|
|
name: "Scout".into(),
|
|
job_title: "researcher".into(),
|
|
system_prompt: String::new(),
|
|
avatar: String::new(),
|
|
accent: String::new(),
|
|
wallpaper: String::new(),
|
|
managed_by: user,
|
|
status: AgentStatus::Online,
|
|
};
|
|
cm_db::repo::agents::insert(pool, &agent, &cm_domain::AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
|
|
// A template with one role, and a skill pinned to it.
|
|
let template_id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO team_templates (id, key, name, description, category, stack,
|
|
default_topology, risk_profile, mcp_bundles, version)
|
|
VALUES ($1, $2, 'Delivery Test', 'test', 'research', '{}',
|
|
'pipeline', 'research_readonly', '{}', 1)",
|
|
)
|
|
.bind(template_id)
|
|
.bind(format!("delivery_test_{}", template_id.simple()))
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO template_roles (template_id, slot, order_idx, system_prompt)
|
|
VALUES ($1, 'researcher', 0, 'you research')",
|
|
)
|
|
.bind(template_id)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let skill_id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO skills
|
|
(id, workspace_id, name, title, author, description, when_to_use,
|
|
tags, source_kind, current_version, body)
|
|
VALUES ($1, NULL, $2, $2, 'system',
|
|
'a procedure the agent must follow', 'always', '{}',
|
|
'builtin', 1, $3)",
|
|
)
|
|
.bind(skill_id)
|
|
.bind(format!("delivery-test-skill-{}", skill_id.simple()))
|
|
.bind(body)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO template_role_skills (template_id, slot, skill_id, pin_in_context, order_idx)
|
|
VALUES ($1, 'researcher', $2, true, 0)",
|
|
)
|
|
.bind(template_id)
|
|
.bind(skill_id)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
cm_db::repo::agent_template_link::upsert(
|
|
pool,
|
|
agent.id.as_uuid(),
|
|
template_id,
|
|
1,
|
|
"researcher",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
(
|
|
cm_api::runtime_provision::claw_alias(agent.id.as_uuid()),
|
|
ws.id,
|
|
)
|
|
}
|
|
|
|
fn executor(pool: &sqlx::PgPool, workspace_id: WorkspaceId) -> ZeroClawDriveExecutor {
|
|
executor_for(pool, workspace_id, Uuid::now_v7())
|
|
}
|
|
|
|
fn executor_for(
|
|
pool: &sqlx::PgPool,
|
|
workspace_id: WorkspaceId,
|
|
mission_id: Uuid,
|
|
) -> ZeroClawDriveExecutor {
|
|
ZeroClawDriveExecutor::new(
|
|
"http://127.0.0.1:1".into(),
|
|
"unused".into(),
|
|
HashMap::new(),
|
|
"default".into(),
|
|
)
|
|
.with_tap(MissionTap {
|
|
pool: pool.clone(),
|
|
workspace_id: workspace_id.as_uuid(),
|
|
mission_id,
|
|
phase_id: None,
|
|
run_id: None,
|
|
})
|
|
}
|
|
|
|
/// A mission row carrying an explicit delivery arm.
|
|
async fn seed_mission_on_arm(pool: &sqlx::PgPool, ws: WorkspaceId, arm: Option<&str>) -> Uuid {
|
|
let mission = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO missions (id, workspace_id, title, template_kind, status, skill_delivery)
|
|
VALUES ($1, $2, 'arm', 'research_only', 'running', $3)",
|
|
)
|
|
.bind(mission)
|
|
.bind(ws.as_uuid())
|
|
.bind(arm)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
mission
|
|
}
|
|
|
|
// ── The index arm ───────────────────────────────────────────────────
|
|
//
|
|
// Trigger — did the agent reach for the skill? — cannot exist while every body
|
|
// is handed over unasked. These tests cover the arm that makes it a question,
|
|
// and the one guarantee the control arm needs: that it did not change.
|
|
|
|
#[tokio::test]
|
|
async fn the_index_arm_sends_the_uri_and_withholds_the_body() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
const MARKER: &str = "Never review a paper from its title alone.";
|
|
let (alias, ws) = seed_claw_with_pinned_skill(&pool, MARKER).await;
|
|
let mission = seed_mission_on_arm(&pool, ws, Some("index")).await;
|
|
|
|
let text = executor_for(&pool, ws, mission)
|
|
.pinned_skills_text(&alias)
|
|
.await
|
|
.expect("an indexed skill is still delivered — as an entry, not a body");
|
|
|
|
assert!(
|
|
!text.contains(MARKER),
|
|
"the BODY is what the index withholds; leaving it in delivers both \
|
|
arms at once and measures neither. Got:\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("uri=\"skill:global/delivery-test-skill-"),
|
|
"an entry without a fetchable uri names a procedure the agent cannot \
|
|
obtain — worse than inlining it. Got:\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("When to use: always"),
|
|
"`when_to_use` is the only thing the agent can judge relevance from, \
|
|
and judging relevance is the entire axis. Got:\n{text}"
|
|
);
|
|
// The scorer counts delivered skills by the marker, in both arms.
|
|
assert_eq!(
|
|
cm_api::topology_exec::skill_names_in(&text).len(),
|
|
1,
|
|
"an indexed skill must still count as delivered:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unrecorded_arm_delivers_bodies() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
const MARKER: &str = "Never review a paper from its title alone.";
|
|
let (alias, ws) = seed_claw_with_pinned_skill(&pool, MARKER).await;
|
|
|
|
// Every mission that ran before the column existed, plus any row whose
|
|
// value is unreadable, plus a turn with no mission row at all. All three
|
|
// resolve to the arm that needs nothing installed to work.
|
|
for arm in [None, Some("nonsense")] {
|
|
let mission = seed_mission_on_arm(&pool, ws, arm).await;
|
|
let text = executor_for(&pool, ws, mission)
|
|
.pinned_skills_text(&alias)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
text.contains(MARKER),
|
|
"skill_delivery={arm:?} must deliver the body — an index arm \
|
|
selected by accident hands out uris behind a door that may not \
|
|
be installed. Got:\n{text}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_pinned_skill_body_reaches_the_turn_prompt() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
const MARKER: &str = "Never review a paper from its title alone.";
|
|
let (alias, ws) = seed_claw_with_pinned_skill(&pool, MARKER).await;
|
|
|
|
let text = executor(&pool, ws)
|
|
.pinned_skills_text(&alias)
|
|
.await
|
|
.expect("a claw with a pinned template skill must produce skill text");
|
|
|
|
assert!(
|
|
text.contains(MARKER),
|
|
"the default arm delivers the BODY. It is the control in the delivery \
|
|
A/B, so it has to stay what production has always sent; the index arm \
|
|
is selected per mission and tested separately. Got:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_agent_with_no_pinned_skills_adds_nothing() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "No Skills".into(),
|
|
plan: "team".into(),
|
|
};
|
|
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
|
let user = seed_user(&pool, ws.id).await;
|
|
let agent = Agent {
|
|
id: AgentId::new(),
|
|
workspace_id: ws.id,
|
|
name: "Bare".into(),
|
|
job_title: "researcher".into(),
|
|
system_prompt: String::new(),
|
|
avatar: String::new(),
|
|
accent: String::new(),
|
|
wallpaper: String::new(),
|
|
managed_by: user,
|
|
status: AgentStatus::Online,
|
|
};
|
|
cm_db::repo::agents::insert(&pool, &agent, &cm_domain::AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
|
|
let alias = cm_api::runtime_provision::claw_alias(agent.id.as_uuid());
|
|
assert!(
|
|
executor(&pool, ws.id)
|
|
.pinned_skills_text(&alias)
|
|
.await
|
|
.is_none(),
|
|
"an agent with no bound skills must add no section at all — an empty \
|
|
`# Your skills` heading tells the model it has skills and then shows \
|
|
it none"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_composed_prompt_carries_the_skill_and_omits_the_heading_when_empty() {
|
|
let base = "You are the \"researcher\" agent.\n\nTask: read the papers";
|
|
|
|
let with = cm_api::topology_exec::compose_turn_prompt(base, Some("## arxiv-daily\nDo not re-search."), Mode::Inline);
|
|
assert!(with.contains("Task: read the papers"), "the base turn must survive");
|
|
assert!(with.contains("Do not re-search."), "the skill body must be in the prompt");
|
|
assert!(with.contains("# Your skills"), "the section needs a heading");
|
|
|
|
for empty in [None, Some(""), Some(" \n ")] {
|
|
let without = cm_api::topology_exec::compose_turn_prompt(base, empty, Mode::Inline);
|
|
assert_eq!(
|
|
without, base,
|
|
"with no skills the prompt must be byte-identical to the base — an \
|
|
empty heading announces skills the agent does not have"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── The other three tiers ───────────────────────────────────────────
|
|
//
|
|
// `topology_exec` injects per TURN and covers only the container tier. The
|
|
// composed-microVM, solo-microVM and direct-session paths in `phase_runner`
|
|
// share one task string built by `phase_task_text`, and until now that string
|
|
// carried no skill at all — so a mission on any of those tiers ran with the
|
|
// catalogue unreachable, exactly as the container tier did before e4942ce.
|
|
|
|
/// Bind a claw to a mission's crew so `phase_skills_text` can find it.
|
|
async fn seed_mission_with_crew(pool: &sqlx::PgPool, ws: WorkspaceId, agent: AgentId) -> Uuid {
|
|
let mission = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO missions (id, workspace_id, title, template_kind, status)
|
|
VALUES ($1, $2, 'skill delivery', 'research_only', 'running')",
|
|
)
|
|
.bind(mission)
|
|
.bind(ws.as_uuid())
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let team = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO teams (id, workspace_id, name, kind, lifecycle, graph)
|
|
VALUES ($1, $2, 'crew', 'pipeline', 'permanent', '{}'::jsonb)",
|
|
)
|
|
.bind(team)
|
|
.bind(ws.as_uuid())
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("INSERT INTO team_members (team_id, claw_id, node_id, role) VALUES ($1, $2, 'researcher', 'researcher')")
|
|
.bind(team)
|
|
.bind(agent.as_uuid())
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("INSERT INTO mission_teams (mission_id, team_id, purpose) VALUES ($1, $2, 'mission')")
|
|
.bind(mission)
|
|
.bind(team)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
mission
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_microvm_and_session_tiers_get_the_skill_in_their_task_text() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
const MARKER: &str = "Never review a paper from its title alone.";
|
|
let (alias, ws) = seed_claw_with_pinned_skill(&pool, MARKER).await;
|
|
let agent = AgentId::from(cm_api::runtime_provision::claw_from_alias(&alias).unwrap());
|
|
let mission = seed_mission_with_crew(&pool, ws, agent).await;
|
|
|
|
let skills = cm_api::phase_runner::phase_skills_text(&pool, mission)
|
|
.await
|
|
.expect("a mission whose crew holds a pinned skill must produce skill text");
|
|
assert!(
|
|
skills.contains(MARKER),
|
|
"the pinned BODY must reach the phase task — these tiers run one \
|
|
`claude -p` session with no per-turn injection, so this string is the \
|
|
agent's only route to the procedure. Got:\n{skills}"
|
|
);
|
|
|
|
// All three tiers share this composition, so testing it once covers them.
|
|
let composed = cm_api::topology_exec::compose_turn_prompt("Task: read the papers", Some(&skills), Mode::Inline);
|
|
assert!(composed.contains(MARKER));
|
|
assert!(composed.contains("Task: read the papers"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_mission_whose_crew_has_no_skills_adds_nothing() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Bare Crew".into(),
|
|
plan: "team".into(),
|
|
};
|
|
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
|
let user = seed_user(&pool, ws.id).await;
|
|
let agent = Agent {
|
|
id: AgentId::new(),
|
|
workspace_id: ws.id,
|
|
name: "Bare".into(),
|
|
job_title: "researcher".into(),
|
|
system_prompt: String::new(),
|
|
avatar: String::new(),
|
|
accent: String::new(),
|
|
wallpaper: String::new(),
|
|
managed_by: user,
|
|
status: AgentStatus::Online,
|
|
};
|
|
cm_db::repo::agents::insert(&pool, &agent, &cm_domain::AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
let mission = seed_mission_with_crew(&pool, ws.id, agent.id).await;
|
|
|
|
assert!(
|
|
cm_api::phase_runner::phase_skills_text(&pool, mission)
|
|
.await
|
|
.is_none(),
|
|
"a crew with no pinned skills must add no section — the empty-heading \
|
|
rule has to hold on this path too"
|
|
);
|
|
}
|