From ef9330a9b63fe5c3e5c5b89af7da2fc55d573126 Mon Sep 17 00:00:00 2001 From: chriscoveries <212684707+chriscoveries@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:57:47 +0000 Subject: [PATCH] feat(actors): a model label on the actor row Which model an Actor runs is visible today only inside its command line, as a runtime-specific flag, and not at all for Actors whose command does not name one. A board or a foreman that wants to choose between Actors by model has to parse commands, and a `model` key sent to actor_add or actor_update is dropped without an error. An Actor now carries an optional `model` label: set on actor_add, changed or cleared (with an empty string) on actor_update, returned on actor_list rows and in the actor.add event. It is trimmed and limited to 128 characters without control characters. It is a label for people and tools; launching does not read it. The CLI gains `--model` on `actor add` and `actor update`, and the MCP `cccc_actor` schema describes the key. --- crates/cccc-cli/src/args/actor.rs | 6 ++ crates/cccc-cli/src/args/mod.rs | 32 +++++++++++ crates/cccc-cli/src/commands/actor.rs | 5 +- crates/cccc-contracts/src/actor.rs | 5 ++ crates/cccc-core/src/actors.rs | 14 +++++ .../tests/suite/integration_ops.rs | 55 +++++++++++++++++++ docs/standards/CCCC_DAEMON_IPC_V1.md | 3 +- resources/mcp_tools.json | 4 ++ 8 files changed, 122 insertions(+), 2 deletions(-) diff --git a/crates/cccc-cli/src/args/actor.rs b/crates/cccc-cli/src/args/actor.rs index 840ea81e0..0a28f3916 100644 --- a/crates/cccc-cli/src/args/actor.rs +++ b/crates/cccc-cli/src/args/actor.rs @@ -16,6 +16,9 @@ pub enum ActorAction { actor_id: String, #[arg(long, default_value = "")] title: String, + /// The model this Actor runs, as a label (launching does not read it). + #[arg(long, default_value = "")] + model: String, #[arg(long, default_value = "codex")] runtime: String, #[arg(long, default_value = "default", value_parser = ["default", "acp"])] @@ -43,6 +46,9 @@ pub enum ActorAction { group_id: Option, #[arg(long)] title: Option, + /// The model label; an empty value clears it. + #[arg(long)] + model: Option, #[arg(long)] runtime: Option, #[arg(long, value_parser = ["default", "acp"])] diff --git a/crates/cccc-cli/src/args/mod.rs b/crates/cccc-cli/src/args/mod.rs index 2e766e148..27822bc8c 100644 --- a/crates/cccc-cli/src/args/mod.rs +++ b/crates/cccc-cli/src/args/mod.rs @@ -450,4 +450,36 @@ mod tests { }; assert_eq!(args.event_id, "event-2"); } + + #[test] + fn parses_the_actor_model_label_on_add_and_update() { + let cli = Cli::try_parse_from([ + "cccc", + "actor", + "add", + "peer1", + "--runtime", + "codex", + "--model", + "gpt-5-codex", + ]) + .expect("actor add"); + let Some(CommandKind::Actor(ActorArgs { + action: ActorAction::Add { model, .. }, + })) = cli.command + else { + panic!("wrong command"); + }; + assert_eq!(model, "gpt-5-codex"); + + let cli = Cli::try_parse_from(["cccc", "actor", "update", "peer1", "--model", ""]) + .expect("actor update"); + let Some(CommandKind::Actor(ActorArgs { + action: ActorAction::Update { model, .. }, + })) = cli.command + else { + panic!("wrong command"); + }; + assert_eq!(model.as_deref(), Some("")); + } } diff --git a/crates/cccc-cli/src/commands/actor.rs b/crates/cccc-cli/src/commands/actor.rs index 20fb86e68..5d30d5f10 100644 --- a/crates/cccc-cli/src/commands/actor.rs +++ b/crates/cccc-cli/src/commands/actor.rs @@ -20,6 +20,7 @@ pub async fn run(client: &DaemonClient, home: &HomeLayout, args: ActorArgs) -> R ActorAction::Add { actor_id, title, + model, runtime, runtime_mode, command, @@ -36,7 +37,7 @@ pub async fn run(client: &DaemonClient, home: &HomeLayout, args: ActorArgs) -> R "actor_add", json!({ "group_id":group(home,group_id)?,"actor_id":actor_id,"title":title, - "runtime":runtime,"runtime_mode":runtime_mode,"command":command,"env":env(raw_env)?, + "model":model,"runtime":runtime,"runtime_mode":runtime_mode,"command":command,"env":env(raw_env)?, "default_scope_key":scope,"submit":submit,"by":by }), ) @@ -50,6 +51,7 @@ pub async fn run(client: &DaemonClient, home: &HomeLayout, args: ActorArgs) -> R actor_id, group_id, title, + model, runtime, runtime_mode, scope, @@ -61,6 +63,7 @@ pub async fn run(client: &DaemonClient, home: &HomeLayout, args: ActorArgs) -> R } => { let mut patch = Map::new(); optional(&mut patch, "title", title); + optional(&mut patch, "model", model); optional(&mut patch, "runtime", runtime); optional(&mut patch, "runtime_mode", runtime_mode); if let Some(scope) = scope { diff --git a/crates/cccc-contracts/src/actor.rs b/crates/cccc-contracts/src/actor.rs index 810fdc7eb..dc76f1fee 100644 --- a/crates/cccc-contracts/src/actor.rs +++ b/crates/cccc-contracts/src/actor.rs @@ -193,6 +193,10 @@ pub struct Actor { pub role: Option, #[serde(default)] pub title: String, + /// The model this Actor runs, as its operator names it. A label for + /// people and tools; launching does not read it. Empty when unset. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub model: String, #[serde(default)] pub command: Vec, #[serde(default)] @@ -245,6 +249,7 @@ impl Actor { id: id.into(), role: None, title: String::new(), + model: String::new(), command: Vec::new(), env: BTreeMap::new(), default_scope_key: String::new(), diff --git a/crates/cccc-core/src/actors.rs b/crates/cccc-core/src/actors.rs index 144ac33ac..c52f31655 100644 --- a/crates/cccc-core/src/actors.rs +++ b/crates/cccc-core/src/actors.rs @@ -39,6 +39,18 @@ pub fn validate_actor_id(value: &str) -> io::Result { Ok(id.to_owned()) } +/// An Actor's `model` label, trimmed: at most 128 characters and no control +/// characters. Empty clears it. +pub fn validate_model(value: &str) -> io::Result { + let model = value.trim(); + if model.chars().count() > 128 || model.chars().any(char::is_control) { + return Err(io::Error::other( + "model must be at most 128 characters, without control characters", + )); + } + Ok(model.to_owned()) +} + pub fn visible(group: &GroupDoc) -> impl Iterator { group .actors @@ -95,6 +107,7 @@ pub fn add(group: &mut GroupDoc, mut actor: Actor) -> io::Result { } actor.id = validate_actor_id(&actor.id)?; + actor.model = validate_model(&actor.model)?; actor .runtime_mode .validate(actor.runtime) @@ -153,6 +166,7 @@ pub fn update( object.insert("updated_at".into(), Value::String(utc_now())); let mut actor: Actor = serde_json::from_value(value).map_err(io::Error::other)?; actor.role = None; + actor.model = validate_model(&actor.model)?; actor .runtime_mode .validate(actor.runtime) diff --git a/crates/cccc-daemon/tests/suite/integration_ops.rs b/crates/cccc-daemon/tests/suite/integration_ops.rs index 29fcaef05..c29cd7891 100644 --- a/crates/cccc-daemon/tests/suite/integration_ops.rs +++ b/crates/cccc-daemon/tests/suite/integration_ops.rs @@ -1428,6 +1428,61 @@ fn rust_daemon_provider_auth_mutations_do_not_report_synthetic_lifecycle() { assert_eq!(invalid.error.expect("invalid action").code, "invalid_args"); } +#[test] +fn an_actor_carries_the_model_label_its_operator_gives_it() { + let temp = tempfile::tempdir().expect("tempdir"); + let home = HomeLayout::from_path(temp.path().join("rust-home")).expect("home"); + let created = call(&home, "group_create", json!({"title":"models"})); + let group_id = created.result["group"]["group_id"] + .as_str() + .expect("group id"); + let added = call( + &home, + "actor_add", + json!({ + "group_id":group_id, + "actor_id":"peer1", + "runtime":"custom", + "command":["sh"], + "enabled":false, + "model":" gpt-5-codex ", + "by":"user" + }), + ); + assert_eq!(added.result["actor"]["model"], "gpt-5-codex"); + assert_eq!( + added.result["event"]["data"]["actor"]["model"], + "gpt-5-codex" + ); + let row = |home: &HomeLayout| { + call(home, "actor_list", json!({"group_id":group_id})).result["actors"] + .as_array() + .expect("actors") + .iter() + .find(|actor| actor["id"] == "peer1") + .cloned() + .expect("peer1 row") + }; + assert_eq!(row(&home)["model"], "gpt-5-codex"); + + let update = |model: Value| { + raw_call( + &home, + "actor_update", + json!({"group_id":group_id,"actor_id":"peer1","by":"user","patch":{"model":model}}), + ) + }; + assert!(update(json!("claude-sonnet-4-5")).ok); + assert_eq!(row(&home)["model"], "claude-sonnet-4-5"); + for invalid in [json!("x".repeat(129)), json!("two\nlines")] { + let refused = update(invalid); + assert!(!refused.ok, "{:?}", refused.result); + assert_eq!(row(&home)["model"], "claude-sonnet-4-5"); + } + assert!(update(json!("")).ok); + assert!(row(&home).get("model").is_none()); +} + fn call(home: &HomeLayout, op: &str, args: Value) -> DaemonResponse { let response = raw_call(home, op, args); assert!(response.ok, "{op}: {:?}", response.error); diff --git a/docs/standards/CCCC_DAEMON_IPC_V1.md b/docs/standards/CCCC_DAEMON_IPC_V1.md index 65fbe0b42..96c7969a5 100644 --- a/docs/standards/CCCC_DAEMON_IPC_V1.md +++ b/docs/standards/CCCC_DAEMON_IPC_V1.md @@ -3370,6 +3370,7 @@ Args: group_id: string actor_id?: string title?: string + model?: string // the model the Actor runs, as a label; at most 128 characters, no control characters runtime?: string runtime_mode?: "default" | "acp" // optional headless ACP for Antigravity, Auggie, Copilot, Devin, Cursor, Kimi Code, Kiro, Hermes and Droid command?: string[] @@ -3424,7 +3425,7 @@ Args: ``` Patch keys used by CCCC v0.4.x include: -- Identity/UI: `title` +- Identity/UI: `title`, `model` (a label naming the model the Actor runs; launching does not read it; an empty string clears it) - Runtime: `runtime`, `runtime_mode`, `command`, `submit` - Scope: `default_scope_key` - Enable/disable: `enabled` diff --git a/resources/mcp_tools.json b/resources/mcp_tools.json index 012c06895..72c4fe3f0 100644 --- a/resources/mcp_tools.json +++ b/resources/mcp_tools.json @@ -1344,6 +1344,10 @@ "title": { "type": "string" }, + "model": { + "type": "string", + "description": "On add: the model this Actor runs, as a label; launching does not read it." + }, "command": { "type": "array", "items": {