Three phases of the approved plan, plus a correction to what the last one
claimed.
CORRECTION: skills reached ONE tier, not all of them
The previous commit said "skills can now reach a mission agent". That was
true only for the container/ZeroClaw tier — the fall-through that queues a
topology_runs row for topology_worker, which drives the executor that was
patched. compose_turn_prompt/pinned_skills_text had exactly one production
caller, and phase_runner's three other paths (composed microVM, solo
microVM, direct session) never called it. CAPABILITY-REVIEW.md said the
broad thing too; both are corrected.
Those three tiers share one task string and have no per-turn alias, so
their skills resolve per PHASE from the mission's crew and are appended
there. The container tier deliberately still injects per turn, with the
running node's own role — appending in both places would put every crew
member's skills in every turn twice.
The behavioural tests prove phase_skills_text and compose_turn_prompt work.
They cannot prove the three launch_* calls pass the composed string, and
that substitution is a one-word edit that would silently return all three
tiers to delivering nothing with every test still green. So there is also a
source-level assertion on the call sites, following the precedent in
mission_events::the_cap_is_enforced_in_one_statement. Its negative control
names the exact tier.
PROVENANCE: what an agent received, and what it said it did
Both were unanswerable. The prompt was never stored anywhere on any tier —
re-deriving it later re-runs the skill lookup against a catalogue that has
since changed, and once agents author their own skills it certainly will
have. The reasoning rows were durably write-only: pushed live once, then
never read from the database again by anything except the GC that deletes
them.
- prompt.composed records the exact bytes, on all four tiers
- the session tier writes its checkpoint record and a reasoning row,
instead of eprintln! and nothing — the same defect the solo microVM
path was fixed for, in the last tier that still had it
- narrative_for_mission reads both back
Found while doing it: the 400-event per-phase cap counted EVERY kind, so a
busy phase could push out its own phase.completed and its own provenance.
The cap now counts only the two unbounded kinds it was written for.
Negative control confirms the old behaviour dropped the prompt.
Retention is now a per-mission hold (0080) rather than a raised global —
with a test asserting unheld missions are still reaped, because an
exemption that applies to everything is not an exemption.
SELF-AUTHORING: agents apply their own skill drafts, no human click
By operator decision. level_up has generated complete drafts from a model
since it shipped; only a checkbox stood between propose and apply.
What replaces the gate is not another gate but four properties, each held
by a test:
- workspace-scoped, so a hand-authored skill can never be modified
- a draft cannot take a hand-authored skill's name. Ids are scoped and
bindings resolve by skill_id, so it could not overwrite or shadow one
anyway — but two procedures under one name means nobody reading a
transcript can tell which the agent followed, and that ambiguity is
fatal in a system where the skill is the standard being graded against
- every revision appends a skill_versions row, so it can be reverted and
a past run can be read against the text it was actually judged under
- approved_by = NULL. An agent's decision is never attributed to a person
who did not make it
Only skill_candidate applies autonomously. identity_refinement and
brain_consolidation still wait for a human: they change what an agent IS
rather than adding a procedure it can consult. State is announced at boot,
because a safety gate that changes silently is one nobody notices changed.
CLAWMATES_SKILL_SELF_AUTHORING=0 restores it.
Also: the test Postgres ran out of /dev/shm mid-suite (Docker's 64MB
default) and surfaced it during MIGRATIONS, which reads like a schema fault
and is not one. --shm-size=1g, and a pointer to the `clean` subcommand that
already existed for the 779 leaked test databases.
Full workspace suite green: 106 binaries, no failures.
Co-Authored-By: Claude Opus 5 <[email protected]>
320 lines
11 KiB
Rust
320 lines
11 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::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 {
|
|
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: Uuid::now_v7(),
|
|
phase_id: None,
|
|
run_id: None,
|
|
})
|
|
}
|
|
|
|
#[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 skill BODY must be present, not just its name — there is no \
|
|
`skills.read` tool on the mission path, so an index would name a \
|
|
procedure the agent has no way to fetch. 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."));
|
|
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);
|
|
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));
|
|
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"
|
|
);
|
|
}
|