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]>
256 lines
9.5 KiB
Rust
256 lines
9.5 KiB
Rust
//! Agents author their own skills, with no human in the loop.
|
|
//!
|
|
//! The operator's decision. The machinery already existed — `level_up` has
|
|
//! generated full skill drafts from a model since it shipped — and the only
|
|
//! thing between propose and apply was an operator ticking a checkbox.
|
|
//!
|
|
//! What replaces that checkbox is not another gate but three properties, and
|
|
//! these tests are what hold them: the write is workspace-scoped and can never
|
|
//! take a hand-authored skill's name, every change appends a version so it can
|
|
//! be read back and reverted, and a proposal applied with no human carries no
|
|
//! human's name in its approval trail.
|
|
|
|
use cm_domain::{Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId};
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
|
|
/// A workspace with one agent. `level_up_target_one` requires a proposal to
|
|
/// name exactly one of agent_id / team_id, so the agent is not optional here.
|
|
async fn seed_workspace(pool: &sqlx::PgPool) -> (WorkspaceId, AgentId) {
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Self Authoring".into(),
|
|
plan: "team".into(),
|
|
};
|
|
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
|
|
|
|
let user = User {
|
|
id: UserId::new(),
|
|
workspace_id: ws.id,
|
|
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();
|
|
|
|
let agent = Agent {
|
|
id: AgentId::new(),
|
|
workspace_id: ws.id,
|
|
name: "Scribe".into(),
|
|
job_title: "researcher".into(),
|
|
system_prompt: String::new(),
|
|
avatar: String::new(),
|
|
accent: String::new(),
|
|
wallpaper: String::new(),
|
|
managed_by: user.id,
|
|
status: AgentStatus::Online,
|
|
};
|
|
cm_db::repo::agents::insert(pool, &agent, &cm_domain::AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
(ws.id, agent.id)
|
|
}
|
|
|
|
/// A pending proposal carrying one `skill_candidate` draft.
|
|
async fn seed_proposal(
|
|
pool: &sqlx::PgPool,
|
|
ws: WorkspaceId,
|
|
agent: AgentId,
|
|
name: &str,
|
|
body: &str,
|
|
) -> Uuid {
|
|
let payload = json!({
|
|
"suggested_items": [{
|
|
"id": "item-1",
|
|
"kind": "skill_candidate",
|
|
"draft": {
|
|
"name": name,
|
|
"description": "a procedure the agent wrote for itself",
|
|
"when_to_use": "when the situation arises",
|
|
"body": body,
|
|
"tags": ["self-authored"],
|
|
}
|
|
}]
|
|
});
|
|
cm_db::repo::level_up::insert(
|
|
pool,
|
|
cm_db::repo::level_up::NewProposal {
|
|
workspace_id: ws.as_uuid(),
|
|
agent_id: Some(agent.as_uuid()),
|
|
team_id: None,
|
|
payload: &payload,
|
|
model: Some("glm:glm-4.7"),
|
|
created_by: None,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_agent_applies_its_own_skill_with_no_human_and_it_is_versioned() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (ws, agent) = seed_workspace(&pool).await;
|
|
|
|
let p1 = seed_proposal(&pool, ws, agent, "vault-note-shape", "First: write the date line.").await;
|
|
let applied = cm_api::level_up::apply_autonomous(&pool, ws, p1)
|
|
.await
|
|
.expect("autonomous apply must succeed");
|
|
assert_eq!(applied, vec!["item-1".to_string()]);
|
|
|
|
let (id, source_kind, workspace, version): (Uuid, String, Option<Uuid>, i32) = sqlx::query_as(
|
|
"SELECT id, source_kind, workspace_id, current_version FROM skills WHERE name = $1",
|
|
)
|
|
.bind("vault-note-shape")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("the skill must exist with no human approval");
|
|
assert_eq!(source_kind, "promoted_from_brain", "self-authored skills must stay distinguishable from builtins in one query");
|
|
assert_eq!(workspace, Some(ws.as_uuid()), "must be workspace-scoped, never global");
|
|
assert_eq!(version, 1);
|
|
|
|
// The approval trail must not name a human who did not approve.
|
|
let approved_by: Option<Uuid> =
|
|
sqlx::query_scalar("SELECT approved_by FROM level_up_proposals WHERE id = $1")
|
|
.bind(p1)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
approved_by.is_none(),
|
|
"an autonomously applied proposal must record NO approver — putting a \
|
|
user id here would attribute a decision to someone who never made it"
|
|
);
|
|
|
|
// A revision bumps the version and keeps the old body readable.
|
|
let p2 = seed_proposal(&pool, ws, agent, "vault-note-shape", "First: write the date line. Then the source.").await;
|
|
cm_api::level_up::apply_autonomous(&pool, ws, p2).await.unwrap();
|
|
|
|
let versions: Vec<(i32, String)> =
|
|
sqlx::query_as("SELECT version, body_md FROM skill_versions WHERE skill_id = $1 ORDER BY version")
|
|
.bind(id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
versions.len(),
|
|
2,
|
|
"each self-authored revision must append a version — without history \
|
|
there is no revert, and no way to read back which text a past run was \
|
|
actually judged under"
|
|
);
|
|
assert_eq!(versions[0].1, "First: write the date line.");
|
|
assert!(versions[1].1.contains("Then the source."));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_draft_cannot_take_a_hand_authored_skills_name() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (ws, agent) = seed_workspace(&pool).await;
|
|
|
|
// A builtin, as `skills_loader` writes them: global, workspace_id NULL.
|
|
let builtin = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO skills
|
|
(id, name, title, author, description, when_to_use, tags,
|
|
source_kind, workspace_id, current_version, body)
|
|
VALUES ($1,'arxiv-daily','arxiv-daily','system','the real one','always',
|
|
'{}','builtin',NULL,1,'Do NOT re-search arXiv.')",
|
|
)
|
|
.bind(builtin)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let p = seed_proposal(&pool, ws, agent, "arxiv-daily", "Actually, re-searching arXiv is fine.").await;
|
|
let applied = cm_api::level_up::apply_autonomous(&pool, ws, p).await.unwrap();
|
|
assert!(
|
|
applied.is_empty(),
|
|
"a draft taking a hand-authored name must be refused: two procedures \
|
|
under one name means nobody reading a transcript can tell which the \
|
|
agent followed — and this one inverts the rule it shadows"
|
|
);
|
|
|
|
let body: String = sqlx::query_scalar("SELECT body FROM skills WHERE id = $1")
|
|
.bind(builtin)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
body, "Do NOT re-search arXiv.",
|
|
"the hand-authored skill must be untouched"
|
|
);
|
|
let n: i64 = sqlx::query_scalar("SELECT count(*) FROM skills WHERE name = 'arxiv-daily'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(n, 1, "no second row may exist under that name");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn autonomous_apply_leaves_identity_and_memory_items_for_a_human() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (ws, agent) = seed_workspace(&pool).await;
|
|
|
|
let payload = json!({
|
|
"suggested_items": [
|
|
{ "id": "skill-1", "kind": "skill_candidate",
|
|
"draft": { "name": "commit-message-shape", "description": "d",
|
|
"body": "Say what changed and why.", "tags": [] } },
|
|
{ "id": "identity-1", "kind": "identity_refinement",
|
|
"new_system_prompt": "You are now a different agent." }
|
|
]
|
|
});
|
|
let p = cm_db::repo::level_up::insert(
|
|
&pool,
|
|
cm_db::repo::level_up::NewProposal {
|
|
workspace_id: ws.as_uuid(),
|
|
agent_id: Some(agent.as_uuid()),
|
|
team_id: None,
|
|
payload: &payload,
|
|
model: Some("glm:glm-4.7"),
|
|
created_by: None,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let applied = cm_api::level_up::apply_autonomous(&pool, ws, p).await.unwrap();
|
|
assert_eq!(
|
|
applied,
|
|
vec!["skill-1".to_string()],
|
|
"only skill_candidate items apply autonomously — an identity rewrite \
|
|
changes what the agent IS rather than adding a procedure it can \
|
|
consult, and that is a different bet than the one that was taken"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_sweep_applies_pending_drafts_and_leaves_nothing_pending_twice() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (ws, agent) = seed_workspace(&pool).await;
|
|
seed_proposal(&pool, ws, agent, "swept-skill", "Say what changed and why.").await;
|
|
|
|
let n = cm_api::skill_self_authoring::sweep(&pool).await.unwrap();
|
|
assert_eq!(n, 1, "the sweep must apply the pending draft with no human");
|
|
|
|
let body: String = sqlx::query_scalar("SELECT body FROM skills WHERE name = 'swept-skill'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("the swept draft must be in the catalogue");
|
|
assert_eq!(body, "Say what changed and why.");
|
|
|
|
// Idempotent: the proposal is no longer pending, so a second pass is a
|
|
// no-op rather than a duplicate apply or a version bump for no change.
|
|
let again = cm_api::skill_self_authoring::sweep(&pool).await.unwrap();
|
|
assert_eq!(again, 0, "a swept proposal must not be applied twice");
|
|
|
|
let versions: i64 =
|
|
sqlx::query_scalar("SELECT count(*) FROM skill_versions sv JOIN skills s ON s.id = sv.skill_id WHERE s.name = 'swept-skill'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(versions, 1, "an unchanged body must not append a version");
|
|
}
|