diff --git a/crates/cccc-cli/src/args/actor.rs b/crates/cccc-cli/src/args/actor.rs index 7783f9467..901fc1cba 100644 --- a/crates/cccc-cli/src/args/actor.rs +++ b/crates/cccc-cli/src/args/actor.rs @@ -21,6 +21,9 @@ pub enum ActorAction { /// Display title. #[arg(long, default_value = "")] title: String, + /// Optional operator-maintained model declaration; does not configure launch. + #[arg(long, default_value = "")] + model_label: String, /// Agent runtime to configure or launch. #[arg(long, default_value = "codex")] runtime: String, @@ -64,6 +67,9 @@ pub enum ActorAction { /// Display title. #[arg(long)] title: Option, + /// Operator-maintained model declaration; an empty value clears it. + #[arg(long)] + model_label: Option, /// Agent runtime to configure or launch. #[arg(long)] runtime: Option, diff --git a/crates/cccc-cli/src/args/mod.rs b/crates/cccc-cli/src/args/mod.rs index b33b513bd..a38de97c0 100644 --- a/crates/cccc-cli/src/args/mod.rs +++ b/crates/cccc-cli/src/args/mod.rs @@ -540,4 +540,42 @@ 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-label", + "gpt-5-codex", + ]) + .expect("actor add"); + let Some(CommandKind::Actor(ActorArgs { + action: ActorAction::Add { model_label, .. }, + })) = cli.command + else { + panic!("wrong command"); + }; + assert_eq!(model_label, "gpt-5-codex"); + + let cli = Cli::try_parse_from(["cccc", "actor", "update", "peer1", "--model-label", ""]) + .expect("actor update"); + let Some(CommandKind::Actor(ActorArgs { + action: ActorAction::Update { model_label, .. }, + })) = cli.command + else { + panic!("wrong command"); + }; + assert_eq!(model_label.as_deref(), Some("")); + for action in ["add", "update"] { + assert!( + Cli::try_parse_from(["cccc", "actor", action, "peer1", "--model", "label"]) + .is_err() + ); + } + } } diff --git a/crates/cccc-cli/src/commands/actor.rs b/crates/cccc-cli/src/commands/actor.rs index 20fb86e68..a878f41b2 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_label, 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_label":model_label,"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_label, 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_label", model_label); 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 6814653f3..f74821322 100644 --- a/crates/cccc-contracts/src/actor.rs +++ b/crates/cccc-contracts/src/actor.rs @@ -194,6 +194,10 @@ pub struct Actor { pub role: Option, #[serde(default)] pub title: String, + /// Optional operator-maintained model declaration, not a launch setting + /// or a verified serving model. Empty means undeclared. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub model_label: String, #[serde(default)] pub command: Vec, #[serde(default)] @@ -249,6 +253,7 @@ impl Actor { id: id.into(), role: None, title: String::new(), + model_label: 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 2fe2d7ed8..41fb709bb 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_label(value: &str) -> io::Result { + let label = value.trim(); + if label.chars().count() > 128 || label.chars().any(char::is_control) { + return Err(io::Error::other( + "model_label must be at most 128 characters, without control characters", + )); + } + Ok(label.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_label = validate_model_label(&actor.model_label)?; if !actor.external_session_id.is_empty() && !matches!( actor.runtime, @@ -187,6 +200,7 @@ pub fn update( )); } actor.role = None; + actor.model_label = validate_model_label(&actor.model_label)?; 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 d52deaae5..a16c5d0a8 100644 --- a/crates/cccc-daemon/tests/suite/integration_ops.rs +++ b/crates/cccc-daemon/tests/suite/integration_ops.rs @@ -1428,6 +1428,84 @@ 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_label":" gpt-5-codex ", + "by":"user" + }), + ); + assert_eq!(added.result["actor"]["model_label"], "gpt-5-codex"); + assert_eq!( + added.result["event"]["data"]["actor"]["model_label"], + "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") + }; + let original = row(&home); + assert_eq!(original["model_label"], "gpt-5-codex"); + + let update = |label: Value| { + raw_call( + &home, + "actor_update", + json!({"group_id":group_id,"actor_id":"peer1","by":"user","patch":{"model_label":label}}), + ) + }; + assert!(update(json!("claude-sonnet-4-5")).ok); + assert_eq!(row(&home)["model_label"], "claude-sonnet-4-5"); + for key in [ + "runtime", + "runtime_mode", + "command", + "env", + "enabled", + "submit", + "external_session_id", + ] { + assert_eq!(row(&home)[key], original[key], "label update changed {key}"); + } + for invalid in [json!("x".repeat(129)), json!("two\nlines")] { + let refused = update(invalid); + assert!(!refused.ok, "{:?}", refused.result); + assert_eq!(row(&home)["model_label"], "claude-sonnet-4-5"); + } + assert!(update(json!("")).ok); + assert!(row(&home).get("model_label").is_none()); + let path = cccc_core::GroupStore::new(home.clone()) + .expect("store") + .ledger_path(group_id) + .expect("ledger path"); + let events = cccc_core::ledger::read_all(&path).expect("ledger"); + assert!( + !events + .iter() + .any(|event| matches!(event.kind.as_str(), "actor.start" | "actor.restart")), + "label-only updates must not produce lifecycle events" + ); +} + #[test] fn a_foreman_retrying_a_tree_after_a_lost_reply_gets_the_same_tasks() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/docs/standards/CCCC_DAEMON_IPC_V1.md b/docs/standards/CCCC_DAEMON_IPC_V1.md index b80a4aaae..a39c27591 100644 --- a/docs/standards/CCCC_DAEMON_IPC_V1.md +++ b/docs/standards/CCCC_DAEMON_IPC_V1.md @@ -3471,6 +3471,7 @@ Args: group_id: string actor_id?: string title?: string + model_label?: string // optional operator-maintained declaration; 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[] @@ -3528,13 +3529,20 @@ Args: ``` Patch keys used by CCCC v0.4.x include: -- Identity/UI: `title` +- Identity/UI: `title`, `model_label` (optional operator-maintained declaration; omission leaves it unchanged, an empty string clears it) - Runtime: `runtime`, `runtime_mode`, `command`, `submit` - Scope: `default_scope_key` - Enable/disable: `enabled` - Environment (use with care): `env` - Capability startup baseline: `capability_autoload` +`model_label` is declared metadata only. Absence means undeclared, not a detected +or default model. It neither configures launch nor verifies the serving model; +updating/clearing it does not restart the Actor or change launch settings. The +CLI spelling is `--model-label`, with no `model` / `--model` alias. Cost tables, +rankings, allocation policy, runtime detection, registries and automatic +synchronization are outside this field's contract. + A linked Profile owns `runtime`, `runtime_mode`, `command`, `submit` and its explicit environment. Actor-local `title`, notes, Web Model conversation bindings and `capability_autoload` remain editable; the Actor's capability baseline is additive to Profile defaults. A linked Actor MUST NOT diff --git a/resources/mcp_tools.json b/resources/mcp_tools.json index 1a878339e..8049afb74 100644 --- a/resources/mcp_tools.json +++ b/resources/mcp_tools.json @@ -1417,6 +1417,10 @@ "title": { "type": "string" }, + "model_label": { + "type": "string", + "description": "On add: optional operator-maintained model declaration; absence means undeclared. Does not configure launch or verify the serving model." + }, "command": { "type": "array", "items": {