feat(cli): whales in account agents and cloud dispatch (CW-7182)

`codewhale account agents create NAME --provider P --model M` sends the
exact route as the Agent's `model`; the service pins it in the whale's
account Fleet revision and the CLI refuses an answer whose saved route
differs from the request. `account agents list` prints each whale's saved
provider/model.

`codewhale dispatch --whale AGENT_ID "task"` creates the cloud conversation
with that whale's agentId and no client route, so the service takes the
route from the whale's pinned Fleet member. The memory-only key handoff
resolves the local configuration for the whale's own provider and model
(CliRuntimeOverrides) and applies the existing portability check; later
`--thread` follow-ups on a whale conversation re-read the whale's current
saved route for the key and turn, so a changed whale applies to the next
turn. `--whale` conflicts with --thread/--cloud-provider/--cloud-model.
The non-whale portability check now runs after the conversation is read,
so an existing whale thread is not refused by an unrelated local default.
Desktop drives the same path (`dispatch --prepare-model`).

docs/CLOUD_DISPATCH.md and the cloud-computer-dispatch registry row
describe whale dispatch.

Verification: dev-cargo test -p codewhale-cli --locked --lib cloud:
123 passed, 0 failed (378 filtered). The existing create_agent test call was
updated for the new signature. Compiled together with an unrelated peer's
uncommitted crates/cli/src/update.rs edit, which is not part of this
commit. No live account, provider or Boat call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-24 22:52:33 -07:00
1 parent 135319aa1a
commit 4d2b3fdb1b
5 files changed
+143 -11

No files matched your search

+67 -2
View File
@@ -196,6 +196,12 @@ enum CloudAgentsCommand {
/// Assign an existing account Project to this Agent.
#[arg(long)]
project_id: Option<String>,
/// Exact configured provider id saved in the whale's Fleet member.
#[arg(long, requires = "model")]
provider: Option<String>,
/// Exact model id on that provider.
#[arg(long, requires = "provider")]
model: Option<String>,
#[arg(long)]
operation_key: String,
},
@@ -632,6 +638,23 @@ struct AccountAgent {
#[serde(default)]
project_id: String,
revision: u64,
#[serde(default)]
configuration: Option<AgentConfiguration>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfiguration {
#[serde(default)]
revision: u64,
#[serde(default)]
model: Option<AgentModelRoute>,
}
#[derive(Clone, Deserialize)]
struct AgentModelRoute {
provider: String,
model: String,
}
#[derive(Deserialize)]
@@ -1176,6 +1199,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
&self,
name: &str,
project_id: Option<&str>,
route: Option<(&str, &str)>,
operation_key: &str,
) -> Result<AccountAgent> {
let name = validate_named_text(name, "Agent name", 80)?;
@@ -1184,6 +1208,12 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
"name": name,
"operationKey": operation_key,
});
if let Some((provider, model)) = route {
body["model"] = serde_json::json!({
"provider": validate_named_text(provider, "Provider", 256)?,
"model": validate_named_text(model, "Model", 256)?,
});
}
if let Some(project_id) = project_id {
body["projectId"] = serde_json::json!(validate_resource_id(project_id, "Project")?);
}
@@ -2313,16 +2343,33 @@ fn run_agents<T: CloudTransport, W: Write>(
writeln!(out, "Codewhale Agents ({})", listing.agents.len())?;
for agent in listing.agents {
validate_resource_id(&agent.id, "Agent")?;
writeln!(out, "{} — {}", printable(&agent.name), agent.id)?;
match agent
.configuration
.as_ref()
.and_then(|configuration| configuration.model.as_ref())
{
Some(route) => writeln!(
out,
"{} — {} ({}/{})",
printable(&agent.name),
agent.id,
printable(&route.provider),
printable(&route.model)
)?,
None => writeln!(out, "{} — {}", printable(&agent.name), agent.id)?,
}
}
Ok(())
}
CloudAgentsCommand::Create {
name,
project_id,
provider,
model,
operation_key,
} => {
let agent = client.create_agent(&name, project_id.as_deref(), &operation_key)?;
let route = provider.as_deref().zip(model.as_deref());
let agent = client.create_agent(&name, project_id.as_deref(), route, &operation_key)?;
validate_resource_id(&agent.id, "Agent")?;
if let Some(project_id) = project_id.as_deref()
&& agent.project_id != validate_resource_id(project_id, "Project")?
@@ -2331,6 +2378,24 @@ fn run_agents<T: CloudTransport, W: Write>(
}
writeln!(out, "Agent: {}", printable(&agent.name))?;
writeln!(out, "ID: {}", agent.id)?;
if let Some(configuration) = agent.configuration.as_ref()
&& let Some(saved) = configuration.model.as_ref()
{
if route.is_some_and(|(provider, model)| {
provider != saved.provider || model != saved.model
}) {
bail!("The Codewhale service saved a different model than requested");
}
writeln!(
out,
"Model: {}/{} (Fleet revision {})",
printable(&saved.provider),
printable(&saved.model),
configuration.revision
)?;
} else if route.is_some() {
bail!("The Codewhale service did not save the requested model");
}
if !agent.project_id.is_empty() {
writeln!(
out,
+60 -7
View File
@@ -58,6 +58,13 @@ pub(crate) struct ComputerOptions {
pub prepare_model: bool,
#[arg(long, hide = true)]
pub expected_account: Option<String>,
#[arg(
long,
value_name = "AGENT_ID",
conflicts_with_all = ["thread", "cloud_provider", "cloud_model"],
help = "Start a cloud conversation with this whale, using its saved model"
)]
pub whale: Option<String>,
}
impl ComputerOptions {
@@ -73,6 +80,7 @@ impl ComputerOptions {
|| self.json
|| self.configured_model
|| self.prepare_model
|| self.whale.is_some()
}
}
@@ -169,7 +177,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
config: &ConfigStore,
secrets: &Secrets,
) -> Result<()> {
let local = config.config.resolve_runtime_options_with_secrets(
let mut local = config.config.resolve_runtime_options_with_secrets(
&codewhale_config::CliRuntimeOverrides::default(),
secrets,
);
@@ -242,13 +250,13 @@ pub(super) fn run<T: CloudTransport, W: Write>(
if options.thread.is_none() && prompt.is_empty() {
bail!("Give a task to dispatch, or continue with --thread <id>.");
}
if let Some(value) = &options.whale {
validate_resource_id(value, "Whale")?;
}
let use_local_model = options.cloud_provider.is_none()
&& !options.key_stdin
&& (!prompt.is_empty() || options.prepare_model);
if use_local_model {
portable_model(&local, config)?;
}
if options.thread.is_none() && options.cloud_provider.is_none() {
if options.thread.is_none() && options.cloud_provider.is_none() && options.whale.is_none() {
options.cloud_provider = Some(local.provider.as_str().to_string());
options.cloud_model = Some(local.model.clone());
}
@@ -316,6 +324,49 @@ pub(super) fn run<T: CloudTransport, W: Write>(
bail!("This account conversation is unavailable.");
}
}
let whale_id = options.whale.clone().or_else(|| {
thread["agentId"]
.as_str()
.filter(|id| !id.is_empty() && thread["agentFleetRevision"].as_u64().is_some())
.map(str::to_string)
});
let whale_route = match &whale_id {
Some(id) => {
validate_resource_id(id, "Whale")?;
let agent = account.request(HttpMethod::Get, &format!("/api/agents/{id}"), None)?;
let route = &agent["agent"]["configuration"]["model"];
match (route["provider"].as_str(), route["model"].as_str()) {
(Some(provider), Some(model)) if agent["agent"]["id"].as_str() == Some(id) => {
validate_model_route(provider, model)?;
Some((provider.to_string(), model.to_string()))
}
_ => bail!(
"This whale has no saved model. Give it a configured model in Codewhale before dispatching."
),
}
}
None => None,
};
if let Some((provider, model)) = &whale_route
&& (use_local_model || options.prepare_model)
{
let kind = codewhale_config::ProviderKind::parse(provider).with_context(|| {
format!(
"Configure the {provider} provider in Codewhale before dispatching to this whale."
)
})?;
local = config.config.resolve_runtime_options_with_secrets(
&codewhale_config::CliRuntimeOverrides {
provider: Some(kind),
model: Some(model.clone()),
..Default::default()
},
secrets,
);
portable_model(&local, config)?;
} else if use_local_model {
portable_model(&local, config)?;
}
let selected = if options.computer.is_none() && options.thread.is_none() {
let list = account.request(HttpMethod::Get, "/api/computers", None)?;
let machines = list["computers"]
@@ -369,7 +420,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
}
if options.thread.is_none() {
let value = account.request(HttpMethod::Post, "/v1/threads", Some(json!({
"title": prompt.chars().take(120).collect::<String>(), "agentId": "", "computerId": computer,
"title": prompt.chars().take(120).collect::<String>(), "agentId": whale_id.as_deref().unwrap_or(""), "computerId": computer,
"productMode": "chat", "mode": "chat", "billingMode": "byok_external", "requiresByok": true,
"modelProvider": options.cloud_provider, "model": options.cloud_model, "operationKey": operation_key,
})))?;
@@ -427,6 +478,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
let provider = options
.cloud_provider
.as_deref()
.or(whale_route.as_ref().map(|(provider, _)| provider.as_str()))
.or_else(|| latest.and_then(|turn| turn["effective_provider"].as_str()))
.or_else(|| {
latest_id
@@ -438,6 +490,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
let model = options
.cloud_model
.as_deref()
.or(whale_route.as_ref().map(|(_, model)| model.as_str()))
.or_else(|| latest.and_then(|turn| turn["effective_model"].as_str()))
.or_else(|| {
latest_id
@@ -446,7 +499,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
.flatten()
})
.unwrap_or("");
let provider_id = if options.cloud_provider.is_some() {
let provider_id = if options.cloud_provider.is_some() || whale_route.is_some() {
None
} else {
latest
+5 -1
View File
@@ -3163,7 +3163,11 @@ fn account_agents_refuse_foreign_threads_ambiguous_main_and_machine_keys() {
.contains("interactive Codewhale account login")
);
assert!(transport.requests().is_empty());
assert!(client.create_agent("Whale", None, "invalid/key").is_err());
assert!(
client
.create_agent("Whale", None, None, "invalid/key")
.is_err()
);
assert!(transport.requests().is_empty());
client
+10
View File
@@ -54,6 +54,16 @@ configured model still matches the conversation before transferring a key.
without returning a credential. `--prepare-model --thread ID` reconnects that
model without starting a turn.
A whale is a named account collaborator with a saved provider and model.
`codewhale account agents create NAME --provider PROVIDER --model MODEL
--operation-key KEY` saves one without starting work or a computer, and
`codewhale account agents list` shows each whale's saved model.
`codewhale dispatch --whale AGENT_ID "your task"` starts a cloud conversation
with that whale: the service takes the route from the whale's saved
configuration, and your local configuration for that provider supplies the
memory-only key. Later `--thread` follow-ups keep using the whale's current
saved model; a changed whale applies to the next turn, not to a running one.
Every send prints a replay key before submission. After an uncertain response,
repeat the original command and prompt with `--operation-key KEY`. Keep the
original `--computer` target when retrying creation, or the original `--thread`
+1 -1
View File
@@ -39,7 +39,7 @@
[[feature]]
id = "cloud-computer-dispatch"
name = "Cloud computer conversations"
summary = "Dispatch, inspect, continue and stop the same account cloud conversation from terminal and desktop."
summary = "Dispatch, inspect, continue and stop the same account cloud conversation from terminal and desktop, including conversations with a saved whale."
status = "experimental"
surfaces = { tui = "preview" }
since = "unreleased"