mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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:
1 parent
135319aa1a
commit
4d2b3fdb1b
5 files changed
+143
-11
No files matched your search
+67
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user