feat(fleet): GLM as a real microVM backend, and per-role models for claws
Three threads, all of which end at the same place: a mission whose verifier does
not share a model with the coder it reviews.
**GLM has a credential contract now.** `microvm_credential_for` returned one env
var name, which quietly assumed every provider reads its secret from the same
place Anthropic does. It returns a `Credential { source, target }` instead —
z.ai's key lives in the server's `ZAI_API_KEY` and Claude Code reads it as
`ANTHROPIC_AUTH_TOKEN`, and collapsing those two names is what forces a guess at
the other end. A wrong guess here sends one provider's credential to another
provider's endpoint.
`images/agent-glm` is the same CLI at the same pinned version as `agent-claude`
with `ANTHROPIC_BASE_URL` baked in. The split is deliberate: the ENDPOINT is a
property of the image, the CREDENTIAL is a property of the turn. That makes the
dangerous mix-up unrepresentable — a GLM VM cannot be handed an Anthropic
subscription token, and a claude VM cannot be pointed at z.ai. Asserted both
ways, because "the GLM VM must not carry CLAUDE_CODE_OAUTH_TOKEN" is the
property that costs a credential if it ever stops holding.
Kimi stays refused. `KIMI_API_KEY` is set and Moonshot serves an
Anthropic-compatible API, but I have not verified its base URL against the
running service, and this function is precisely where guessing a URL is
expensive. It becomes an arm the day someone measures it.
`api.z.ai` joins the node's default egress allow-list. A default that cannot
run the images we ship is a trap rather than a policy — the alternative is an
operator discovering it as a hung agent with no model access.
**Per-role models for claws** (migration 0071). `template_roles` had no model
column, so `mint_team_from_template` bound every role of every mission team to
one literal — a template whose whole point is an independent reviewer minted a
reviewer sharing a model with the coder. A role may now name its own; roles that
say nothing still take the mint's default, so every template written before this
behaves exactly as it did. The literal is now that default rather than a
hardcode.
**A harness scenario for the roster flow.** `verify-mission-delivery.sh roster`
runs the whole Slice 5 loop — planner proposes, human approves, mission runs —
and asserts the roster LANDED on the mission row rather than trusting the API's
answer. That distinction is not theoretical: the first live approval returned an
error while leaving the proposal marked approved.
Built and proven on tank ahead of the deploy: `clawmates/agent-glm:dev` reports
`2.1.223` and `BASE=https://api.z.ai/api/anthropic`, and
`fc-build-rootfs.sh … glm 8G` boots a VM from it that has git, can write
/mission, and answers `claude --version`.
533 tests pass, clippy clean. Migration 0071.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
75d09241fb
commit
f7f3dfe495
@@ -52,6 +52,13 @@ const DEFAULT_ALLOW: &[&str] = &[
|
|||||||
// The model API. Without it there is no agent.
|
// The model API. Without it there is no agent.
|
||||||
"api.anthropic.com",
|
"api.anthropic.com",
|
||||||
".anthropic.com",
|
".anthropic.com",
|
||||||
|
// z.ai, for a `glm` backend VM. Claude Code speaks to it through
|
||||||
|
// ANTHROPIC_BASE_URL, so the binary is the same and only the host differs —
|
||||||
|
// and a host the proxy denies is an agent that cannot reach any model at
|
||||||
|
// all. Adding it here rather than requiring an operator to set
|
||||||
|
// CLAWMATES_FC_EGRESS_ALLOW: the image exists to be used, and a default that
|
||||||
|
// cannot run the images we ship is a trap, not a policy.
|
||||||
|
"api.z.ai",
|
||||||
// Our forge: clone, push, PRs.
|
// Our forge: clone, push, PRs.
|
||||||
"git.redclaw.dev",
|
"git.redclaw.dev",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -491,7 +491,17 @@ async fn mint_team_from_template(
|
|||||||
.map_err(|e| format!("insert agent {}: {e}", role.slot))?;
|
.map_err(|e| format!("insert agent {}: {e}", role.slot))?;
|
||||||
let claw_id = agent.id.as_uuid();
|
let claw_id = agent.id.as_uuid();
|
||||||
|
|
||||||
cm_db::repo::agents::set_model_binding(pool, agent.id, default_model)
|
// The ROLE's model when the template names one, else the mint's default.
|
||||||
|
// Before migration 0071 there was no role model at all, so every claw of
|
||||||
|
// every mission team ran the same one — including a reviewer reviewing
|
||||||
|
// the coder it shares a model with.
|
||||||
|
let role_model = role
|
||||||
|
.model
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
.unwrap_or(default_model);
|
||||||
|
cm_db::repo::agents::set_model_binding(pool, agent.id, role_model)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("set_model_binding {claw_id}: {e}"))?;
|
.map_err(|e| format!("set_model_binding {claw_id}: {e}"))?;
|
||||||
|
|
||||||
@@ -508,7 +518,7 @@ async fn mint_team_from_template(
|
|||||||
// out-of-band via MissionRuntimeProvisioner::pin_agent_workspaces.
|
// out-of-band via MissionRuntimeProvisioner::pin_agent_workspaces.
|
||||||
if let Some(p) = provisioner {
|
if let Some(p) = provisioner {
|
||||||
match p
|
match p
|
||||||
.provision_claw(claw_id, default_model, &template.template.risk_profile)
|
.provision_claw(claw_id, role_model, &template.template.risk_profile)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => provisioned_claws.push(agent.id),
|
Ok(_) => provisioned_claws.push(agent.id),
|
||||||
@@ -574,18 +584,13 @@ async fn mint_team_from_template(
|
|||||||
Ok(team_id)
|
Ok(team_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The model every claw a mission mints runs on.
|
/// The model a minted claw runs on when its template role does not name one.
|
||||||
///
|
///
|
||||||
/// One model for every role, which is a real limitation and not a preference:
|
/// A DEFAULT now, not a hardcode: `template_roles.model` (migration 0071) lets a
|
||||||
/// `template_roles` has no `model` column, so a template cannot express "the
|
/// template put its reviewer on a different model from the coder it reviews,
|
||||||
/// verifier runs elsewhere" — and a same-model verifier is the correlated
|
/// which is the correlated failure the cross-provider judge exists to break,
|
||||||
/// failure the independent judge exists to break.
|
/// one layer down. Roles that say nothing still land here, so every template
|
||||||
///
|
/// that existed before 0071 behaves exactly as it did.
|
||||||
/// The composed path closes this: an approved roster
|
|
||||||
/// (`routes::mission_roster`) carries a `backend` per node, and each backend is
|
|
||||||
/// a different provider's CLI in its own VM. Closing it for CLAWS as well needs
|
|
||||||
/// a per-role model on the template or on the mint, and neither exists yet —
|
|
||||||
/// stated here rather than left as a literal nobody notices.
|
|
||||||
const MINTED_CLAW_MODEL: &str = "claude-sonnet-5";
|
const MINTED_CLAW_MODEL: &str = "claude-sonnet-5";
|
||||||
|
|
||||||
/// The graph a COMPOSED microVM mission runs, built from its team template
|
/// The graph a COMPOSED microVM mission runs, built from its team template
|
||||||
|
|||||||
@@ -139,13 +139,6 @@ pub fn microvm_provider_env(backend: Option<&str>) -> Result<Vec<(String, String
|
|||||||
microvm_provider_env_from(backend, |k| std::env::var(k).ok())
|
microvm_provider_env_from(backend, |k| std::env::var(k).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The credential a backend's CLI authenticates with inside a VM.
|
|
||||||
///
|
|
||||||
/// Unknown backends are refused rather than given the Anthropic token: sending a
|
|
||||||
/// subscription credential to whatever endpoint an unrecognised backend points
|
|
||||||
/// at is worse than not launching. GLM and Kimi reach their own endpoints via
|
|
||||||
/// `ANTHROPIC_BASE_URL` and need that contract settled (B4.6) before a VM can
|
|
||||||
/// carry their keys — guessing it here would send an Anthropic token to z.ai.
|
|
||||||
/// Can a mission agent authenticate in a VM booted from this image?
|
/// Can a mission agent authenticate in a VM booted from this image?
|
||||||
///
|
///
|
||||||
/// The honest answer to "which backends can run a mission", which is NOT the
|
/// The honest answer to "which backends can run a mission", which is NOT the
|
||||||
@@ -157,13 +150,49 @@ pub fn backend_can_run_a_mission(backend: &str) -> bool {
|
|||||||
microvm_credential_for(Some(backend)).is_ok()
|
microvm_credential_for(Some(backend)).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn microvm_credential_for(backend: Option<&str>) -> Result<&'static str, String> {
|
/// Where a backend's secret comes from, and what the guest's CLI reads it as.
|
||||||
|
///
|
||||||
|
/// Two names, not one, because they differ for every provider but Anthropic:
|
||||||
|
/// z.ai's key lives in the server's `ZAI_API_KEY` and Claude Code reads it as
|
||||||
|
/// `ANTHROPIC_AUTH_TOKEN`. Collapsing them into one string is what forces a
|
||||||
|
/// guess at the other end, and a wrong guess here sends one provider's
|
||||||
|
/// credential to another provider's endpoint.
|
||||||
|
struct Credential {
|
||||||
|
/// The env var on THIS SERVER holding the secret.
|
||||||
|
source: &'static str,
|
||||||
|
/// The env var the GUEST's CLI reads it from.
|
||||||
|
target: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The credential a backend's CLI authenticates with inside a VM.
|
||||||
|
///
|
||||||
|
/// Unknown backends are refused rather than given the Anthropic token: sending a
|
||||||
|
/// subscription credential to whatever endpoint an unrecognised backend points
|
||||||
|
/// at is worse than not launching.
|
||||||
|
fn microvm_credential_for(backend: Option<&str>) -> Result<Credential, String> {
|
||||||
match backend {
|
match backend {
|
||||||
None | Some("") | Some("default") | Some("claude") => Ok("CLAUDE_CODE_OAUTH_TOKEN"),
|
None | Some("") | Some("default") | Some("claude") => Ok(Credential {
|
||||||
|
source: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
|
target: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
|
}),
|
||||||
|
// GLM. `images/agent-glm` bakes `ANTHROPIC_BASE_URL=https://api.z.ai/
|
||||||
|
// api/anthropic` into the rootfs, so the endpoint is a property of the
|
||||||
|
// image and the credential is a property of the turn. That split is what
|
||||||
|
// makes the dangerous mix-up unrepresentable: this VM cannot be handed
|
||||||
|
// an Anthropic token, and a `claude` VM cannot be pointed at z.ai.
|
||||||
|
Some("glm") => Ok(Credential {
|
||||||
|
source: "ZAI_API_KEY",
|
||||||
|
target: "ANTHROPIC_AUTH_TOKEN",
|
||||||
|
}),
|
||||||
|
// Kimi is deliberately still refused. `KIMI_API_KEY` is set on the
|
||||||
|
// server and Moonshot serves an Anthropic-compatible API, but I have not
|
||||||
|
// verified its base URL against the running service — and this function
|
||||||
|
// is exactly where guessing a URL costs a credential. It becomes one
|
||||||
|
// arm here the day someone measures it, alongside an `agent-kimi` image.
|
||||||
Some(other) => Err(format!(
|
Some(other) => Err(format!(
|
||||||
"backend {other:?} has no defined microVM credential contract yet — \
|
"backend {other:?} has no defined microVM credential contract yet — \
|
||||||
refusing to launch rather than forward an Anthropic subscription \
|
refusing to launch rather than forward one provider's credential to \
|
||||||
token to another provider's endpoint"
|
another provider's endpoint"
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,17 +202,18 @@ fn microvm_provider_env_from(
|
|||||||
lookup: impl Fn(&str) -> Option<String>,
|
lookup: impl Fn(&str) -> Option<String>,
|
||||||
) -> Result<Vec<(String, String)>, String> {
|
) -> Result<Vec<(String, String)>, String> {
|
||||||
let want = microvm_credential_for(backend)?;
|
let want = microvm_credential_for(backend)?;
|
||||||
let token = lookup(want)
|
let token = lookup(want.source)
|
||||||
.filter(|v| !v.trim().is_empty())
|
.filter(|v| !v.trim().is_empty())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"{want} is not set on this server, so a microVM mission would run \
|
"{} is not set on this server, so a microVM mission on backend \
|
||||||
`claude -p` with no credential — which hangs rather than failing. \
|
{backend:?} would run `claude -p` with no credential — which hangs \
|
||||||
Set it, or run the mission on the container path."
|
rather than failing. Set it, or run the mission on another backend.",
|
||||||
|
want.source
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut env = vec![(want.to_string(), token)];
|
let mut env = vec![(want.target.to_string(), token)];
|
||||||
// Non-Anthropic providers a mission's tools may need, forwarded when set.
|
// Non-Anthropic providers a mission's tools may need, forwarded when set.
|
||||||
// ANTHROPIC_API_KEY is absent from this list and must stay absent — see the
|
// ANTHROPIC_API_KEY is absent from this list and must stay absent — see the
|
||||||
// doc comment above.
|
// doc comment above.
|
||||||
@@ -1106,11 +1136,57 @@ mod tests {
|
|||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The credential each provider gets, and — the part that matters — the one
|
||||||
|
/// it must never get. A GLM VM handed `CLAUDE_CODE_OAUTH_TOKEN` would send an
|
||||||
|
/// Anthropic subscription credential to z.ai, verbatim, on the first turn.
|
||||||
|
/// The two providers' secrets must not cross.
|
||||||
|
#[test]
|
||||||
|
fn each_provider_gets_its_own_credential_and_only_its_own() {
|
||||||
|
let env = microvm_provider_env_from(Some("glm"), |k| Some(format!("value-of-{k}")))
|
||||||
|
.expect("glm has a settled contract");
|
||||||
|
let by_key: std::collections::HashMap<_, _> = env.into_iter().collect();
|
||||||
|
// Claude Code reads a custom-endpoint credential as ANTHROPIC_AUTH_TOKEN,
|
||||||
|
// and the value is the SERVER's ZAI_API_KEY — two different names, which
|
||||||
|
// is exactly why the contract carries both.
|
||||||
|
assert_eq!(
|
||||||
|
by_key.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
|
||||||
|
Some("value-of-ZAI_API_KEY"),
|
||||||
|
"{by_key:?}"
|
||||||
|
);
|
||||||
|
for forbidden in ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] {
|
||||||
|
assert!(
|
||||||
|
!by_key.contains_key(forbidden),
|
||||||
|
"a GLM VM must never carry {forbidden} — it would be sent to z.ai: {by_key:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the reverse: a claude VM carries no z.ai key.
|
||||||
|
let env = microvm_provider_env_from(Some("claude"), |k| Some(format!("value-of-{k}")))
|
||||||
|
.expect("claude");
|
||||||
|
let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
|
||||||
|
assert!(!keys.contains(&"ANTHROPIC_AUTH_TOKEN"), "{keys:?}");
|
||||||
|
assert!(!keys.contains(&"ZAI_API_KEY"), "{keys:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GLM mission with no z.ai key refuses to launch, and says which var is
|
||||||
|
/// missing. It must NOT fall back to the Anthropic token that IS set.
|
||||||
|
#[test]
|
||||||
|
fn a_glm_mission_without_a_zai_key_refuses_rather_than_falls_back() {
|
||||||
|
let err = microvm_provider_env_from(Some("glm"), |k| {
|
||||||
|
(k == "CLAUDE_CODE_OAUTH_TOKEN").then(|| "sub-token".to_string())
|
||||||
|
})
|
||||||
|
.expect_err("no z.ai key must refuse the launch");
|
||||||
|
assert!(err.contains("ZAI_API_KEY"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
/// A backend whose credential contract is not settled is refused, rather
|
/// A backend whose credential contract is not settled is refused, rather
|
||||||
/// than handed the Anthropic subscription token to send at its endpoint.
|
/// than handed another provider's credential to send at its endpoint.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_undefined_backend_is_refused_rather_than_given_the_anthropic_token() {
|
fn an_undefined_backend_is_refused_rather_than_given_the_anthropic_token() {
|
||||||
for b in [Some("glm"), Some("kimi"), Some("something-new")] {
|
// `kimi` stays here on purpose: the key is set on the server, but the
|
||||||
|
// base URL has not been verified against the running service, and this
|
||||||
|
// is the function where guessing a URL costs a credential.
|
||||||
|
for b in [Some("kimi"), Some("something-new"), Some("agent-terminal")] {
|
||||||
let r = microvm_provider_env_from(b, |_| Some("set".into()));
|
let r = microvm_provider_env_from(b, |_| Some("set".into()));
|
||||||
assert!(r.is_err(), "backend {b:?} should be refused: {r:?}");
|
assert!(r.is_err(), "backend {b:?} should be refused: {r:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ struct TemplateRoleFile {
|
|||||||
skills: Vec<String>,
|
skills: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
brain_seed: Option<String>,
|
brain_seed: Option<String>,
|
||||||
|
/// Which model this role's claw runs on. Omitted means the mint's default,
|
||||||
|
/// which is what every authored template does today — so adding the field
|
||||||
|
/// changes nothing until a template uses it.
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn templates_dir() -> PathBuf {
|
fn templates_dir() -> PathBuf {
|
||||||
@@ -137,6 +142,7 @@ async fn load_one(pool: &PgPool, path: &std::path::Path) -> Result<String, Strin
|
|||||||
system_prompt: &r.system_prompt,
|
system_prompt: &r.system_prompt,
|
||||||
skills: r.skills.clone(),
|
skills: r.skills.clone(),
|
||||||
brain_seed: r.brain_seed.as_deref(),
|
brain_seed: r.brain_seed.as_deref(),
|
||||||
|
model: r.model.as_deref(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
|
|||||||
system_prompt: "Plan the feature. Break it into INT-XX items.",
|
system_prompt: "Plan the feature. Break it into INT-XX items.",
|
||||||
skills: vec!["decompose-int-items".into()],
|
skills: vec!["decompose-int-items".into()],
|
||||||
brain_seed: Some("# Planner\nBreak features into INT items."),
|
brain_seed: Some("# Planner\nBreak features into INT items."),
|
||||||
|
model: None,
|
||||||
},
|
},
|
||||||
team_templates::UpsertBuiltinRole {
|
team_templates::UpsertBuiltinRole {
|
||||||
slot: "coder",
|
slot: "coder",
|
||||||
@@ -76,6 +77,7 @@ async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
|
|||||||
system_prompt: "Implement one INT item at a time.",
|
system_prompt: "Implement one INT item at a time.",
|
||||||
skills: vec!["write-rust-current-edition".into()],
|
skills: vec!["write-rust-current-edition".into()],
|
||||||
brain_seed: Some("# Coder\nOne INT per commit."),
|
brain_seed: Some("# Coder\nOne INT per commit."),
|
||||||
|
model: None,
|
||||||
},
|
},
|
||||||
team_templates::UpsertBuiltinRole {
|
team_templates::UpsertBuiltinRole {
|
||||||
slot: "reviewer",
|
slot: "reviewer",
|
||||||
@@ -83,6 +85,9 @@ async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
|
|||||||
system_prompt: "Review each commit before merge.",
|
system_prompt: "Review each commit before merge.",
|
||||||
skills: vec!["code-review-checklist".into()],
|
skills: vec!["code-review-checklist".into()],
|
||||||
brain_seed: None,
|
brain_seed: None,
|
||||||
|
// The point of migration 0071: a reviewer that does NOT
|
||||||
|
// share a model with the coder it reviews.
|
||||||
|
model: Some("glm-4.7"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -323,3 +328,49 @@ async fn an_approved_roster_outranks_the_template() {
|
|||||||
assert_eq!(nodes[0]["attrs"]["backend"], "claude");
|
assert_eq!(nodes[0]["attrs"]["backend"], "claude");
|
||||||
assert_eq!(nodes[1]["attrs"]["backend"], "kimi");
|
assert_eq!(nodes[1]["attrs"]["backend"], "kimi");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migration 0071: a template role may name its own model, and the claw minted
|
||||||
|
/// for it must actually run on that model.
|
||||||
|
///
|
||||||
|
/// Before this, `mint_team_from_template` bound EVERY role to one literal — so a
|
||||||
|
/// template whose whole point is an independent reviewer minted a reviewer
|
||||||
|
/// sharing a model with the coder it reviews. That is the correlated failure the
|
||||||
|
/// cross-provider judge exists to break, reintroduced one layer down.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_template_role_may_run_on_its_own_model() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = seed_workspace(&pool).await;
|
||||||
|
let user = seed_owner(&pool, ws).await;
|
||||||
|
let template_id = seed_test_template(&pool).await;
|
||||||
|
let mission = seed_mission(&pool, ws, template_id, "per-role models").await;
|
||||||
|
|
||||||
|
mission_orchestrator::on_launch(&pool, ws, user, mission, None)
|
||||||
|
.await
|
||||||
|
.expect("launch");
|
||||||
|
|
||||||
|
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT job_title, model_binding FROM agents
|
||||||
|
WHERE workspace_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY job_title",
|
||||||
|
)
|
||||||
|
.bind(ws.as_uuid())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let by_role: std::collections::HashMap<_, _> = rows.into_iter().collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
by_role.get("reviewer").and_then(|m| m.clone()).as_deref(),
|
||||||
|
Some("glm-4.7"),
|
||||||
|
"the reviewer must run the model its role names: {by_role:?}"
|
||||||
|
);
|
||||||
|
// And a role that names none still gets the mint's default, so every
|
||||||
|
// template written before 0071 behaves exactly as it did.
|
||||||
|
for silent in ["planner", "coder"] {
|
||||||
|
assert_eq!(
|
||||||
|
by_role.get(silent).and_then(|m| m.clone()).as_deref(),
|
||||||
|
Some("claude-sonnet-5"),
|
||||||
|
"{silent} named no model and must take the default"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ pub struct TemplateRole {
|
|||||||
pub system_prompt: String,
|
pub system_prompt: String,
|
||||||
pub skills: Vec<String>,
|
pub skills: Vec<String>,
|
||||||
pub brain_seed: Option<String>,
|
pub brain_seed: Option<String>,
|
||||||
|
/// Which model this role's claw runs on. `None` takes the mint's default —
|
||||||
|
/// which is what every role did unconditionally before migration 0071, and
|
||||||
|
/// why a template could not put its reviewer on a different model from the
|
||||||
|
/// coder it reviews.
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -60,6 +66,8 @@ pub struct UpsertBuiltinRole<'a> {
|
|||||||
pub system_prompt: &'a str,
|
pub system_prompt: &'a str,
|
||||||
pub skills: Vec<String>,
|
pub skills: Vec<String>,
|
||||||
pub brain_seed: Option<&'a str>,
|
pub brain_seed: Option<&'a str>,
|
||||||
|
/// Optional per-role model. `None` leaves the mint's default in place.
|
||||||
|
pub model: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -138,13 +146,14 @@ pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result<Uuid,
|
|||||||
for r in &b.roles {
|
for r in &b.roles {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO template_roles
|
"INSERT INTO template_roles
|
||||||
(template_id, slot, order_idx, system_prompt, skills, brain_seed)
|
(template_id, slot, order_idx, system_prompt, skills, brain_seed, model)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6)
|
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||||
ON CONFLICT (template_id, slot) DO UPDATE SET
|
ON CONFLICT (template_id, slot) DO UPDATE SET
|
||||||
order_idx = EXCLUDED.order_idx,
|
order_idx = EXCLUDED.order_idx,
|
||||||
system_prompt = EXCLUDED.system_prompt,
|
system_prompt = EXCLUDED.system_prompt,
|
||||||
skills = EXCLUDED.skills,
|
skills = EXCLUDED.skills,
|
||||||
brain_seed = EXCLUDED.brain_seed",
|
brain_seed = EXCLUDED.brain_seed,
|
||||||
|
model = EXCLUDED.model",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(r.slot)
|
.bind(r.slot)
|
||||||
@@ -152,6 +161,7 @@ pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result<Uuid,
|
|||||||
.bind(r.system_prompt)
|
.bind(r.system_prompt)
|
||||||
.bind(&r.skills)
|
.bind(&r.skills)
|
||||||
.bind(r.brain_seed)
|
.bind(r.brain_seed)
|
||||||
|
.bind(r.model)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -226,7 +236,7 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TeamTemplateDetail>,
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let role_rows = sqlx::query(
|
let role_rows = sqlx::query(
|
||||||
"SELECT template_id, slot, order_idx, system_prompt, skills, brain_seed
|
"SELECT template_id, slot, order_idx, system_prompt, skills, brain_seed, model
|
||||||
FROM template_roles WHERE template_id = $1
|
FROM template_roles WHERE template_id = $1
|
||||||
ORDER BY order_idx ASC",
|
ORDER BY order_idx ASC",
|
||||||
)
|
)
|
||||||
@@ -242,6 +252,7 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TeamTemplateDetail>,
|
|||||||
system_prompt: r.get("system_prompt"),
|
system_prompt: r.get("system_prompt"),
|
||||||
skills: r.get("skills"),
|
skills: r.get("skills"),
|
||||||
brain_seed: r.get("brain_seed"),
|
brain_seed: r.get("brain_seed"),
|
||||||
|
model: r.get("model"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Some(TeamTemplateDetail { template: t, roles }))
|
Ok(Some(TeamTemplateDetail { template: t, roles }))
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ async fn a_template_with_live_agents_still_accepts_edits() {
|
|||||||
system_prompt: prompt,
|
system_prompt: prompt,
|
||||||
skills: extra,
|
skills: extra,
|
||||||
brain_seed: None,
|
brain_seed: None,
|
||||||
|
model: None,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
team_templates::upsert_builtin(&pool, build("first", vec![]))
|
team_templates::upsert_builtin(&pool, build("first", vec![]))
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Plan A6, second of three: Claude Code pointed at GLM.
|
||||||
|
#
|
||||||
|
# The same CLI as `agent-claude`, the same toolchain underneath, and a different
|
||||||
|
# endpoint. That is the whole difference, and it is deliberate: z.ai serves an
|
||||||
|
# Anthropic-compatible API, so a second provider costs an env contract rather
|
||||||
|
# than a second agent harness with its own failure modes.
|
||||||
|
#
|
||||||
|
# Why this image exists at all: a composed mission's `verifier` node reviewing
|
||||||
|
# work its own model wrote is a correlated failure — the same one the
|
||||||
|
# cross-provider judge exists to break, one layer down. A roster can only put a
|
||||||
|
# node on another provider if another provider's rootfs is on the fleet.
|
||||||
|
#
|
||||||
|
# Build (on the node that will run it):
|
||||||
|
#
|
||||||
|
# ssh osobh@tank "cd ~/clawmates && \
|
||||||
|
# docker build -f images/agent-glm/Dockerfile -t clawmates/agent-glm:dev images/agent-glm/"
|
||||||
|
# scripts/fc-build-rootfs.sh osobh@tank clawmates/agent-glm:dev glm 8G
|
||||||
|
FROM clawmates/agent-toolchain:dev
|
||||||
|
|
||||||
|
# Pinned to the SAME version as agent-claude on purpose. A solo run and a
|
||||||
|
# composed run's verifier node should differ by provider and by nothing else; two
|
||||||
|
# CLI versions in one graph would make "the verifier disagreed" ambiguous between
|
||||||
|
# the model and the harness.
|
||||||
|
ARG CLAUDE_CODE_VERSION=2.1.223
|
||||||
|
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||||
|
&& npm cache clean --force \
|
||||||
|
&& rm -rf /root/.npm \
|
||||||
|
&& claude --version
|
||||||
|
|
||||||
|
# The endpoint is baked in; the CREDENTIAL never is. `mission_runtime::
|
||||||
|
# microvm_provider_env` injects `ANTHROPIC_AUTH_TOKEN` from the server's
|
||||||
|
# `ZAI_API_KEY` at turn time, so the key lives and dies with the VM.
|
||||||
|
#
|
||||||
|
# Baking the base URL rather than injecting it is the safer half of the split: an
|
||||||
|
# image whose URL is fixed cannot be handed a token for one provider and an
|
||||||
|
# endpoint for another. That mix-up — an Anthropic subscription token sent to
|
||||||
|
# z.ai — is precisely what `microvm_credential_for` refuses to allow.
|
||||||
|
ENV HOME=/root \
|
||||||
|
CLAWMATES_AGENT_CLI=claude \
|
||||||
|
ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
|
||||||
|
RUN mkdir -p /root/.claude
|
||||||
|
|
||||||
|
# No CLAUDE_CODE_OAUTH_TOKEN and no ANTHROPIC_API_KEY: this backend authenticates
|
||||||
|
# with a z.ai key alone. The subscription token must never reach this image —
|
||||||
|
# it would be sent, verbatim, to another company's endpoint.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Which model a template role's claw runs on.
|
||||||
|
--
|
||||||
|
-- `mint_team_from_template` minted EVERY role of EVERY mission team on one
|
||||||
|
-- model, because a template had no way to say otherwise: `template_roles` had a
|
||||||
|
-- slot, a prompt, skills and a brain seed, and no model. So a team whose whole
|
||||||
|
-- point is an independent reviewer got a reviewer running the same model as the
|
||||||
|
-- coder it reviews — the correlated failure the cross-provider judge exists to
|
||||||
|
-- break, reintroduced one layer down.
|
||||||
|
--
|
||||||
|
-- NULL means "the mint's default", which is what every existing role gets: this
|
||||||
|
-- migration changes no behaviour on its own. A template that wants a cheap
|
||||||
|
-- summarizer or a different-provider reviewer can now say so.
|
||||||
|
--
|
||||||
|
-- No CHECK constraint and no FK to a model catalogue, for the same reason
|
||||||
|
-- `missions.backend` and `missions.validator_model` have none: which models a
|
||||||
|
-- deployment registered is configuration, not schema. An unknown alias is
|
||||||
|
-- refused where models are resolved, with a message naming what IS registered.
|
||||||
|
ALTER TABLE template_roles
|
||||||
|
ADD COLUMN IF NOT EXISTS model text;
|
||||||
@@ -468,6 +468,93 @@ assert_stop_gate() { # <mission> <label>
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Scenario: a model sizes the team ─────────────────────────────
|
||||||
|
#
|
||||||
|
# Slice 5. The planner proposes a roster for THIS mission, a human approves it,
|
||||||
|
# and the mission runs the graph the model chose — not the team template's.
|
||||||
|
#
|
||||||
|
# The proof is the delivered file: one line per member the model proposed. A
|
||||||
|
# roster that was accepted, stored and then silently ignored at launch — which is
|
||||||
|
# exactly what the first live approval did — delivers the template's node count
|
||||||
|
# instead, or one line from the solo path.
|
||||||
|
|
||||||
|
ROSTER_BODY=$(cat <<JSON
|
||||||
|
{"title":"verify: a model sizes this mission",
|
||||||
|
"template_kind":"research_and_code",
|
||||||
|
"repo_id":"$REPO_ID",
|
||||||
|
"runtime_kind":"microvm",
|
||||||
|
"backend":"claude",
|
||||||
|
"description":"Add a small, self-contained change and have it independently verified.",
|
||||||
|
"phases":[
|
||||||
|
{"kind":"coding","order_idx":0,"config":{"commit_policy":"always","max_iterations":1,
|
||||||
|
"task":"Append EXACTLY ONE line to ROSTER.md at the repository root, creating it if absent: your stage name, a space, and the output of \`uname -r\`. Do not remove or rewrite lines already there — earlier stages wrote them. Change no other file."}}
|
||||||
|
]}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
# The whole Slice 5 flow, which is why this is not a plain `run_scenario`: the
|
||||||
|
# mission must be shaped by an approved proposal BEFORE it launches.
|
||||||
|
scenario_roster() {
|
||||||
|
local token mission proposal members engine nodes tier delivered lines status
|
||||||
|
token=$(mint_session) || { norun "roster: could not mint a session"; return 1; }
|
||||||
|
mission=$(create_mission "$token" "$(echo "$ROSTER_BODY" | tr -d '\n')") \
|
||||||
|
|| { norun "roster: mission create failed"; return 1; }
|
||||||
|
info "roster: mission=$mission"
|
||||||
|
|
||||||
|
# 1. Ask the model.
|
||||||
|
api "$token" POST "/api/missions/$mission/team-proposals" '{}' >/dev/null 2>&1
|
||||||
|
read -r proposal members <<<"$(api "$token" GET "/api/missions/$mission/team-proposals" | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
if d: print(d[0]["id"], len(d[0]["roster"]["members"]))
|
||||||
|
' 2>/dev/null)"
|
||||||
|
if [ -z "${proposal:-}" ]; then
|
||||||
|
norun "roster: the planner produced no usable proposal"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
pass "roster: the planner sized this mission at $members member(s)"
|
||||||
|
|
||||||
|
# 2. Approve it, and check it actually LANDED on the mission. The first live
|
||||||
|
# approval returned an error while leaving the proposal marked approved,
|
||||||
|
# so "the API said ok" is not the assertion — the mission row is.
|
||||||
|
api "$token" POST "/api/missions/$mission/team-proposals/$proposal/decide" \
|
||||||
|
'{"status":"approved"}' >/dev/null 2>&1
|
||||||
|
read -r engine nodes <<<"$(ssh "$HOST" "docker exec clawmates_postgres_1 psql -U postgres -d clawmates -tAc \
|
||||||
|
\"select coalesce(team_engine,'-'), coalesce(jsonb_array_length(config->'roster'->'nodes'), 0) from missions where id='$mission';\"" \
|
||||||
|
| head -1 | tr '|' ' ')"
|
||||||
|
if [ "$engine" = "composed" ] && [ "${nodes:-0}" = "$members" ]; then
|
||||||
|
pass "roster: the approved roster is on the mission ($nodes nodes, engine $engine)"
|
||||||
|
else
|
||||||
|
fail "roster: approval did not reach the mission (engine=$engine nodes=${nodes:-0}, expected $members)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Run it.
|
||||||
|
api "$token" PATCH "/api/missions/$mission/status" '{"status":"running"}' >/dev/null
|
||||||
|
status=$(await_mission "$token" "$mission")
|
||||||
|
[ "$status" != "timeout" ] || { norun "roster: mission did not finish in ${MISSION_TIMEOUT}s"; return 1; }
|
||||||
|
printf '%s\n' "$(phase_report "$token" "$mission")" | sed 's/^/ phase /'
|
||||||
|
|
||||||
|
tier=$(ssh "$HOST" "docker exec clawmates_postgres_1 psql -U postgres -d clawmates -tAc \
|
||||||
|
\"select tier from topology_runs where mission_id='$mission' order by created_at desc limit 1;\"" \
|
||||||
|
| head -1 | tr -d '[:space:]')
|
||||||
|
if [ "$tier" = "microvm_graph" ]; then
|
||||||
|
pass "roster: the mission ran the composed graph the model chose"
|
||||||
|
else
|
||||||
|
fail "roster: run tier is '$tier' — the roster was accepted and then ignored"
|
||||||
|
fi
|
||||||
|
|
||||||
|
delivered=$(fetch_delivered "$token" "$mission" ROSTER.md) \
|
||||||
|
|| { fail "roster: could not read ROSTER.md from the pushed branch"; return 1; }
|
||||||
|
lines=$(printf '%s\n' "$delivered" | grep -c '[^[:space:]]')
|
||||||
|
if [ "${lines:-0}" = "$members" ]; then
|
||||||
|
pass "roster: ROSTER.md has one line per proposed member ($lines)"
|
||||||
|
else
|
||||||
|
fail "roster: ROSTER.md has $lines line(s) for a $members-member roster: $(printf '%s' "$delivered" | tr '\n' '|')"
|
||||||
|
fi
|
||||||
|
check_single_uid "$mission" roster
|
||||||
|
}
|
||||||
|
|
||||||
run_scenario() { # run_scenario <label> <json> <assert-fn>
|
run_scenario() { # run_scenario <label> <json> <assert-fn>
|
||||||
local label="$1" body="$2" assert_fn="$3" token mission status
|
local label="$1" body="$2" assert_fn="$3" token mission status
|
||||||
# Every one of these MUST go through fail()/norun(). The first version of
|
# Every one of these MUST go through fail()/norun(). The first version of
|
||||||
@@ -657,6 +744,9 @@ case "${1:-all}" in
|
|||||||
composed)
|
composed)
|
||||||
run_scenario composed "$(echo "$COMPOSED_BODY" | tr -d '\n')" assert_composed
|
run_scenario composed "$(echo "$COMPOSED_BODY" | tr -d '\n')" assert_composed
|
||||||
;;
|
;;
|
||||||
|
roster)
|
||||||
|
scenario_roster
|
||||||
|
;;
|
||||||
all)
|
all)
|
||||||
selftest_uid_probe
|
selftest_uid_probe
|
||||||
run_scenario chain "$CHAIN_BODY" assert_chain
|
run_scenario chain "$CHAIN_BODY" assert_chain
|
||||||
@@ -666,9 +756,10 @@ case "${1:-all}" in
|
|||||||
run_scenario microvm "$(echo "$MICROVM_BODY" | tr -d '\n')" assert_microvm
|
run_scenario microvm "$(echo "$MICROVM_BODY" | tr -d '\n')" assert_microvm
|
||||||
scenario_microvm_unavailable_backend
|
scenario_microvm_unavailable_backend
|
||||||
run_scenario composed "$(echo "$COMPOSED_BODY" | tr -d '\n')" assert_composed
|
run_scenario composed "$(echo "$COMPOSED_BODY" | tr -d '\n')" assert_composed
|
||||||
|
scenario_roster
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
die "unknown scenario: $1 (selftest|uids|chain|multirole|noop|microvm|composed|all)"
|
die "unknown scenario: $1 (selftest|uids|chain|multirole|noop|microvm|composed|roster|all)"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user