feat(engine,app-server): ACP options route, typed control refusals and agent-reported usage (CW-7879 EA-04)

GET /v1/agents/external/{id}/options?cwd= answers with the agent's own
session/new offer (models, modes, config options, capabilities) read on a
throwaway process that is stopped on every path, plus which Codewhale
actions it carries. A saved model choice is validated against the complete
offer at setup and written through the method it was offered on; a choice
the agent does not offer or no longer offers, and per-turn controls an
agent cannot honor, are stable typed refusals instead of strings.
Agent-reported usage is recorded as turn.agent_usage events: context
window, a per-turn token total, cost unknown, and unknown (not zero) when
the agent reports nothing. A started agent process is now owned by a guard
that stops it on error or panic and logs a failed stop.

Checks (Boat farm, CARGO_INCREMENTAL=0): engine --lib external_agent
fleet::acp 20 passed 0 failed; app-server --lib external_agent 3 passed
0 failed; clippy -D warnings engine+app-server --all-targets clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-11-10 18:21:41 -08:00
1 parent 3da52ab015
commit 10ca1deee0
8 files changed
+1807 -88

No files matched your search

+5
View File
@@ -117,6 +117,7 @@ mod avatars;
mod computer_display;
mod context;
mod diagnostics;
mod external_agent_options;
mod external_agents;
mod git;
mod jobs;
@@ -3462,6 +3463,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/agents/external",
get(external_agents::list_external_agents),
)
.route(
"/v1/agents/external/{id}/options",
get(external_agent_options::get_external_agent_options),
)
.route("/v1/providers", get(list_providers))
.route("/v1/providers/{id}/models", get(list_provider_models))
.route(
@@ -0,0 +1,83 @@
//! `GET /v1/agents/external/{id}/options?cwd=` — what one external agent
//! offers for one workspace.
//!
//! The answer is the agent's own `session/new` offer, read on a throwaway
//! process the Engine stops before it returns. Nothing here comes from
//! Codewhale's provider catalog.
//!
//! Known limitations: probing starts a real agent session in `cwd`, which an
//! agent may keep in its own history. The route is gated like the rest of the
//! Runtime API; it does not tie `cwd` to a thread owner.
use std::path::PathBuf;
use axum::Json;
use axum::extract::{Path, Query, State};
use serde::Deserialize;
use codewhale_engine::fleet::acp::AgentOptions;
use codewhale_engine::fleet::agents::ExecutorUnavailable;
use super::{ApiError, RuntimeApiState, external_agent_refusal};
const MAX_AGENT_ID_BYTES: usize = 64;
const MAX_CWD_BYTES: usize = 4096;
#[derive(Debug, Deserialize)]
pub(super) struct OptionsQuery {
cwd: Option<PathBuf>,
}
fn valid_agent_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_AGENT_ID_BYTES
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
}
pub(super) async fn get_external_agent_options(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
Query(query): Query<OptionsQuery>,
) -> Result<Json<AgentOptions>, ApiError> {
if !valid_agent_id(&id) {
return Err(ApiError::bad_request(
"the agent id must name an agent from GET /v1/agents/external",
));
}
let cwd = query
.cwd
.ok_or_else(|| ApiError::bad_request("cwd is required"))?;
let text = cwd.to_string_lossy();
if text.len() > MAX_CWD_BYTES || text.chars().any(char::is_control) || !cwd.is_absolute() {
return Err(ApiError::bad_request(
"cwd must be an absolute directory path",
));
}
let probe = cwd.clone();
let is_dir = tokio::task::spawn_blocking(move || probe.is_dir())
.await
.map_err(|_| ApiError::internal("cwd check failed"))?;
if !is_dir {
return Err(ApiError::not_found(
"cwd is not a directory on this computer",
));
}
match state
.runtime_threads
.external_agent_options(&id, &cwd)
.await
{
Ok(options) => Ok(Json(options)),
Err(error) => {
let mut refusal = external_agent_refusal(&error);
if let Some(refusal) = refusal.as_mut()
&& refusal.code == Some(ExecutorUnavailable::UNKNOWN_AGENT)
{
refusal.status = axum::http::StatusCode::NOT_FOUND;
}
Err(refusal.unwrap_or_else(|| ApiError::internal(error.to_string())))
}
}
}
@@ -18,6 +18,8 @@ use uuid::Uuid;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
#[cfg(unix)]
mod external_agent_options;
mod headless_catalog;
#[cfg(unix)]
mod owner_bootstrap;
@@ -0,0 +1,184 @@
use super::*;
use reqwest::StatusCode as HttpStatus;
fn options_fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../engine/tests/fixtures/acp-options-agent.mjs")
.canonicalize()
.expect("the options fixture agent")
}
fn with_query(base: &str, pairs: &[(&str, &str)]) -> reqwest::Url {
let mut url = reqwest::Url::parse(base).expect("a url");
for (key, value) in pairs {
url.query_pairs_mut().append_pair(key, value);
}
url
}
fn received(workspace: &Path) -> Vec<Value> {
fs::read_to_string(workspace.join("options-received.jsonl"))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
async fn wait_for_usage_total(
manager: &SharedRuntimeThreadManager,
thread_id: &str,
) -> Result<RuntimeEventRecord> {
for _ in 0..600 {
let events = manager.events_since_async(thread_id, None).await?;
if let Some(event) = events.into_iter().find(|event| {
event.event == "turn.agent_usage" && event.payload["kind"] == "turn_total"
}) {
return Ok(event);
}
sleep(Duration::from_millis(50)).await;
}
bail!("the agent's usage was never recorded")
}
#[tokio::test]
async fn the_options_route_offers_the_agents_choices_and_a_chosen_one_reaches_it() -> Result<()> {
let _env = lock_test_env();
let _fixture = EnvVarGuard::set("CODEWHALE_ACP_FIXTURE_AGENT", options_fixture());
let Some((addr, manager, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let workspace = tempfile::tempdir()?;
let cwd = workspace.path().canonicalize()?;
let url = format!("http://{addr}/v1/agents/external/fixture/options");
let offered: Value = client
.get(with_query(&url, &[("cwd", cwd.to_str().unwrap())]))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(offered["agent"]["id"], "fixture");
let controls = offered["controls"].as_array().unwrap();
let model = controls.iter().find(|c| c["id"] == "model").unwrap();
let values: Vec<&str> = model["choices"]
.as_array()
.unwrap()
.iter()
.map(|c| c["value"].as_str().unwrap())
.collect();
assert!(values.len() >= 2, "{values:?}");
assert!(controls.iter().any(|c| c["id"] == "mode"));
assert!(
controls.iter().any(|c| c["id"] == "config:verbose"
&& c["unavailable"]["code"] == "control_type_unsupported")
);
assert!(
offered["actions"]
.as_array()
.unwrap()
.iter()
.any(|a| a["action"] == "steering" && a["supported"] == false)
);
let leaked = fs::read_to_string(cwd.join("options-env.json"))?;
assert!(!leaked.contains("CODEWHALE_ACP_FIXTURE_AGENT"));
for (query, status, code) in [
(vec![], HttpStatus::BAD_REQUEST, None),
(vec![("cwd", "relative/dir")], HttpStatus::BAD_REQUEST, None),
(
vec![("cwd", "/definitely/not/here")],
HttpStatus::NOT_FOUND,
None,
),
] {
let response = client.get(with_query(&url, &query)).send().await?;
assert_eq!(response.status(), status, "{query:?}");
let body: Value = response.json().await?;
assert_eq!(body["error"]["code"].as_str(), code, "{body}");
}
let unknown = client
.get(with_query(
&format!("http://{addr}/v1/agents/external/nope/options"),
&[("cwd", cwd.to_str().unwrap())],
))
.send()
.await?;
assert_eq!(unknown.status(), HttpStatus::NOT_FOUND);
let body: Value = unknown.json().await?;
assert_eq!(body["error"]["code"], "unknown_agent");
let bad_id = client
.get(with_query(
&format!("http://{addr}/v1/agents/external/..%2Fsh/options"),
&[("cwd", cwd.to_str().unwrap())],
))
.send()
.await?;
assert!(bad_id.status().is_client_error());
let chosen = values[1];
let created: Value = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"workspace": cwd,
"permission_posture": "ask",
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": chosen}}
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let thread_id = created["id"].as_str().unwrap().to_string();
let refused = client
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
.json(&json!({"prompt": "x", "model": "other"}))
.send()
.await?;
assert_eq!(refused.status(), HttpStatus::FAILED_DEPENDENCY);
let body: Value = refused.json().await?;
assert_eq!(body["error"]["code"], "unsupported_control");
client
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
.json(&json!({"prompt": "hello"}))
.send()
.await?
.error_for_status()?;
let total = wait_for_usage_total(&manager, &thread_id).await?;
assert_eq!(total.payload["source"], "agent reported");
assert_eq!(total.payload["cost"], "unknown");
assert_eq!(total.payload["tokens"]["total_tokens"], 150);
let log = received(&cwd);
let prompt = log
.iter()
.find(|e| e["method"] == "session/prompt")
.unwrap();
assert_eq!(prompt["state"]["model"], chosen);
let stale = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"workspace": cwd,
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": "model-z"}}
}))
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
let stale_id = stale["id"].as_str().unwrap();
let response = client
.post(format!("http://{addr}/v1/threads/{stale_id}/turns"))
.json(&json!({"prompt": "x"}))
.send()
.await?;
assert_eq!(response.status(), HttpStatus::FAILED_DEPENDENCY);
let body: Value = response.json().await?;
assert_eq!(body["error"]["code"], "choice_not_offered");
handle.abort();
Ok(())
}
+668 -11
View File
@@ -22,6 +22,7 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, sync_channel};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
@@ -32,6 +33,21 @@ const METHOD_NOT_FOUND: i64 = -32601;
pub const MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024;
pub const AUTH_REQUIRED: i64 = -32000;
const ABORT_POLL: Duration = Duration::from_millis(25);
pub const MAX_CONTROLS: usize = 32;
pub const MAX_CHOICES: usize = 256;
pub const MAX_VALUE_BYTES: usize = 128;
const MAX_NAME_CHARS: usize = 200;
const MAX_DESCRIPTION_CHARS: usize = 400;
pub mod refusal {
pub const CONTROL_NOT_OFFERED: &str = "control_not_offered";
pub const CONTROL_UNAVAILABLE: &str = "control_unavailable";
pub const CHOICE_NOT_OFFERED: &str = "choice_not_offered";
pub const CHOICE_REJECTED: &str = "choice_rejected";
pub const UNSUPPORTED_CONTROL: &str = "unsupported_control";
pub const CONTROL_TYPE_UNSUPPORTED: &str = "control_type_unsupported";
pub const CONTROL_HAS_NO_CHOICES: &str = "control_has_no_choices";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionPolicy {
@@ -117,6 +133,482 @@ pub struct AgentIdentity {
pub protocol_version: u64,
pub load_session: bool,
pub resume_session: bool,
pub capabilities: AgentCapabilities,
}
/// What the agent said it can do at `initialize`; absent means not reported.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct AgentCapabilities {
pub load_session: bool,
pub resume_session: bool,
pub fork_session: bool,
pub list_sessions: bool,
pub prompt_image: bool,
pub prompt_audio: bool,
pub prompt_embedded_context: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Choice {
pub value: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ControlKind {
Model,
Mode,
Config,
}
/// The one ACP method that writes a control, so a choice is never sent through
/// a different door than the agent offered it on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "method")]
pub enum ControlWrite {
#[serde(rename = "session/set_config_option")]
SetConfigOption { config_id: String },
#[serde(rename = "session/set_model")]
SetModel,
#[serde(rename = "session/set_mode")]
SetMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ControlRefusal {
pub code: &'static str,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Control {
pub id: String,
pub kind: ControlKind,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current: Option<String>,
pub choices: Vec<Choice>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub write: Option<ControlWrite>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable: Option<ControlRefusal>,
}
impl Control {
pub fn offers(&self, value: &str) -> bool {
self.unavailable.is_none() && self.choices.iter().any(|choice| choice.value == value)
}
}
/// Everything a session handshake offered, bounded. Choices are the only values
/// a caller may write; names and descriptions are display text.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct SessionOffer {
pub controls: Vec<Control>,
pub malformed_controls: usize,
}
impl SessionOffer {
pub fn control(&self, id: &str) -> Option<&Control> {
self.controls.iter().find(|control| control.id == id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ActionSupport {
pub action: &'static str,
pub supported: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_reports: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OptionsAgent {
pub id: String,
pub name: String,
pub version: String,
pub protocol_version: u64,
}
/// What `GET /v1/agents/external/{id}/options` answers: the agent's own offer
/// for one workspace, and which Codewhale actions it will and will not carry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AgentOptions {
pub agent: OptionsAgent,
pub cwd: String,
pub capabilities: AgentCapabilities,
pub controls: Vec<Control>,
pub malformed_controls: usize,
pub actions: Vec<ActionSupport>,
}
impl AgentOptions {
pub fn new(identity: &AgentIdentity, id: &str, cwd: &Path, offer: SessionOffer) -> Self {
let actions = action_support(&identity.capabilities, &offer);
Self {
agent: OptionsAgent {
id: id.to_string(),
name: identity.name.clone(),
version: identity.version.clone(),
protocol_version: identity.protocol_version,
},
cwd: cwd.display().to_string(),
capabilities: identity.capabilities.clone(),
controls: offer.controls,
malformed_controls: offer.malformed_controls,
actions,
}
}
}
fn action_support(capabilities: &AgentCapabilities, offer: &SessionOffer) -> Vec<ActionSupport> {
let offered = |kind: ControlKind| {
offer
.controls
.iter()
.any(|control| control.kind == kind && control.unavailable.is_none())
};
let choice = |action, kind| {
let supported = offered(kind);
ActionSupport {
action,
supported,
code: (!supported).then_some(refusal::CONTROL_NOT_OFFERED),
reason: (!supported).then_some("The agent did not offer this control."),
agent_reports: None,
}
};
let refused = |action, reason, agent_reports| ActionSupport {
action,
supported: false,
code: Some(refusal::UNSUPPORTED_CONTROL),
reason: Some(reason),
agent_reports,
};
vec![
choice("model_choice", ControlKind::Model),
choice("mode_choice", ControlKind::Mode),
choice("config_choice", ControlKind::Config),
refused(
"steering",
"Agent Client Protocol has no way to steer a running turn; interrupt it and send a new message.",
None,
),
refused(
"compaction",
"Codewhale cannot compact another agent's context.",
None,
),
refused(
"fork",
"Codewhale does not fork another agent's session.",
Some(capabilities.fork_session),
),
refused(
"images",
"Codewhale sends text prompts to another agent only.",
Some(capabilities.prompt_image),
),
refused(
"dynamic_tools",
"The agent uses its own tools; Codewhale tools cannot be added.",
None,
),
refused(
"output_limit",
"Codewhale cannot cap another agent's output tokens.",
None,
),
refused(
"provider_override",
"Codewhale providers do not apply; choose the agent's own model.",
None,
),
refused(
"remote_placement",
"The agent runs on this computer only.",
None,
),
]
}
pub struct NewSession {
pub session_id: String,
pub offer: SessionOffer,
}
/// Token usage an agent put on a prompt answer. Absent fields are unknown.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct AgentTokenUsage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub thought_tokens: Option<u64>,
pub cached_read_tokens: Option<u64>,
pub cached_write_tokens: Option<u64>,
}
impl AgentTokenUsage {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
fn from_result(result: &Value) -> Option<Self> {
let usage = result.get("usage")?.as_object()?;
let field = |name: &str| usage.get(name).and_then(Value::as_u64);
let parsed = Self {
input_tokens: field("inputTokens"),
output_tokens: field("outputTokens"),
total_tokens: field("totalTokens"),
thought_tokens: field("thoughtTokens"),
cached_read_tokens: field("cachedReadTokens"),
cached_write_tokens: field("cachedWriteTokens"),
};
(!parsed.is_empty()).then_some(parsed)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptReply {
pub stop_reason: String,
pub usage: Option<AgentTokenUsage>,
}
fn bounded_text(value: Option<&Value>, max_chars: usize) -> Option<String> {
let text: String = value?
.as_str()?
.chars()
.filter(|ch| !ch.is_control())
.take(max_chars)
.collect();
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
fn offered_value(value: Option<&Value>) -> Option<String> {
let value = value?.as_str()?;
(!value.is_empty() && value.len() <= MAX_VALUE_BYTES && !value.chars().any(char::is_control))
.then(|| value.to_string())
}
fn push_choice(
choices: &mut Vec<Choice>,
truncated: &mut bool,
value: Option<String>,
name: Option<&Value>,
description: Option<&Value>,
) {
let Some(value) = value else {
*truncated = true;
return;
};
if choices.iter().any(|choice| choice.value == value) {
return;
}
if choices.len() >= MAX_CHOICES {
*truncated = true;
return;
}
choices.push(Choice {
name: bounded_text(name, MAX_NAME_CHARS).unwrap_or_else(|| value.clone()),
description: bounded_text(description, MAX_DESCRIPTION_CHARS),
value,
});
}
fn config_choices(options: &[Value]) -> (Vec<Choice>, bool) {
let mut choices = Vec::new();
let mut truncated = false;
for option in options {
if let Some(group) = option.get("options").and_then(Value::as_array) {
for grouped in group {
push_choice(
&mut choices,
&mut truncated,
offered_value(grouped.get("value")),
grouped.get("name"),
grouped.get("description"),
);
}
} else {
push_choice(
&mut choices,
&mut truncated,
offered_value(option.get("value")),
option.get("name"),
option.get("description"),
);
}
}
(choices, truncated)
}
impl SessionOffer {
/// Parse the `modes`, `models` and `configOptions` a `session/new`,
/// `session/load` or `session/resume` answer carries. A config option that
/// names a model or mode owns that control; the older `models` and `modes`
/// blocks fill only what no config option claimed.
pub fn from_session_result(result: &Value) -> Self {
let mut offer = Self::default();
let mut model_claimed = false;
let mut mode_claimed = false;
let options = result
.get("configOptions")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
for option in options {
if offer.controls.len() >= MAX_CONTROLS {
offer.malformed_controls += 1;
continue;
}
let Some(config_id) = offered_value(option.get("id")) else {
offer.malformed_controls += 1;
continue;
};
let category = bounded_text(option.get("category"), 64);
let kind = if !model_claimed
&& (config_id == "model" || category.as_deref() == Some("model"))
{
model_claimed = true;
ControlKind::Model
} else if !mode_claimed && (config_id == "mode" || category.as_deref() == Some("mode"))
{
mode_claimed = true;
ControlKind::Mode
} else {
ControlKind::Config
};
let id = match kind {
ControlKind::Model => "model".to_string(),
ControlKind::Mode => "mode".to_string(),
ControlKind::Config => format!("config:{config_id}"),
};
if offer.control(&id).is_some() {
offer.malformed_controls += 1;
continue;
}
let is_select = option.get("type").and_then(Value::as_str) == Some("select");
let (choices, truncated) = if is_select {
config_choices(
option
.get("options")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default(),
)
} else {
(Vec::new(), false)
};
let unavailable = if !is_select {
Some(ControlRefusal {
code: refusal::CONTROL_TYPE_UNSUPPORTED,
reason: "Codewhale can only write select-type agent options.".to_string(),
})
} else if choices.is_empty() {
Some(ControlRefusal {
code: refusal::CONTROL_HAS_NO_CHOICES,
reason: "The agent offered this option with no choices.".to_string(),
})
} else {
None
};
offer.controls.push(Control {
name: bounded_text(option.get("name"), MAX_NAME_CHARS)
.unwrap_or_else(|| config_id.clone()),
description: bounded_text(option.get("description"), MAX_DESCRIPTION_CHARS),
category,
current: offered_value(option.get("currentValue")),
write: unavailable
.is_none()
.then_some(ControlWrite::SetConfigOption { config_id }),
id,
kind,
choices,
truncated,
unavailable,
});
}
if !model_claimed
&& let Some(models) = result.get("models").filter(|value| value.is_object())
{
offer.push_legacy(
ControlKind::Model,
models.get("currentModelId"),
models.get("availableModels"),
"modelId",
ControlWrite::SetModel,
);
}
if !mode_claimed && let Some(modes) = result.get("modes").filter(|value| value.is_object())
{
offer.push_legacy(
ControlKind::Mode,
modes.get("currentModeId"),
modes.get("availableModes"),
"id",
ControlWrite::SetMode,
);
}
offer
}
fn push_legacy(
&mut self,
kind: ControlKind,
current: Option<&Value>,
available: Option<&Value>,
value_key: &str,
write: ControlWrite,
) {
let mut choices = Vec::new();
let mut truncated = false;
for entry in available
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
push_choice(
&mut choices,
&mut truncated,
offered_value(entry.get(value_key)),
entry.get("name"),
entry.get("description"),
);
}
if choices.is_empty() {
self.malformed_controls += 1;
return;
}
let (id, name) = match kind {
ControlKind::Model => ("model", "Model"),
_ => ("mode", "Mode"),
};
self.controls.push(Control {
id: id.to_string(),
kind,
name: name.to_string(),
description: None,
category: None,
current: offered_value(current),
choices,
truncated,
write: Some(write),
unavailable: None,
});
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -456,7 +948,25 @@ impl AcpClient {
.and_then(Value::as_u64)
.filter(|version| *version == 1)
.ok_or_else(|| AcpError::new("the agent did not negotiate ACP protocol version 1"))?;
let flag = |pointer: &str| {
capabilities
.pointer(pointer)
.is_some_and(|value| !value.is_null() && value != &Value::Bool(false))
};
let capability_report = AgentCapabilities {
load_session: capabilities
.get("loadSession")
.and_then(Value::as_bool)
.unwrap_or(false),
resume_session: flag("/sessionCapabilities/resume"),
fork_session: flag("/sessionCapabilities/fork"),
list_sessions: flag("/sessionCapabilities/list"),
prompt_image: flag("/promptCapabilities/image"),
prompt_audio: flag("/promptCapabilities/audio"),
prompt_embedded_context: flag("/promptCapabilities/embeddedContext"),
};
Ok(AgentIdentity {
capabilities: capability_report,
name: info
.get("name")
.and_then(Value::as_str)
@@ -478,7 +988,7 @@ impl AcpClient {
})
}
pub fn new_session(&mut self, cwd: &Path, timeout: Duration) -> Result<String, AcpError> {
pub fn new_session(&mut self, cwd: &Path, timeout: Duration) -> Result<NewSession, AcpError> {
let deadline = Instant::now() + timeout;
let result = self.request(
"session/new",
@@ -488,11 +998,15 @@ impl AcpClient {
&mut DiscardHandler,
None,
)?;
result
let session_id = result
.get("sessionId")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| AcpError::new("session/new did not name a session"))
.ok_or_else(|| AcpError::new("session/new did not name a session"))?;
Ok(NewSession {
session_id,
offer: SessionOffer::from_session_result(&result),
})
}
pub fn reopen_session(
@@ -501,7 +1015,7 @@ impl AcpClient {
session: &str,
cwd: &Path,
timeout: Duration,
) -> Result<(), AcpError> {
) -> Result<SessionOffer, AcpError> {
let method = if identity.resume_session {
"session/resume"
} else if identity.load_session {
@@ -520,6 +1034,45 @@ impl AcpClient {
&mut DiscardHandler,
None,
)
.map(|result| SessionOffer::from_session_result(&result))
}
/// Write one offered choice through the method its control was offered on.
pub fn apply_control(
&mut self,
session: &str,
control: &Control,
value: &str,
timeout: Duration,
) -> Result<(), AcpError> {
let deadline = Instant::now() + timeout;
let (method, params) = match control.write.as_ref() {
Some(ControlWrite::SetConfigOption { config_id }) => (
"session/set_config_option",
json!({"sessionId": session, "configId": config_id, "value": value}),
),
Some(ControlWrite::SetModel) => (
"session/set_model",
json!({"sessionId": session, "modelId": value}),
),
Some(ControlWrite::SetMode) => (
"session/set_mode",
json!({"sessionId": session, "modeId": value}),
),
None => {
return Err(AcpError::new(
"the agent did not offer this control for writing",
));
}
};
self.request(
method,
params,
Some(session),
deadline,
&mut DiscardHandler,
None,
)
.map(|_| ())
}
@@ -549,7 +1102,7 @@ impl AcpClient {
timeout: Duration,
handler: &mut dyn AcpHandler,
cancel: Option<&CancellationToken>,
) -> Result<String, AcpError> {
) -> Result<PromptReply, AcpError> {
let deadline = Instant::now() + timeout;
let result = self.request(
"session/prompt",
@@ -559,12 +1112,16 @@ impl AcpClient {
handler,
cancel,
)?;
result
let stop_reason = result
.get("stopReason")
.and_then(Value::as_str)
.filter(|reason| !reason.is_empty())
.map(str::to_string)
.ok_or_else(|| AcpError::new("session/prompt did not report a stop reason"))
.ok_or_else(|| AcpError::new("session/prompt did not report a stop reason"))?;
Ok(PromptReply {
stop_reason,
usage: AgentTokenUsage::from_result(&result),
})
}
pub fn prompt(
@@ -575,7 +1132,7 @@ impl AcpClient {
timeout: Duration,
) -> Result<PromptOutcome, AcpError> {
let mut outcome = PromptOutcome::default();
let stop_reason = self.prompt_with(
let reply = self.prompt_with(
session,
text,
timeout,
@@ -585,7 +1142,7 @@ impl AcpClient {
},
None,
)?;
outcome.stop_reason = stop_reason;
outcome.stop_reason = reply.stop_reason;
Ok(outcome)
}
@@ -644,6 +1201,104 @@ mod tests {
request
}
#[test]
fn a_session_offer_is_parsed_bounded_and_never_widened() {
let many: Vec<Value> = (0..(MAX_CHOICES + 40))
.map(|index| json!({"value": format!("v{index}"), "name": "x".repeat(900)}))
.collect();
let offer = SessionOffer::from_session_result(&json!({
"configOptions": [
{"id": "model", "name": "Model", "category": "model", "type": "select",
"currentValue": "a",
"options": [{"group": "g", "name": "G", "options": [
{"value": "a", "name": "A"}, {"value": "a", "name": "dup"}, {"value": "b", "name": "B", "description": "d"}]},
{"value": "c", "name": "C"}]},
{"id": "flag", "name": "Flag", "type": "boolean", "currentValue": true},
{"id": "none", "name": "None", "type": "select", "options": []},
{"id": "", "name": "No id", "type": "select", "options": []},
{"id": "big", "name": "Big", "type": "select", "currentValue": "v0", "options": many},
{"id": "bad\nid", "name": "Control char", "type": "select", "options": [{"value": "x"}]}
],
"modes": {"currentModeId": "m1", "availableModes": [{"id": "m1", "name": "M1"}, {"id": "", "name": "bad"}]},
"models": {"currentModelId": "legacy", "availableModels": [{"modelId": "legacy", "name": "Legacy"}]}
}));
let ids: Vec<&str> = offer.controls.iter().map(|c| c.id.as_str()).collect();
assert_eq!(
ids,
["model", "config:flag", "config:none", "config:big", "mode"]
);
assert_eq!(offer.malformed_controls, 2);
let model = offer.control("model").unwrap();
let values: Vec<&str> = model.choices.iter().map(|c| c.value.as_str()).collect();
assert_eq!(
values,
["a", "b", "c"],
"groups flatten and duplicates collapse"
);
assert_eq!(model.choices[1].description.as_deref(), Some("d"));
assert!(model.offers("c") && !model.offers("legacy") && !model.offers(""));
assert_eq!(
offer
.control("config:flag")
.unwrap()
.unavailable
.as_ref()
.unwrap()
.code,
refusal::CONTROL_TYPE_UNSUPPORTED
);
assert!(!offer.control("config:flag").unwrap().offers("true"));
assert_eq!(
offer
.control("config:none")
.unwrap()
.unavailable
.as_ref()
.unwrap()
.code,
refusal::CONTROL_HAS_NO_CHOICES
);
let big = offer.control("config:big").unwrap();
assert_eq!(big.choices.len(), MAX_CHOICES);
assert!(big.truncated);
assert!(big.choices[0].name.chars().count() <= MAX_NAME_CHARS);
assert!(!big.offers(&format!("v{}", MAX_CHOICES + 1)));
let mode = offer.control("mode").unwrap();
assert_eq!(mode.write, Some(ControlWrite::SetMode));
assert_eq!(mode.choices.len(), 1);
assert!(
mode.truncated,
"a dropped malformed choice marks the list incomplete"
);
let legacy = SessionOffer::from_session_result(&json!({
"models": {"currentModelId": "legacy", "availableModels": [{"modelId": "legacy", "name": "Legacy"}]}
}));
assert_eq!(
legacy.control("model").unwrap().write,
Some(ControlWrite::SetModel)
);
assert!(
SessionOffer::from_session_result(&json!({}))
.controls
.is_empty()
);
}
#[test]
fn prompt_usage_keeps_only_what_the_agent_reported() {
let usage = AgentTokenUsage::from_result(&json!({
"stopReason": "end_turn",
"usage": {"inputTokens": 5, "outputTokens": "many", "totalTokens": 9}
}))
.unwrap();
assert_eq!(usage.input_tokens, Some(5));
assert_eq!(usage.output_tokens, None);
assert_eq!(usage.total_tokens, Some(9));
assert!(AgentTokenUsage::from_result(&json!({"stopReason": "end_turn"})).is_none());
assert!(AgentTokenUsage::from_result(&json!({"usage": {}})).is_none());
}
#[test]
fn a_json_rpc_worker_speaks_acp_end_to_end() {
let Some(_node) = node() else {
@@ -677,7 +1332,8 @@ mod tests {
let session = client
.new_session(workspace.path(), Duration::from_secs(20))
.unwrap();
.unwrap()
.session_id;
let allowed = client
.prompt(
&session,
@@ -821,7 +1477,8 @@ mod tests {
client.initialize(Duration::from_secs(20)).unwrap();
let session = client
.new_session(workspace.path(), Duration::from_secs(20))
.unwrap();
.unwrap()
.session_id;
let canceller = client.canceller(&session);
let prompt_session = session.clone();
let blocked = std::thread::spawn(move || {
@@ -1,7 +1,10 @@
use std::sync::mpsc as std_mpsc;
use super::*;
use crate::fleet::acp::{AcpCanceller, AcpClient, AcpHandler, AgentIdentity, PermissionAnswer};
use crate::fleet::acp::{
AcpCanceller, AcpClient, AcpError, AcpHandler, AgentIdentity, AgentOptions, AgentTokenUsage,
PermissionAnswer, PromptReply, SessionOffer, refusal,
};
use crate::fleet::agents::ExternalAgentLaunch;
use crate::fleet::host::{
FleetHostAdapter, FleetWorkerCommand, FleetWorkerStartRequest, FleetWorkerTransport,
@@ -19,6 +22,11 @@ const MAX_ID_BYTES: usize = 128;
const MAX_TOOL_ITEMS_PER_TURN: usize = 512;
const MAX_LOCATIONS: usize = 32;
const MAX_OPTIONS: usize = 16;
const MAX_CONCURRENT_OPTION_PROBES: usize = 4;
static OPTIONS_PROBES: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
std::sync::LazyLock::new(|| {
Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_OPTION_PROBES))
});
const KILLED_REASON: &str = "The agent did not stop; Codewhale ended its process.";
pub(crate) struct ExternalThreadState {
@@ -47,7 +55,7 @@ enum AgentSignal {
params: Value,
reply: std_mpsc::Sender<PermissionAnswer>,
},
Finished(Result<String, String>),
Finished(Result<PromptReply, String>),
}
pub(crate) struct ExternalSession {
@@ -145,6 +153,149 @@ struct SessionStart {
session_id: String,
}
struct AgentProcess {
host: Option<LocalProcessFleetHostAdapter>,
worker_id: String,
}
impl AgentProcess {
fn spawn(
log_root: &Path,
worker_id: String,
workspace: &Path,
launch: &ExternalAgentLaunch,
) -> Result<(Self, AcpClient)> {
let mut host = LocalProcessFleetHostAdapter::new(log_root);
let mut request = FleetWorkerStartRequest::new(
worker_id.clone(),
FleetWorkerCommand::new(launch.program.display().to_string(), launch.args.clone()),
);
request.transport = FleetWorkerTransport::JsonRpc;
request.cwd = Some(workspace.to_path_buf());
request.env_allowlist = launch.env.keys().cloned().collect();
request.env = launch.env.clone();
host.start_worker(request)
.map_err(|error| anyhow!("{} could not start: {error}", launch.agent_name))?;
let io = host.take_worker_io(&worker_id);
let process = Self {
host: Some(host),
worker_id,
};
let Some(io) = io else {
bail!("{} started without a protocol channel", launch.agent_name);
};
Ok((process, AcpClient::new(io)))
}
fn release(mut self) -> LocalProcessFleetHostAdapter {
self.host.take().expect("an agent process is released once")
}
fn stop(&mut self) {
if let Some(mut host) = self.host.take()
&& let Err(error) = host.stop_worker(&self.worker_id)
{
tracing::warn!(worker = %self.worker_id, %error, "stopping an external agent failed");
}
}
}
impl Drop for AgentProcess {
fn drop(&mut self) {
self.stop();
}
}
fn sign_in_or(
error: anyhow::Error,
agent_name: &str,
login: Option<&'static str>,
) -> anyhow::Error {
if error
.downcast_ref::<AcpError>()
.is_some_and(AcpError::is_auth_required)
{
let hint = login
.map(|command| format!(" Run `{command}` in a terminal, then try again."))
.unwrap_or_default();
return crate::fleet::agents::ExecutorUnavailable::new(
crate::fleet::agents::ExecutorUnavailable::NEEDS_SIGN_IN,
format!("{agent_name} needs you to sign in to it first.{hint}"),
)
.into();
}
if error
.downcast_ref::<crate::fleet::agents::ExecutorUnavailable>()
.is_some()
{
return error;
}
anyhow!("{agent_name}: {error}")
}
fn executor_choices(executor: &ExecutorRecord) -> Vec<(&'static str, String)> {
executor
.model
.iter()
.map(|model| ("model", model.clone()))
.collect()
}
fn refuse(code: &'static str, message: impl Into<String>) -> anyhow::Error {
crate::fleet::agents::ExecutorUnavailable::new(code, message).into()
}
fn apply_choices(
client: &mut AcpClient,
session_id: &str,
offer: &SessionOffer,
choices: &[(&'static str, String)],
) -> Result<()> {
for (id, value) in choices {
let shown = label(Some(&Value::String(value.clone())), "");
if offer.controls.is_empty() && *id == "model" {
client
.set_config_option(session_id, "model", value, SETUP_TIMEOUT)
.map_err(|error| rejected_choice(id, &shown, error))?;
continue;
}
let Some(control) = offer.control(id) else {
return Err(refuse(
refusal::CONTROL_NOT_OFFERED,
format!("The agent does not offer a {id} choice, so `{shown}` was not applied."),
));
};
if let Some(why) = control.unavailable.as_ref() {
return Err(refuse(refusal::CONTROL_UNAVAILABLE, why.reason.clone()));
}
if !control.offers(value) {
return Err(refuse(
refusal::CHOICE_NOT_OFFERED,
format!(
"The agent no longer offers `{shown}` for {id}; choose one of its current options."
),
));
}
if control.current.as_deref() == Some(value.as_str()) {
continue;
}
client
.apply_control(session_id, control, value, SETUP_TIMEOUT)
.map_err(|error| rejected_choice(id, &shown, error))?;
}
Ok(())
}
fn rejected_choice(id: &str, shown: &str, error: AcpError) -> anyhow::Error {
if error.is_auth_required() {
return error.into();
}
refuse(
refusal::CHOICE_REJECTED,
format!("The agent refused {id} `{shown}`: {error}"),
)
}
fn start_session_blocking(
log_root: PathBuf,
thread_id: String,
@@ -155,77 +306,44 @@ fn start_session_blocking(
abandoned: CancellationToken,
) -> Result<SessionStart> {
let worker_id = format!("acp-{thread_id}");
let mut host = LocalProcessFleetHostAdapter::new(&log_root);
let mut request = FleetWorkerStartRequest::new(
worker_id.clone(),
FleetWorkerCommand::new(launch.program.display().to_string(), launch.args.clone()),
);
request.transport = FleetWorkerTransport::JsonRpc;
request.cwd = Some(workspace.clone());
request.env_allowlist = launch.env.keys().cloned().collect();
request.env = launch.env.clone();
host.start_worker(request)
.map_err(|error| anyhow!("{} could not start: {error}", launch.agent_name))?;
let Some(io) = host.take_worker_io(&worker_id) else {
let _ = host.stop_worker(&worker_id);
bail!("{} started without a protocol channel", launch.agent_name);
};
let mut client = AcpClient::new(io);
let (process, mut client) =
AgentProcess::spawn(&log_root, worker_id.clone(), &workspace, &launch)?;
client.abort_requests_on(Some(abandoned));
let setup = (|| {
let choices = executor_choices(&executor);
let setup = (|| -> Result<(AgentIdentity, String)> {
let identity = client.initialize(SETUP_TIMEOUT)?;
let session_id = match executor.foreign_session_id.as_deref() {
let (session_id, offer) = match executor.foreign_session_id.as_deref() {
Some(session_id) => {
client.reopen_session(&identity, session_id, &workspace, SETUP_TIMEOUT)?;
session_id.to_string()
let offer =
client.reopen_session(&identity, session_id, &workspace, SETUP_TIMEOUT)?;
(session_id.to_string(), offer)
}
None => {
let started = client.new_session(&workspace, SETUP_TIMEOUT)?;
(started.session_id, started.offer)
}
None => client.new_session(&workspace, SETUP_TIMEOUT)?,
};
if let Some(model) = executor.model.as_deref() {
client.set_config_option(&session_id, "model", model, SETUP_TIMEOUT)?;
}
Ok::<_, crate::fleet::acp::AcpError>((identity, session_id))
apply_choices(&mut client, &session_id, &offer, &choices)?;
Ok((identity, session_id))
})();
let (identity, session_id) = match setup {
Ok(setup) => setup,
Err(error) => {
let _ = host.stop_worker(&worker_id);
if error.is_auth_required() {
let hint = login
.map(|command| format!(" Run `{command}` in a terminal, then try again."))
.unwrap_or_default();
return Err(crate::fleet::agents::ExecutorUnavailable::new(
crate::fleet::agents::ExecutorUnavailable::NEEDS_SIGN_IN,
format!(
"{} needs you to sign in to it first.{hint}",
launch.agent_name
),
)
.into());
}
bail!("{}: {error}", launch.agent_name);
}
};
let (identity, session_id) =
setup.map_err(|error| sign_in_or(error, &launch.agent_name, login))?;
if !valid_id(&session_id) {
let _ = host.stop_worker(&worker_id);
bail!("{} named an invalid session id", launch.agent_name);
}
client.abort_requests_on(None);
let canceller = client.canceller(&session_id);
let (commands, receiver) = std_mpsc::channel();
let session_for_thread = session_id.clone();
if let Err(error) = std::thread::Builder::new()
std::thread::Builder::new()
.name(format!("acp-{}", launch.agent_id))
.spawn(move || run_session(client, session_for_thread, receiver))
{
let _ = host.stop_worker(&worker_id);
bail!("starting the agent session thread failed: {error}");
}
.map_err(|error| anyhow!("starting the agent session thread failed: {error}"))?;
Ok(SessionStart {
session: ExternalSession {
commands: parking_lot::Mutex::new(Some(commands)),
canceller,
host: parking_lot::Mutex::new(host),
host: parking_lot::Mutex::new(process.release()),
worker_id,
},
identity,
@@ -233,6 +351,31 @@ fn start_session_blocking(
})
}
fn probe_options_blocking(
log_root: PathBuf,
workspace: PathBuf,
launch: ExternalAgentLaunch,
login: Option<&'static str>,
abandoned: CancellationToken,
) -> Result<AgentOptions> {
let worker_id = format!("acp-options-{}", runtime_record_id("probe"));
let (mut process, mut client) = AgentProcess::spawn(&log_root, worker_id, &workspace, &launch)?;
client.abort_requests_on(Some(abandoned));
let probed = (|| -> Result<AgentOptions> {
let identity = client.initialize(SETUP_TIMEOUT)?;
let started = client.new_session(&workspace, SETUP_TIMEOUT)?;
Ok(AgentOptions::new(
&identity,
&launch.agent_id,
&workspace,
started.offer,
))
})();
drop(client);
process.stop();
probed.map_err(|error| sign_in_or(error, &launch.agent_name, login))
}
fn valid_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_ID_BYTES
@@ -394,6 +537,8 @@ struct ExternalProjection {
plan: Option<TurnItemRecord>,
title: Option<String>,
failure: Option<String>,
context: Option<(Option<u64>, Option<u64>)>,
tokens: Option<AgentTokenUsage>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -637,6 +782,50 @@ impl RuntimeThreadManager {
Ok(session)
}
/// Ask an agent what it offers for one workspace, on a throwaway process
/// that is stopped before this returns, on every path.
pub async fn external_agent_options(&self, agent_id: &str, cwd: &Path) -> Result<AgentOptions> {
self.ensure_accepting_execution()?;
let agent = crate::fleet::agents::find(agent_id).ok_or_else(|| {
crate::fleet::agents::ExecutorUnavailable::new(
crate::fleet::agents::ExecutorUnavailable::UNKNOWN_AGENT,
format!("No external agent is named `{agent_id}`."),
)
})?;
let workspace = tokio::fs::canonicalize(cwd)
.await
.map_err(|error| anyhow!("cwd {} is unavailable: {error}", cwd.display()))?;
anyhow::ensure!(
tokio::fs::metadata(&workspace)
.await
.is_ok_and(|metadata| metadata.is_dir()),
"cwd {} is not a directory",
workspace.display()
);
let launch = crate::fleet::agents::launch(agent.id).await?;
let log_root = self
.session_store_binding()
.data_dir
.join("external-agents");
tokio::fs::create_dir_all(&log_root)
.await
.context("creating the external agent log directory failed")?;
let abandoned = self.cancel_token.child_token();
let _abandon = abandoned.clone().drop_guard();
let permit = tokio::select! {
permit = Arc::clone(&OPTIONS_PROBES).acquire_owned() => permit?,
() = abandoned.cancelled() => bail!("Runtime is shutting down; execution admission is closed"),
};
self.ensure_accepting_execution()?;
let login = agent.login;
tokio::task::spawn_blocking(move || {
let _permit = permit;
probe_options_blocking(log_root, workspace, launch, login, abandoned)
})
.await
.context("external agent options worker failed")?
}
pub(super) async fn external_thread_loaded(&self, thread_id: &str) -> bool {
self.active.lock().await.external.contains_key(thread_id)
}
@@ -724,27 +913,36 @@ impl RuntimeThreadManager {
matches!(input_source, RuntimeTurnInputSource::ExternalUser),
"this kind of turn is not available in a session with another agent"
);
anyhow::ensure!(
req.images.is_empty(),
"image inputs are not available in a session with another agent"
);
anyhow::ensure!(
req.dynamic_tools.is_empty(),
"dynamic tools are not available in a session with another agent"
);
anyhow::ensure!(
req.model.is_none()
&& req.reasoning_effort.is_none()
&& req.max_output_tokens.is_none()
&& req.model_provider.is_none()
&& req.model_provider_id.is_none()
&& req.account_model_owner.is_none(),
"a session with another agent uses its saved agent model; per-turn model controls are unavailable"
);
anyhow::ensure!(
req.allowed_tools.is_none(),
"a session with another agent uses that agent's own tools; tool allowlists are unavailable"
);
if !req.images.is_empty() {
return Err(refuse(
refusal::UNSUPPORTED_CONTROL,
"Image inputs are not available in a session with another agent.",
));
}
if !req.dynamic_tools.is_empty() {
return Err(refuse(
refusal::UNSUPPORTED_CONTROL,
"Dynamic tools are not available in a session with another agent.",
));
}
if req.model.is_some()
|| req.reasoning_effort.is_some()
|| req.max_output_tokens.is_some()
|| req.model_provider.is_some()
|| req.model_provider_id.is_some()
|| req.account_model_owner.is_some()
{
return Err(refuse(
refusal::UNSUPPORTED_CONTROL,
"A session with another agent keeps its saved agent model; per-turn model, reasoning, output and provider controls are unavailable.",
));
}
if req.allowed_tools.is_some() {
return Err(refuse(
refusal::UNSUPPORTED_CONTROL,
"A session with another agent uses that agent's own tools; tool allowlists are unavailable.",
));
}
if req.environment_id.is_some() {
return Err(crate::fleet::agents::ExecutorUnavailable::new(
crate::fleet::agents::ExecutorUnavailable::ENVIRONMENT_NOT_SUPPORTED,
@@ -1041,10 +1239,21 @@ impl RuntimeThreadManager {
};
let _ = reply.send(answer);
}
Some(AgentSignal::Finished(result)) => break result,
Some(AgentSignal::Finished(result)) => {
break result.map(|reply| {
projection.tokens = reply.usage;
reply.stop_reason
});
}
None => break Err("the agent session ended".to_string()),
}
};
if let Err(error) = self
.emit_agent_usage(&thread.id, &turn_id, &projection)
.await
{
tracing::warn!(thread_id = %thread.id, turn_id = %turn_id, %error, "recording an external agent's usage failed");
}
if let Err(error) = self
.settle_external_turn(&thread, &turn_id, projection, outcome)
.await
@@ -1053,6 +1262,32 @@ impl RuntimeThreadManager {
}
}
async fn emit_agent_usage(
&self,
thread_id: &str,
turn_id: &str,
projection: &ExternalProjection,
) -> Result<()> {
let tokens = projection
.tokens
.as_ref()
.filter(|tokens| !tokens.is_empty());
let reported = tokens.is_some() || projection.context.is_some();
let payload = json!({
"kind": "turn_total",
"source": if reported { "agent reported" } else { "none" },
"tokens": tokens.map_or_else(|| json!("unknown"), |tokens| json!(tokens)),
"context": projection.context.map_or_else(
|| json!("unknown"),
|(used, size)| json!({ "used": used, "size": size }),
),
"cost": "unknown",
});
self.emit_event(thread_id, Some(turn_id), None, "turn.agent_usage", payload)
.await?;
Ok(())
}
async fn open_external_item(
&self,
thread_id: &str,
@@ -1265,12 +1500,13 @@ impl RuntimeThreadManager {
let used = update.get("used").and_then(Value::as_u64);
let size = update.get("size").and_then(Value::as_u64);
if used.is_some() || size.is_some() {
projection.context = Some((used, size));
self.emit_event(
thread_id,
Some(turn_id),
None,
"turn.agent_usage",
json!({ "used": used, "size": size, "source": "agent reported", "cost": "unknown" }),
json!({ "kind": "context_window", "used": used, "size": size, "source": "agent reported", "cost": "unknown" }),
)
.await?;
}
@@ -715,3 +715,390 @@ async fn an_abandoned_external_startup_stops_its_agent_inside_the_shutdown_barri
);
Ok(())
}
fn options_fixture() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/acp-options-agent.mjs")
}
fn received(workspace: &Path) -> Vec<Value> {
std::fs::read_to_string(workspace.join("options-received.jsonl"))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
fn pid_alive(pid: u64) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn refusal_code(error: &anyhow::Error) -> Option<&'static str> {
error
.downcast_ref::<crate::fleet::agents::ExecutorUnavailable>()
.map(|refusal| refusal.code)
}
fn options_thread_request(workspace: &Path, model: &str) -> CreateThreadRequest {
let mut request = fixture_thread_request(workspace, "ask");
request.executor.as_mut().unwrap().config.model = Some(model.to_string());
request
}
#[cfg(unix)]
#[tokio::test]
async fn an_options_probe_reports_the_agents_own_offer_and_leaves_no_process() -> Result<()> {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
let _secret = crate::test_support::EnvVarGuard::set("CODEWHALE_TEST_SECRET", "must-not-travel");
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
let manager = test_manager(root.path().join("runtime"))?;
let options = manager
.external_agent_options(FIXTURE_AGENT_ID, &workspace)
.await?;
let json = serde_json::to_value(&options)?;
let controls: Vec<&str> = options.controls.iter().map(|c| c.id.as_str()).collect();
assert_eq!(
controls,
["model", "config:effort", "config:verbose", "mode"]
);
let model = &options.controls[0];
assert_eq!(model.current.as_deref(), Some("model-a"));
let values: Vec<&str> = model.choices.iter().map(|c| c.value.as_str()).collect();
assert_eq!(values, ["model-a", "model-b", "model-c"]);
assert_eq!(
json["controls"][0]["write"]["method"],
"session/set_config_option"
);
assert_eq!(json["controls"][3]["write"]["method"], "session/set_mode");
let verbose = &options.controls[2];
assert_eq!(
verbose.unavailable.as_ref().map(|u| u.code),
Some("control_type_unsupported")
);
assert!(verbose.write.is_none());
assert_eq!(json["capabilities"]["prompt_image"], true);
assert_eq!(json["capabilities"]["fork_session"], true);
let action = |name: &str| {
json["actions"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["action"] == name)
.cloned()
.unwrap()
};
assert_eq!(action("model_choice")["supported"], true);
assert_eq!(action("steering")["code"], "unsupported_control");
assert_eq!(action("images")["supported"], false);
assert_eq!(action("images")["agent_reports"], true);
let log = received(&workspace);
let pid = log[0]["pid"].as_u64().expect("the probe agent's pid");
assert!(!pid_alive(pid), "the probe's agent process was stopped");
assert!(log.iter().all(|entry| entry["method"] != "session/prompt"));
let dumped = std::fs::read_to_string(workspace.join("options-env.json"))?;
assert!(!dumped.contains("CODEWHALE_TEST_SECRET"));
let wrong = manager
.external_agent_options(FIXTURE_AGENT_ID, &workspace.join("missing"))
.await
.unwrap_err();
assert!(wrong.to_string().contains("unavailable"), "{wrong}");
let unknown = manager
.external_agent_options("nope", &workspace)
.await
.unwrap_err();
assert_eq!(refusal_code(&unknown), Some("unknown_agent"));
std::fs::write(
workspace.join("options-fixture.json"),
r#"{"auth_required":true}"#,
)?;
let unauthenticated = manager
.external_agent_options(FIXTURE_AGENT_ID, &workspace)
.await
.unwrap_err();
assert_eq!(refusal_code(&unauthenticated), Some("needs_sign_in"));
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn an_options_probe_ends_when_the_runtime_shuts_down() -> Result<()> {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
std::fs::write(workspace.join("options-fixture.json"), r#"{"hang":true}"#)?;
let manager = test_manager(root.path().join("runtime"))?;
let probing = manager.clone();
let probe_workspace = workspace.clone();
let probe = tokio::spawn(async move {
probing
.external_agent_options(FIXTURE_AGENT_ID, &probe_workspace)
.await
});
let deadline = Instant::now() + Duration::from_secs(30);
let pid = loop {
if let Some(pid) = received(&workspace)
.iter()
.find(|entry| entry["method"] == "session/new")
.and_then(|entry| entry["pid"].as_u64())
{
break pid;
}
anyhow::ensure!(
Instant::now() < deadline,
"the probe never reached session/new"
);
sleep(Duration::from_millis(20)).await;
};
assert!(pid_alive(pid));
manager.close_execution_admission().await;
let ended = tokio::time::timeout(Duration::from_secs(20), probe).await??;
assert!(ended.is_err());
let deadline = Instant::now() + Duration::from_secs(10);
while pid_alive(pid) {
anyhow::ensure!(
Instant::now() < deadline,
"the probe's agent outlived shutdown"
);
sleep(Duration::from_millis(50)).await;
}
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn a_chosen_model_reaches_the_agent_survives_reopen_and_usage_is_agent_reported() -> Result<()>
{
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
let manager = test_manager(root.path().join("runtime"))?;
let offered = manager
.external_agent_options(FIXTURE_AGENT_ID, &workspace)
.await?;
let chosen = &offered.controls[0].choices[1].value;
assert_eq!(chosen, "model-b");
let thread = manager
.create_thread(options_thread_request(&workspace, chosen))
.await?;
let turn = manager.start_turn(&thread.id, prompt("first")).await?;
let settled = wait_for_turn(&manager, &turn.id).await?;
assert_eq!(settled.status, RuntimeTurnStatus::Completed, "{settled:?}");
let detail = manager.get_thread_detail(&thread.id).await?;
let message = detail
.items
.iter()
.find(|item| item.kind == TurnItemKind::AgentMessage)
.and_then(|item| item.detail.clone())
.unwrap_or_default();
assert!(message.contains("model=model-b"), "{message}");
let usage: Vec<_> = events(&manager, &thread.id)
.await?
.into_iter()
.filter(|event| event.event == "turn.agent_usage")
.collect();
let window = usage
.iter()
.find(|event| event.payload["kind"] == "context_window")
.expect("a context window update");
assert_eq!(window.payload["used"], 1200);
assert_eq!(window.payload["size"], 200000);
let total = usage
.iter()
.find(|event| event.payload["kind"] == "turn_total")
.expect("a turn total");
assert_eq!(total.payload["source"], "agent reported");
assert_eq!(total.payload["cost"], "unknown");
assert_eq!(total.payload["tokens"]["total_tokens"], 150);
assert_eq!(total.payload["tokens"]["input_tokens"], 100);
assert_eq!(total.payload["context"]["used"], 1200);
assert!(manager.stop_external_agent(&thread.id).await?);
let reopened = manager.start_turn(&thread.id, prompt("second")).await?;
let settled = wait_for_turn(&manager, &reopened.id).await?;
assert_eq!(settled.status, RuntimeTurnStatus::Completed, "{settled:?}");
let log = received(&workspace);
let sets: Vec<_> = log
.iter()
.filter(|entry| entry["method"] == "session/set_config_option")
.collect();
assert_eq!(sets.len(), 2, "{log:?}");
assert!(sets.iter().all(|entry| entry["value"] == "model-b"));
assert_ne!(
sets[0]["pid"], sets[1]["pid"],
"a new agent process took the saved choice"
);
let prompts: Vec<_> = log
.iter()
.filter(|entry| entry["method"] == "session/prompt")
.collect();
assert_eq!(prompts.len(), 2);
assert!(log.iter().any(|entry| entry["method"] == "session/load"));
assert_eq!(prompts[1]["state"]["model"], "model-b");
assert_eq!(
manager
.store
.load_thread(&thread.id)?
.executor
.and_then(|record| record.model)
.as_deref(),
Some("model-b")
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn an_agent_that_reports_no_usage_says_unknown() -> Result<()> {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
std::fs::write(
workspace.join("options-fixture.json"),
r#"{"no_usage":true}"#,
)?;
let manager = test_manager(root.path().join("runtime"))?;
let thread = manager
.create_thread(fixture_thread_request(&workspace, "ask"))
.await?;
let turn = manager.start_turn(&thread.id, prompt("quiet")).await?;
wait_for_turn(&manager, &turn.id).await?;
let events = events(&manager, &thread.id).await?;
let usage: Vec<_> = events
.iter()
.filter(|event| event.event == "turn.agent_usage")
.collect();
assert_eq!(usage.len(), 1, "{usage:?}");
assert_eq!(usage[0].payload["tokens"], "unknown");
assert_eq!(usage[0].payload["context"], "unknown");
assert_eq!(usage[0].payload["source"], "none");
assert_eq!(usage[0].payload["cost"], "unknown");
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn a_stale_or_withdrawn_choice_is_refused_before_any_prompt() -> Result<()> {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
let manager = test_manager(root.path().join("runtime"))?;
let invented = manager
.create_thread(options_thread_request(&workspace, "model-z"))
.await?;
let error = manager
.start_turn(&invented.id, prompt("never"))
.await
.unwrap_err();
assert_eq!(
refusal_code(&error),
Some("choice_not_offered"),
"{error:#}"
);
std::fs::write(
workspace.join("options-fixture.json"),
r#"{"withdraw":["model-b"]}"#,
)?;
let withdrawn = manager
.create_thread(options_thread_request(&workspace, "model-b"))
.await?;
let error = manager
.start_turn(&withdrawn.id, prompt("never"))
.await
.unwrap_err();
assert_eq!(
refusal_code(&error),
Some("choice_not_offered"),
"{error:#}"
);
let log = received(&workspace);
assert!(log.iter().all(|entry| entry["method"] != "session/prompt"));
assert!(
log.iter()
.all(|entry| entry["method"] != "session/set_config_option")
);
for pid in log.iter().filter_map(|entry| entry["pid"].as_u64()) {
assert!(!pid_alive(pid), "a refused setup left agent {pid} running");
}
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn per_turn_controls_an_agent_cannot_honor_are_typed_refusals() -> Result<()> {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
let manager = test_manager(root.path().join("runtime"))?;
let thread = manager
.create_thread(fixture_thread_request(&workspace, "ask"))
.await?;
for request in [
StartTurnRequest {
allowed_tools: Some(Vec::new()),
..prompt("x")
},
StartTurnRequest {
model: Some("model-b".to_string()),
..prompt("x")
},
StartTurnRequest {
reasoning_effort: Some("high".to_string()),
..prompt("x")
},
] {
let error = manager.start_turn(&thread.id, request).await.unwrap_err();
assert_eq!(
refusal_code(&error),
Some("unsupported_control"),
"{error:#}"
);
}
assert!(
received(&workspace).is_empty(),
"no agent was started for a refused turn"
);
Ok(())
}
+165
View File
@@ -0,0 +1,165 @@
// A deterministic Agent Client Protocol agent that advertises its own models,
// a mode, a config option and one control Codewhale cannot write, and reports
// token usage. Behavior is steered by `options-fixture.json` in the session
// cwd; everything it receives is appended to `options-received.jsonl` there.
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const plan = () => {
const path = join(process.cwd(), 'options-fixture.json');
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
};
const record = (entry) =>
appendFileSync(join(process.cwd(), 'options-received.jsonl'), `${JSON.stringify({ pid: process.pid, ...entry })}\n`);
writeFileSync(join(process.cwd(), 'options-env.json'), JSON.stringify(process.env));
const MODELS = [
{ value: 'model-a', name: 'Model A', description: 'Fast' },
{ value: 'model-b', name: 'Model B', description: 'Careful' },
{ value: 'model-c', name: 'Model C' },
];
const EFFORTS = [
{ value: 'low', name: 'Low' },
{ value: 'high', name: 'High' },
];
const MODES = [
{ id: 'default', name: 'Default' },
{ id: 'review', name: 'Review only', description: 'Reads, never edits' },
];
let buffer = '';
let sessionId = null;
const state = { model: 'model-a', effort: 'low', mode: 'default' };
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
const result = (id, value) => send({ jsonrpc: '2.0', id, result: value });
const failure = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
const notify = (method, params) => send({ jsonrpc: '2.0', method, params });
function offer() {
const withdrawn = new Set(plan().withdraw ?? []);
const models = MODELS.filter((model) => !withdrawn.has(model.value));
const configOptions = [
{ id: 'model', name: 'Model', category: 'model', type: 'select', currentValue: state.model, options: models },
{ id: 'effort', name: 'Reasoning effort', category: 'thought_level', type: 'select', currentValue: state.effort, options: EFFORTS },
{ id: 'verbose', name: 'Verbose logging', type: 'boolean', currentValue: false },
];
const reply = {
configOptions,
modes: { currentModeId: state.mode, availableModes: MODES },
};
if (plan().malformed) {
reply.configOptions.push({ id: '', name: 'No id', type: 'select', currentValue: 'x', options: [] });
reply.configOptions.push({ id: 'empty', name: 'Empty', type: 'select', currentValue: 'x', options: [] });
}
if (plan().huge) {
reply.configOptions.push({
id: 'many',
name: 'Many',
type: 'select',
currentValue: 'v0',
options: Array.from({ length: 600 }, (_, index) => ({ value: `v${index}`, name: `Value ${index}` })),
});
}
return reply;
}
function offered(config, value) {
const found = offer().configOptions.find((option) => option.id === config);
return Boolean(found && found.type === 'select' && found.options.some((option) => option.value === value));
}
function prompt(request) {
const text = (request.params?.prompt ?? []).map((block) => block.text ?? '').join('');
record({ method: 'session/prompt', text, state: { ...state } });
notify('session/update', {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: `model=${state.model} effort=${state.effort} mode=${state.mode}` },
},
});
if (!plan().no_usage) {
notify('session/update', { sessionId, update: { sessionUpdate: 'usage_update', used: 1200, size: 200000 } });
}
result(request.id, {
stopReason: 'end_turn',
...(plan().no_usage ? {} : { usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150, thoughtTokens: 7 } }),
});
}
function handle(message) {
if (message.id !== undefined && message.method === undefined) return;
const params = message.params ?? {};
switch (message.method) {
case 'initialize':
result(message.id, {
protocolVersion: 1,
agentInfo: { name: 'options-fixture', version: '1.0.0' },
authMethods: [],
agentCapabilities: {
loadSession: true,
promptCapabilities: { image: true, embeddedContext: true },
sessionCapabilities: { fork: {} },
},
});
break;
case 'session/new':
if (plan().auth_required) {
failure(message.id, -32000, 'Authentication required');
break;
}
sessionId = `options-session-${process.pid}`;
record({ method: 'session/new', cwd: params.cwd });
if (plan().hang) break;
result(message.id, { sessionId, ...offer() });
break;
case 'session/load':
case 'session/resume':
sessionId = params.sessionId ?? null;
record({ method: message.method, sessionId });
result(message.id, offer());
break;
case 'session/set_config_option':
record({ method: message.method, configId: params.configId, value: params.value });
if (!offered(params.configId, params.value)) {
failure(message.id, -32602, 'value not offered');
break;
}
state[params.configId] = params.value;
result(message.id, { configOptions: offer().configOptions });
break;
case 'session/set_mode':
record({ method: message.method, modeId: params.modeId });
if (!MODES.some((mode) => mode.id === params.modeId)) {
failure(message.id, -32602, 'mode not offered');
break;
}
state.mode = params.modeId;
result(message.id, {});
break;
case 'session/prompt':
prompt(message);
break;
case 'session/cancel':
break;
default:
if (message.id !== undefined) failure(message.id, -32601, `no method ${message.method}`);
}
}
process.stdin.on('data', (chunk) => {
buffer += chunk;
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
if (!line.trim()) continue;
try {
handle(JSON.parse(line));
} catch (error) {
process.stderr.write(`options fixture: bad line: ${String(error)}\n`);
}
}
});
process.stdin.on('end', () => process.exit(0));