Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/cccc-cli/src/args/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +67,9 @@ pub enum ActorAction {
/// Display title.
#[arg(long)]
title: Option<String>,
/// Operator-maintained model declaration; an empty value clears it.
#[arg(long)]
model_label: Option<String>,
/// Agent runtime to configure or launch.
#[arg(long)]
runtime: Option<String>,
Expand Down
38 changes: 38 additions & 0 deletions crates/cccc-cli/src/args/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}
}
}
5 changes: 4 additions & 1 deletion crates/cccc-cli/src/commands/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}),
)
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions crates/cccc-contracts/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ pub struct Actor {
pub role: Option<ActorRole>,
#[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<String>,
#[serde(default)]
Expand Down Expand Up @@ -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(),
Expand Down
14 changes: 14 additions & 0 deletions crates/cccc-core/src/actors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ pub fn validate_actor_id(value: &str) -> io::Result<String> {
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<String> {
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<Item = &Actor> {
group
.actors
Expand Down Expand Up @@ -95,6 +107,7 @@ pub fn add(group: &mut GroupDoc, mut actor: Actor) -> io::Result<Actor> {
}

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,
Expand Down Expand Up @@ -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)
Expand Down
78 changes: 78 additions & 0 deletions crates/cccc-daemon/tests/suite/integration_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 9 additions & 1 deletion docs/standards/CCCC_DAEMON_IPC_V1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions resources/mcp_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading