use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use bitrouter_sdk::acp::transport::{AcpAgentConfig, AcpTransport};
use bitrouter_sdk::config::Config;
use futures::StreamExt;
use serde::Serialize;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use agent_client_protocol::schema::v1::{Cost, LlmProtocol, SessionUpdate};
use bitrouter_sdk::acp::client::{AcpClient, ClientOptions, PendingPermission};
use bitrouter_sdk::acp::controller::{
RouteControl as AcpRouteControl, RouteControlError, RouteControlState,
SessionCost as AcpSessionCost,
};
use bitrouter_sdk::acp::translate::{SessionUpdateKind, translate};
use bitrouter_tui::permission::{Decision, Mode, Policy, Prompt as PermissionPrompt};
use crate::chat::effects::Wire;
use crate::paths::ConfigSource;
#[derive(clap::Args, Debug, Clone, Default)]
pub struct RoutingOptions {
#[arg(long)]
pub direct: bool,
#[arg(long)]
pub base_url: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub no_start: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum PromptFormat {
#[default]
Json,
Text,
Quiet,
}
#[derive(clap::Args, Debug, Clone, Default, PartialEq, Eq)]
pub struct HeadlessOptions {
#[arg(long, conflicts_with_all = ["approve_reads", "deny_all"])]
pub approve_all: bool,
#[arg(long, conflicts_with_all = ["approve_all", "deny_all"])]
pub approve_reads: bool,
#[arg(long, conflicts_with_all = ["approve_all", "approve_reads"])]
pub deny_all: bool,
#[arg(long, value_name = "JSON|@PATH")]
pub permission_policy: Option<String>,
#[arg(long, value_enum, default_value_t)]
pub format: PromptFormat,
}
#[derive(serde::Deserialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PolicyFile {
#[serde(default)]
auto_approve: Vec<String>,
#[serde(default)]
auto_deny: Vec<String>,
default_action: Option<PolicyAction>,
}
#[derive(serde::Deserialize, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum PolicyAction {
Approve,
Deny,
}
impl HeadlessOptions {
pub fn policy(&self) -> Result<Policy> {
let mode = if self.approve_all {
Mode::ApproveAll
} else if self.approve_reads {
Mode::ApproveReads
} else {
Mode::DenyAll
};
let file = match self.permission_policy.as_deref() {
None => PolicyFile::default(),
Some(flag) => {
let text = match flag.strip_prefix('@') {
Some(path) => std::fs::read_to_string(path)
.with_context(|| format!("reading --permission-policy from {path}"))?,
None => flag.to_string(),
};
serde_json::from_str(&text).context("--permission-policy is not a valid policy")?
}
};
Ok(Policy {
mode,
auto_approve: file.auto_approve,
auto_deny: file.auto_deny,
default_action: file.default_action.map(|action| match action {
PolicyAction::Approve => Decision::Approve,
PolicyAction::Deny => Decision::Deny,
}),
})
}
}
#[derive(Default)]
pub struct PromptOptions {
pub no_wait: bool,
pub contract: Option<crate::result_contract::ResultContract>,
pub policy: Policy,
pub format: PromptFormat,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct PermissionTally {
approved: usize,
denied: usize,
}
impl PermissionTally {
fn record(&mut self, decision: Decision) {
match decision {
Decision::Approve => self.approved += 1,
Decision::Deny => self.denied += 1,
}
}
pub fn exit_code(&self) -> i32 {
if self.denied > 0 && self.approved == 0 {
5
} else {
0
}
}
}
pub struct SpawnContext<'a> {
pub source: &'a ConfigSource,
pub config: Config,
pub agent_id: &'a str,
pub options: LaunchOptions,
pub routing: RoutingOptions,
}
#[derive(Debug)]
pub enum RoutingError {
DaemonUnreachable {
via: String,
},
AuthRequired {
via: String,
},
EndpointConfiguration {
via: String,
message: String,
},
}
impl std::fmt::Display for RoutingError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}\n hint: {}", self.message(), self.hint())
}
}
impl std::error::Error for RoutingError {}
impl RoutingError {
fn code(&self) -> &'static str {
match self {
RoutingError::DaemonUnreachable { .. } => "daemon_unreachable",
RoutingError::AuthRequired { .. } => "auth_required",
RoutingError::EndpointConfiguration { .. } => "endpoint_configuration",
}
}
fn via(&self) -> &str {
match self {
RoutingError::DaemonUnreachable { via }
| RoutingError::AuthRequired { via }
| RoutingError::EndpointConfiguration { via, .. } => via,
}
}
fn hint(&self) -> &'static str {
match self {
RoutingError::DaemonUnreachable { .. } => "run `bitrouter start`, or pass --direct",
RoutingError::AuthRequired { .. } => {
"export BITROUTER_API_KEY (or create a key), or pass --direct"
}
RoutingError::EndpointConfiguration { .. } => {
"check the pinned ACP adapter configuration, or pass --direct"
}
}
}
fn message(&self) -> String {
match self {
RoutingError::DaemonUnreachable { via } => {
format!("BitRouter daemon unreachable at {via}")
}
RoutingError::AuthRequired { via } => {
format!("daemon at {via} requires auth but no BITROUTER_API_KEY is set")
}
RoutingError::EndpointConfiguration { message, .. } => message.clone(),
}
}
fn ndjson(&self) -> serde_json::Value {
serde_json::json!({
"type": "error",
"code": self.code(),
"via": self.via(),
"hint": self.hint(),
"message": self.message(),
})
}
}
#[derive(Debug, Clone, Default)]
pub struct Routed {
pub via: Option<String>,
pub launch_id: Option<String>,
pub controller_instance_id: Option<String>,
pub api_principal: Option<String>,
pub endpoint_plan: Option<crate::harness::HarnessEndpointPlan>,
}
pub async fn apply_routing(
source: &ConfigSource,
config: &mut Config,
agent_id: &str,
opts: &RoutingOptions,
) -> std::result::Result<Routed, RoutingError> {
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
apply_routing_with_cloud_credentials(source, config, agent_id, opts, &cloud_credentials).await
}
async fn apply_routing_with_cloud_credentials(
source: &ConfigSource,
config: &mut Config,
agent_id: &str,
opts: &RoutingOptions,
cloud_credentials: &crate::cloud::StandaloneCloudCredentials,
) -> std::result::Result<Routed, RoutingError> {
if !config.agents.contains_key(agent_id)
&& let Some(h) = crate::harness::by_id(agent_id)
&& let Some(command) = h.acp_command
{
config.agents.insert(
agent_id.to_string(),
AcpAgentConfig {
name: agent_id.to_string(),
transport: AcpTransport::Stdio {
command: command.to_string(),
args: h.acp_args.iter().map(|s| s.to_string()).collect(),
env: Default::default(),
},
},
);
}
if let Some(agent) = config.agents.get_mut(agent_id) {
crate::local_cli::apply(&mut agent.transport).await;
}
let warn_model_dropped = |why: &str| {
if let Some(m) = &opts.model {
eprintln!("note: --model '{m}' ignored — {why}");
}
};
if opts.direct {
warn_model_dropped("running --direct");
return Ok(Routed::default());
}
let harness = match config.agents.get(agent_id) {
Some(entry) => {
let AcpTransport::Stdio { command, args, .. } = &entry.transport;
crate::harness::match_invocation(command, args)
}
None => return Ok(Routed::default()),
};
let Some(harness) = harness else {
eprintln!(
"note: routing unavailable for '{agent_id}' (not catalog-matched); \
launching direct — set its `env` to route manually"
);
warn_model_dropped("the agent is not catalog-matched");
return Ok(Routed::default());
};
if !harness.env_args_routable() {
eprintln!(
"note: '{}' routes via synthesized config, which headless spawn doesn't do yet \
in this ACP adapter; launching direct",
harness.id
);
warn_model_dropped("the harness routes only in the interactive facet");
return Ok(Routed::default());
}
let uses_maintained_adapter =
config
.agents
.get(agent_id)
.is_some_and(|entry| match &entry.transport {
AcpTransport::Stdio { command, args, .. } => {
harness.uses_maintained_adapter(command, args)
}
});
if harness.id == "codex-acp" && !uses_maintained_adapter {
eprintln!(
"note: routing unavailable for '{agent_id}': Codex ACP endpoint configuration \
requires @agentclientprotocol/codex-acp@1.10.0; launching direct"
);
warn_model_dropped("the configured Codex ACP adapter is not the maintained pin");
return Ok(Routed::default());
}
let base_url = opts
.base_url
.clone()
.unwrap_or_else(|| crate::spawn::derive_base_url(&config.server.listen));
let target_authority = opts
.base_url
.as_deref()
.and_then(crate::spawn::listen_from_base_url);
let target_is_local = match &target_authority {
Some(a) => crate::spawn::listen_is_local(a),
None => crate::spawn::listen_is_local(&config.server.listen),
};
let require_key = !target_is_local || !config.server.skip_auth;
if require_key && !harness.auth_is_bearer() {
eprintln!(
"warning: '{}' sends its API key as a non-Bearer header the daemon rejects under \
auth mode (`skip_auth: false`) — this session will likely 401. Use `skip_auth: \
true`, a `--direct` session, or a different harness.",
harness.id
);
}
let explicit_key = crate::spawn::nonempty_env(crate::harness::BITROUTER_API_KEY_ENV);
let stored_cloud_key = if explicit_key.is_none() && require_key && !target_is_local {
cloud_credentials.routing_fallback(&base_url).await
} else {
None
};
let supplied = explicit_key.or(stored_cloud_key);
if supplied.is_none() && require_key {
return Err(RoutingError::AuthRequired { via: base_url });
}
let auth = crate::spawn::resolve_launch_token(supplied, None);
let launch_id = crate::spawn::is_launch_token(&auth).then(|| auth.clone());
let api_principal = if target_is_local && config.server.skip_auth {
"local".to_string()
} else {
crate::auth::keys::hash_key(&auth)
};
if opts.base_url.is_none() && target_is_local {
crate::spawn::ensure_local_daemon(source, config, opts.no_start).await;
}
if !crate::spawn::base_url_reachable(&base_url).await {
return Err(RoutingError::DaemonUnreachable { via: base_url });
}
let controller_instance_id = format!("brc_{}", uuid::Uuid::new_v4().simple());
let endpoint_plan = if uses_maintained_adapter {
harness.endpoint_plan(
&base_url,
&auth,
opts.model.as_deref(),
&controller_instance_id,
)
} else {
None
};
let overlay = match &endpoint_plan {
Some(plan) => {
plan.fallback_overlay()
.map_err(|error| RoutingError::EndpointConfiguration {
via: base_url.clone(),
message: format!("could not configure pinned '{}': {error}", harness.id),
})?
}
None => harness.routing_overlay(&base_url, &auth, opts.model.as_deref()),
};
if let Some(entry) = config.agents.get_mut(agent_id) {
let AcpTransport::Stdio { args, env, .. } = &mut entry.transport;
for (k, v) in overlay.env {
if let Some(existing) = env.get(&k)
&& existing != &v
{
eprintln!(
"note: routing overrides your `env.{k}` for '{agent_id}' \
(pass --direct to keep your value)"
);
}
env.insert(k, v);
}
args.extend(overlay.args);
}
Ok(Routed {
via: Some(base_url),
launch_id,
controller_instance_id: endpoint_plan.as_ref().map(|_| controller_instance_id),
api_principal: endpoint_plan.as_ref().map(|_| api_principal),
endpoint_plan,
})
}
#[derive(Serialize)]
struct ResultLine<S: Serialize> {
#[serde(rename = "type")]
kind: &'static str,
stop_reason: S,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
schema_ok: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
raw: Option<String>,
}
async fn write_ndjson_line<W, T>(out: &mut W, value: &T) -> Result<()>
where
W: AsyncWrite + Unpin,
T: Serialize,
{
let mut line = serde_json::to_string(value).context("serialising NDJSON line")?;
line.push('\n');
out.write_all(line.as_bytes())
.await
.context("writing NDJSON line")
}
pub async fn spawn_check(
config: Config,
agent_id: &str,
routing: &RoutingOptions,
) -> Result<crate::spawn::SpawnCheckReport> {
use crate::spawn::{SpawnCheckReport, SpawnCheckRow, SpawnCheckStatus};
let row = |name: &str, status: SpawnCheckStatus, message: String| SpawnCheckRow {
name: name.to_string(),
status,
message,
};
let mut checks = Vec::new();
let configured = config.agents.get(agent_id);
let catalog = crate::harness::by_id(agent_id);
let (command, args) = match (configured, catalog) {
(Some(entry), _) => {
let AcpTransport::Stdio { command, args, .. } = &entry.transport;
checks.push(row(
"agent",
SpawnCheckStatus::Pass,
format!("configured in agents: ({command})"),
));
(command.clone(), args.clone())
}
(None, Some(h)) => match h.acp_command {
Some(command) => {
checks.push(row(
"agent",
SpawnCheckStatus::Pass,
format!("bundled catalog ({} {})", command, h.acp_args.join(" ")),
));
(
command.to_string(),
h.acp_args.iter().map(|s| s.to_string()).collect(),
)
}
None => {
checks.push(row(
"agent",
SpawnCheckStatus::Fail,
format!("'{agent_id}' has no ACP adapter and cannot be used as an agent"),
));
(String::new(), Vec::new())
}
},
(None, None) => {
checks.push(row(
"agent",
SpawnCheckStatus::Fail,
format!("'{agent_id}' is neither a configured agent nor a bundled-catalog id"),
));
(String::new(), Vec::new())
}
};
let base_url = routing
.base_url
.clone()
.unwrap_or_else(|| crate::spawn::derive_base_url(&config.server.listen));
let harness = crate::harness::match_invocation(&command, &args);
let mut routable_harness: Option<&'static crate::harness::Harness> = None;
if routing.direct {
checks.push(row(
"routing",
SpawnCheckStatus::Warn,
"--direct: the sub-agent uses its own provider auth".to_string(),
));
} else {
match harness {
Some(h) if h.env_args_routable() => {
routable_harness = Some(h);
checks.push(row(
"routing",
SpawnCheckStatus::Pass,
format!("via daemon {base_url} [{}]", h.id),
));
}
Some(h) => {
checks.push(row(
"routing",
SpawnCheckStatus::Warn,
format!(
"'{}' routes via synthesized config (interactive facet only); \
will run direct",
h.id
),
));
}
None => checks.push(row(
"routing",
SpawnCheckStatus::Warn,
"not catalog-matched; will run direct (set its `env` to route manually)"
.to_string(),
)),
}
}
if let Some(h) = routable_harness {
let target_is_local = routing
.base_url
.as_deref()
.and_then(crate::spawn::listen_from_base_url)
.map(|a| crate::spawn::listen_is_local(&a))
.unwrap_or_else(|| crate::spawn::listen_is_local(&config.server.listen));
let require_key = !target_is_local || !config.server.skip_auth;
let has_key = crate::spawn::nonempty_env(crate::harness::BITROUTER_API_KEY_ENV).is_some();
checks.push(if require_key && !has_key {
row(
"auth",
SpawnCheckStatus::Fail,
"daemon requires auth but BITROUTER_API_KEY is not set — export it or pass --direct"
.to_string(),
)
} else if require_key && !h.auth_is_bearer() {
row(
"auth",
SpawnCheckStatus::Warn,
format!(
"'{}' sends a non-Bearer header the daemon rejects under skip_auth:false — \
the session will likely 401",
h.id
),
)
} else if has_key {
row(
"auth",
SpawnCheckStatus::Pass,
"BITROUTER_API_KEY present".to_string(),
)
} else {
row(
"auth",
SpawnCheckStatus::Pass,
"skip_auth: credential-less requests admitted".to_string(),
)
});
}
if routable_harness.is_some() {
checks.push(if crate::spawn::base_url_reachable(&base_url).await {
row(
"daemon",
SpawnCheckStatus::Pass,
format!("{base_url} is reachable"),
)
} else {
row(
"daemon",
SpawnCheckStatus::Fail,
format!("{base_url} is unreachable — run `bitrouter start` (or pass --direct)"),
)
});
}
Ok(SpawnCheckReport {
agent: agent_id.to_string(),
base_url,
model: routing.model.clone(),
checks,
})
}
fn controller_identity(
agent_id: &str,
command: &str,
args: &[String],
endpoint: Option<&crate::harness::HarnessEndpointPlan>,
) -> bitrouter_sdk::acp::controller::ControllerIdentity {
if let Some(endpoint) = endpoint {
return bitrouter_sdk::acp::controller::ControllerIdentity::new(
endpoint.harness_id,
endpoint.adapter_package,
endpoint.adapter_version,
);
}
if let Some(harness) = crate::harness::match_invocation(command, args)
&& let Some((package, version)) = harness.maintained_adapter_identity()
&& harness.uses_maintained_adapter(command, args)
{
return bitrouter_sdk::acp::controller::ControllerIdentity::new(
harness.id, package, version,
);
}
bitrouter_sdk::acp::controller::ControllerIdentity::new(
agent_id,
"configured-acp-adapter",
"configured",
)
}
fn controller_endpoint(
endpoint: &crate::harness::HarnessEndpointPlan,
) -> bitrouter_sdk::acp::controller::ProviderEndpointPlan {
let protocol = match endpoint.protocol {
crate::harness::HarnessProtocol::Anthropic => LlmProtocol::Anthropic,
crate::harness::HarnessProtocol::OpenAi => LlmProtocol::OpenAi,
};
bitrouter_sdk::acp::controller::ProviderEndpointPlan {
provider_id: endpoint.provider_id.to_string(),
protocol,
base_url: endpoint.base_url.clone(),
headers: endpoint.headers.clone(),
}
}
struct DaemonRouteControl {
socket_path: PathBuf,
api_principal: String,
controller_instance_id: String,
}
impl DaemonRouteControl {
fn new(
socket_path: PathBuf,
api_principal: impl Into<String>,
controller_instance_id: impl Into<String>,
) -> Self {
Self {
socket_path,
api_principal: api_principal.into(),
controller_instance_id: controller_instance_id.into(),
}
}
async fn route_state(
&self,
command: crate::daemon::DaemonCommand,
) -> std::result::Result<RouteControlState, RouteControlError> {
match crate::daemon::send_command(&self.socket_path, &command).await {
Ok(crate::daemon::DaemonResponse::AcpRouteState {
available, current, ..
}) => Ok(RouteControlState { available, current }),
Ok(crate::daemon::DaemonResponse::Error { message }) => {
if message.starts_with("route is not available") {
Err(RouteControlError::invalid_route(message))
} else {
Err(RouteControlError::unavailable(message))
}
}
Ok(other) => Err(RouteControlError::unavailable(format!(
"daemon returned an unexpected route-control response: {other:?}"
))),
Err(error) => Err(RouteControlError::unavailable(format!(
"daemon route control is unavailable: {error}"
))),
}
}
}
#[async_trait::async_trait]
impl AcpRouteControl for DaemonRouteControl {
async fn list(
&self,
session_id: &str,
) -> std::result::Result<RouteControlState, RouteControlError> {
self.route_state(crate::daemon::DaemonCommand::AcpRouteList {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
session_id: session_id.to_string(),
})
.await
}
async fn set(
&self,
session_id: &str,
route: &str,
) -> std::result::Result<RouteControlState, RouteControlError> {
self.route_state(crate::daemon::DaemonCommand::AcpRouteSet {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
session_id: session_id.to_string(),
route: route.to_string(),
})
.await
}
async fn reset(
&self,
session_id: &str,
) -> std::result::Result<RouteControlState, RouteControlError> {
self.route_state(crate::daemon::DaemonCommand::AcpRouteReset {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
session_id: session_id.to_string(),
})
.await
}
async fn session_closed(&self, session_id: &str) -> std::result::Result<(), RouteControlError> {
self.reset(session_id).await.map(|_| ())
}
async fn disconnected(&self) -> std::result::Result<(), RouteControlError> {
match crate::daemon::send_command(
&self.socket_path,
&crate::daemon::DaemonCommand::AcpControllerCleanup {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
},
)
.await
{
Ok(crate::daemon::DaemonResponse::Ok) => Ok(()),
Ok(crate::daemon::DaemonResponse::Error { message }) => {
Err(RouteControlError::unavailable(message))
}
Ok(other) => Err(RouteControlError::unavailable(format!(
"daemon returned an unexpected cleanup response: {other:?}"
))),
Err(error) => Err(RouteControlError::unavailable(format!(
"daemon controller cleanup is unavailable: {error}"
))),
}
}
}
struct DaemonSessionCost {
socket_path: PathBuf,
api_principal: String,
controller_instance_id: String,
}
const SESSION_COST_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
#[async_trait::async_trait]
impl AcpSessionCost for DaemonSessionCost {
async fn attributed_cost(&self, session_id: &str) -> Option<Cost> {
let command = crate::daemon::DaemonCommand::AcpSessionSpend {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
session_id: session_id.to_string(),
};
let response = tokio::time::timeout(
SESSION_COST_LOOKUP_TIMEOUT,
crate::daemon::send_command(&self.socket_path, &command),
)
.await;
match response {
Ok(Ok(crate::daemon::DaemonResponse::AcpSessionSpend {
spend_micro_usd,
requests,
unpriced,
})) => attributed_cost(spend_micro_usd, requests, unpriced),
Ok(Ok(crate::daemon::DaemonResponse::Error { message })) => {
tracing::debug!(session_id, %message, "session cost is unavailable");
None
}
Ok(Ok(other)) => {
tracing::debug!(
session_id,
?other,
"daemon returned an unexpected session-spend response"
);
None
}
Ok(Err(error)) => {
tracing::debug!(session_id, %error, "session cost lookup failed");
None
}
Err(_) => {
tracing::debug!(session_id, "session cost lookup timed out");
None
}
}
}
}
fn attributed_cost(spend_micro_usd: u64, requests: u64, unpriced: u64) -> Option<Cost> {
(requests > 0 && unpriced < requests)
.then(|| Cost::new(spend_micro_usd as f64 / 1_000_000.0, "USD"))
}
struct CachedSessionCost {
inner: Arc<dyn AcpSessionCost>,
cache: Arc<CostCache>,
}
#[derive(Default)]
struct CostCache {
figures: std::sync::Mutex<std::collections::HashMap<String, Cost>>,
refreshing: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl CostCache {
fn held(&self, session_id: &str) -> Option<Cost> {
match self.figures.lock() {
Ok(figures) => figures.get(session_id).cloned(),
Err(poisoned) => poisoned.into_inner().get(session_id).cloned(),
}
}
fn claim_refresh(&self, session_id: &str) -> bool {
match self.refreshing.lock() {
Ok(mut refreshing) => refreshing.insert(session_id.to_string()),
Err(poisoned) => poisoned.into_inner().insert(session_id.to_string()),
}
}
fn settle_refresh(&self, session_id: &str, figure: Option<Cost>) {
if let Some(figure) = figure {
match self.figures.lock() {
Ok(mut figures) => {
figures.insert(session_id.to_string(), figure);
}
Err(poisoned) => {
poisoned.into_inner().insert(session_id.to_string(), figure);
}
}
}
match self.refreshing.lock() {
Ok(mut refreshing) => {
refreshing.remove(session_id);
}
Err(poisoned) => {
poisoned.into_inner().remove(session_id);
}
}
}
}
impl CachedSessionCost {
fn new(inner: Arc<dyn AcpSessionCost>) -> Self {
Self {
inner,
cache: Arc::new(CostCache::default()),
}
}
}
#[async_trait::async_trait]
impl AcpSessionCost for CachedSessionCost {
async fn attributed_cost(&self, session_id: &str) -> Option<Cost> {
if self.cache.claim_refresh(session_id) {
let inner = Arc::clone(&self.inner);
let cache = Arc::clone(&self.cache);
let session_id = session_id.to_string();
tokio::spawn(async move {
let figure = inner.attributed_cost(&session_id).await;
cache.settle_refresh(&session_id, figure);
});
}
self.cache.held(session_id)
}
}
#[derive(Clone)]
struct LocalControllerBinding {
socket_path: PathBuf,
api_principal: String,
controller_instance_id: String,
}
impl LocalControllerBinding {
fn open(
source: &ConfigSource,
config: &Config,
routed: &Routed,
explicit_base_url: bool,
) -> Option<Self> {
let (Some(_endpoint), Some(controller_instance_id), Some(api_principal)) = (
routed.endpoint_plan.as_ref(),
routed.controller_instance_id.clone(),
routed.api_principal.clone(),
) else {
return None;
};
if explicit_base_url {
eprintln!(
"note: _bitrouter/route/* is unavailable with an explicit --base-url; \
the remote model endpoint remains usable without session route control"
);
return None;
}
Some(Self {
socket_path: crate::daemon::socket_path_for(source, config),
api_principal,
controller_instance_id,
})
}
fn route_control(&self) -> Arc<dyn AcpRouteControl> {
Arc::new(DaemonRouteControl::new(
self.socket_path.clone(),
self.api_principal.clone(),
self.controller_instance_id.clone(),
))
}
fn session_cost(&self) -> Arc<dyn AcpSessionCost> {
Arc::new(CachedSessionCost::new(Arc::new(DaemonSessionCost {
socket_path: self.socket_path.clone(),
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
})))
}
async fn revoke(&self) {
let cleaned = crate::daemon::send_command(
&self.socket_path,
&crate::daemon::DaemonCommand::AcpControllerCleanup {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
},
)
.await;
match cleaned {
Ok(crate::daemon::DaemonResponse::Ok) => {}
Ok(other) => tracing::debug!(?other, "controller lease cleanup was not acknowledged"),
Err(error) => {
tracing::debug!(%error, "controller lease cleanup did not reach the daemon")
}
}
}
}
pub async fn serve(ctx: SpawnContext<'_>) -> Result<()> {
let SpawnContext {
source,
mut config,
agent_id,
options,
routing,
} = ctx;
for message in crate::assemble::ignored_config_warnings(&config) {
tracing::warn!("{message}");
}
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
let routed = apply_routing_with_cloud_credentials(
source,
&mut config,
agent_id,
&routing,
&cloud_credentials,
)
.await
.map_err(anyhow::Error::new)?;
config
.agents
.get(agent_id)
.with_context(|| format!("ACP agent '{agent_id}' is not configured"))?
.validate()
.with_context(|| format!("invalid ACP agent '{agent_id}'"))?;
let binding =
LocalControllerBinding::open(source, &config, &routed, routing.base_url.is_some());
let agent = config
.agents
.get(agent_id)
.with_context(|| format!("ACP agent '{agent_id}' is not configured"))?;
let AcpTransport::Stdio { command, args, env } = &agent.transport;
let identity = controller_identity(agent_id, command, args, routed.endpoint_plan.as_ref());
let mut controller_config = bitrouter_sdk::acp::controller::ControllerConfig::new(identity);
if let Some(endpoint) = routed.endpoint_plan.as_ref() {
controller_config = controller_config.endpoint(controller_endpoint(endpoint));
}
if options.turn_timeout.is_some() {
eprintln!(
"note: --turn-timeout is not enforced by transparent acp serve; \
the manager controls prompt deadlines"
);
}
let process =
bitrouter_sdk::acp::up::AgentProcess::new(command.clone(), args.clone(), env.clone())
.strip_inherited_env(options.strip_inherited_env);
let mut controller =
bitrouter_sdk::acp::controller::Controller::new(process, controller_config);
if let Some(binding) = &binding {
controller = controller
.route_control(binding.route_control())
.session_cost(binding.session_cost());
}
controller
.run(agent_client_protocol::Stdio::new())
.await
.map_err(|error| anyhow::anyhow!("acp serve: {error}"))
}
fn show_session_log(error: anyhow::Error) -> anyhow::Error {
crate::chat::session::report_failed_launch();
error
}
pub async fn chat(ctx: SpawnContext<'_>) -> Result<()> {
use std::io::IsTerminal as _;
let SpawnContext {
source,
mut config,
agent_id,
options,
routing,
} = ctx;
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
let routed = apply_routing_with_cloud_credentials(
source,
&mut config,
agent_id,
&routing,
&cloud_credentials,
)
.await
.map_err(anyhow::Error::new)?;
match &routed.via {
Some(via) => eprintln!("chat: '{agent_id}' routed via bitrouter ({via})"),
None => eprintln!("chat: '{agent_id}' running direct (not routed, not metered)"),
}
let binding =
LocalControllerBinding::open(source, &config, &routed, routing.base_url.is_some());
let ports = crate::actions::session::SessionPorts::open(
source.clone(),
crate::daemon::socket_path_for(source, &config),
);
let prompt_commands = crate::actions::session::prompt_commands(&config.chat)?;
if !std::io::stdout().is_terminal() {
return chat_piped(
&config,
agent_id,
&routed,
options,
&cloud_credentials,
binding,
crate::actions::session::SessionSurface {
commands: Vec::new(),
prompt_commands,
ports: &ports,
},
)
.await;
}
let cwd = std::env::current_dir().context("resolving current directory")?;
let mcp_servers = options.mcp_servers.clone();
let options = LaunchOptions {
terminal_auth: true,
..options
};
let mut session =
launch_controlled(&config, agent_id, &routed, options.clone(), binding.clone())
.await
.with_context(|| format!("launching acp session for agent '{agent_id}'"))
.map_err(show_session_log)?;
let mut authenticated = false;
let ids = loop {
match session
.client
.new_session(cwd.clone(), mcp_servers.clone())
.await
{
Ok(ids) => break ids,
Err(error) => {
if !bitrouter_sdk::acp::client::is_auth_required(&error) {
session.shutdown().await;
return Err(show_session_log(
error.context("opening the harness session"),
));
}
let choice = if authenticated {
AuthChoice::Declined
} else {
choose_auth_method(agent_id, session.client.auth_methods())?
};
match choice {
AuthChoice::Declined => {
let context =
unauthenticated_message(agent_id, session.client.auth_methods());
session.shutdown().await;
return Err(error.context(context));
}
AuthChoice::Agent(method_id) => {
if let Err(failed) = session.client.authenticate(method_id).await {
session.shutdown().await;
return Err(show_session_log(
failed
.context(format!("authenticating '{agent_id}' with the agent")),
));
}
}
AuthChoice::Terminal(method) => {
session.shutdown().await;
run_terminal_login(&config, agent_id, &method).await?;
session = launch_controlled(
&config,
agent_id,
&routed,
options.clone(),
binding.clone(),
)
.await
.with_context(|| format!("relaunching '{agent_id}' after its login"))
.map_err(show_session_log)?;
}
}
authenticated = true;
}
}
};
let observability =
build_observability(&config, agent_id, &ids.acp_session_id, &cloud_credentials).await;
if let Some(recorder) = observability.recorder.clone() {
spawn_tool_spans(recorder, session.client.subscribe_updates());
}
let commands = crate::actions::session::offered_commands(&session.client);
if commands
.iter()
.any(|command| command.action == "route_set" && command.unavailable.is_none())
{
eprintln!("chat: type /route to change the route mid-session.");
}
eprintln!("chat: type a message and press enter; Ctrl-D to end the session.");
let ended = crate::chat::session::run(
&mut session,
&ids.acp_session_id,
agent_id,
observability.recorder,
routed.via.clone(),
crate::actions::session::SessionSurface {
commands,
prompt_commands,
ports: &ports,
},
)
.await;
if let Some(exporter) = observability.exporter {
exporter.shutdown();
}
ended
}
enum AuthChoice {
Agent(agent_client_protocol::schema::v1::AuthMethodId),
Terminal(Box<agent_client_protocol::schema::v1::AuthMethodTerminal>),
Declined,
}
fn choose_auth_method(
agent_id: &str,
methods: &[agent_client_protocol::schema::v1::AuthMethod],
) -> Result<AuthChoice> {
use std::io::{BufRead, IsTerminal as _};
if methods.is_empty() || !std::io::stdin().is_terminal() {
return Ok(AuthChoice::Declined);
}
eprintln!();
eprintln!(" '{agent_id}' is not authenticated. How would you like to sign in?");
for (index, method) in methods.iter().enumerate() {
match method.description() {
Some(description) => {
eprintln!(" {}) {} — {description}", index + 1, method.name())
}
None => eprintln!(" {}) {}", index + 1, method.name()),
}
}
eprintln!(" 0) cancel");
let stdin = std::io::stdin();
let mut handle = stdin.lock();
loop {
eprint!(" Choose [1]: ");
let mut line = String::new();
if handle
.read_line(&mut line)
.context("reading the authentication choice")?
== 0
{
eprintln!();
return Ok(AuthChoice::Declined);
}
match classify_choice(&line, methods) {
Some(choice) => return Ok(choice),
None => match line.trim().parse::<usize>() {
Ok(n) => eprintln!(
" choice must be between 0 and {}, got {n}",
methods.len()
),
Err(_) => eprintln!(" '{}' is not a number", line.trim()),
},
}
}
}
fn classify_choice(
input: &str,
methods: &[agent_client_protocol::schema::v1::AuthMethod],
) -> Option<AuthChoice> {
use agent_client_protocol::schema::v1::AuthMethod;
let trimmed = input.trim();
let index = if trimmed.is_empty() {
1
} else {
match trimmed.parse::<usize>() {
Ok(0) => return Some(AuthChoice::Declined),
Ok(n) if (1..=methods.len()).contains(&n) => n,
Ok(_) | Err(_) => return None,
}
};
Some(match methods.get(index - 1)? {
AuthMethod::Terminal(terminal) => AuthChoice::Terminal(Box::new(terminal.clone())),
other => AuthChoice::Agent(other.id().clone()),
})
}
async fn run_terminal_login(
config: &Config,
agent_id: &str,
method: &agent_client_protocol::schema::v1::AuthMethodTerminal,
) -> Result<()> {
let agent = config
.agents
.get(agent_id)
.with_context(|| format!("no acp agent configured for '{agent_id}'"))?;
let AcpTransport::Stdio { command, args, env } = &agent.transport;
let mut invocation = args.clone();
invocation.extend(method.args.iter().cloned());
eprintln!();
eprintln!(" Running '{}'s login — {}.", agent_id, method.name);
eprintln!(" Finish it in the terminal below; this session resumes afterwards.");
eprintln!();
let mut child = tokio::process::Command::new(command);
child.args(&invocation).envs(env);
child.envs(method.env.iter());
let status = child
.status()
.await
.with_context(|| format!("running the login for '{agent_id}'"))?;
if !status.success() {
anyhow::bail!(
"the login for '{agent_id}' did not complete ({status}) — \
the session is still unauthenticated"
);
}
Ok(())
}
fn unauthenticated_message(
agent_id: &str,
methods: &[agent_client_protocol::schema::v1::AuthMethod],
) -> String {
use std::io::IsTerminal as _;
if methods.is_empty() {
return format!(
"'{agent_id}' is not authenticated, and advertises no authentication method. \
Sign in with the harness's own tooling, then run this again"
);
}
let offered = methods
.iter()
.map(|method| match method.description() {
Some(description) => format!("{} ({description})", method.name()),
None => method.name().to_string(),
})
.collect::<Vec<_>>()
.join("; ");
if std::io::stdin().is_terminal() {
format!(
"'{agent_id}' is not authenticated and the sign-in was cancelled. \
It offers: {offered}"
)
} else {
format!(
"'{agent_id}' is not authenticated. It offers: {offered}. \
Run `bitrouter chat {agent_id}` in a terminal to sign in, or use the \
harness's own tooling"
)
}
}
async fn chat_piped(
config: &Config,
agent_id: &str,
routed: &Routed,
options: LaunchOptions,
cloud_credentials: &crate::cloud::StandaloneCloudCredentials,
binding: Option<LocalControllerBinding>,
mut surface: crate::actions::session::SessionSurface<'_>,
) -> Result<()> {
let cwd = std::env::current_dir().context("resolving current directory")?;
let mcp_servers = options.mcp_servers.clone();
let mut session = launch_controlled(config, agent_id, routed, options, binding)
.await
.with_context(|| format!("launching acp session for agent '{agent_id}'"))
.map_err(show_session_log)?;
let ids = match session.client.new_session(cwd, mcp_servers).await {
Ok(ids) => ids,
Err(error) => {
let unauthenticated = bitrouter_sdk::acp::client::is_auth_required(&error);
let context = if unauthenticated {
unauthenticated_message(agent_id, session.client.auth_methods())
} else {
"opening the harness session".to_string()
};
session.shutdown().await;
let failure = error.context(context);
return Err(if unauthenticated {
failure
} else {
show_session_log(failure)
});
}
};
let observability =
build_observability(config, agent_id, &ids.acp_session_id, cloud_credentials).await;
if let Some(recorder) = observability.recorder.clone() {
spawn_tool_spans(recorder, session.client.subscribe_updates());
}
surface.commands = crate::actions::session::offered_commands(&session.client);
let ended = crate::chat::session::chat_plain(
&session.client,
&ids.acp_session_id,
agent_id,
observability.recorder,
surface,
)
.await;
session.shutdown().await;
if let Some(exporter) = observability.exporter {
exporter.shutdown();
}
ended
}
pub async fn prompt<W>(
ctx: SpawnContext<'_>,
text: &str,
options: PromptOptions,
out: &mut W,
) -> Result<PermissionTally>
where
W: AsyncWrite + Unpin + Send,
{
let PromptOptions {
no_wait,
contract,
policy,
format,
} = options;
let mut presenter = Presenter::new(format, contract.is_some(), out);
let SpawnContext {
source,
mut config,
agent_id,
options,
routing,
} = ctx;
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
let routed = match apply_routing_with_cloud_credentials(
source,
&mut config,
agent_id,
&routing,
&cloud_credentials,
)
.await
{
Ok(routed) => routed,
Err(e) => {
presenter.routing_error(&e).await?;
return Err(anyhow::Error::new(e));
}
};
let expanded = match bitrouter_tui::machine::resolve(
&[],
&crate::actions::session::prompt_commands(&config.chat)?,
text,
) {
bitrouter_tui::machine::Resolution::Expand(prompt) => prompt,
_ => text.to_string(),
};
let text = expanded.as_str();
let cwd = std::env::current_dir().context("resolving current directory")?;
let mcp_servers = options.mcp_servers.clone();
let mut session = launch_controlled(&config, agent_id, &routed, options, None)
.await
.with_context(|| format!("launching acp session for agent '{agent_id}'"))?;
let ids = match session.client.new_session(cwd, mcp_servers).await {
Ok(ids) => ids,
Err(error) => {
let context = if bitrouter_sdk::acp::client::is_auth_required(&error) {
unauthenticated_message(agent_id, session.client.auth_methods())
} else {
"opening the harness session".to_string()
};
session.shutdown().await;
return Err(error.context(context));
}
};
let observability =
build_observability(&config, agent_id, &ids.acp_session_id, &cloud_credentials).await;
if let Some(recorder) = observability.recorder.clone() {
spawn_tool_spans(recorder, session.client.subscribe_updates());
}
presenter
.session(&serde_json::json!({
"type": "session",
"session_id": ids.acp_session_id,
"agent_session_id": ids.agent_session_id,
"agent": agent_id,
"via": routed.via,
"launch_id": routed.launch_id,
}))
.await?;
if no_wait {
presenter.submitted().await?;
let clean = session.shutdown().await;
if let Some(exporter) = observability.exporter {
exporter.shutdown();
}
return teardown_result(clean).map(|()| PermissionTally::default());
}
let mut turn = Turn {
client: &session.client,
session_id: &ids.acp_session_id,
agent_id,
recorder: observability.recorder.clone(),
wire: Wire::new(&session.client, &ids.acp_session_id),
permissions: session.client.subscribe_permissions(),
policy,
tally: PermissionTally::default(),
};
let outcome = prompt_wait(&mut turn, text, contract, &mut presenter).await;
let tally = turn.tally;
let clean = session.shutdown().await;
if let Some(exporter) = observability.exporter {
exporter.shutdown();
}
outcome?;
teardown_result(clean).map(|()| tally)
}
fn teardown_result(clean: bool) -> Result<()> {
if clean {
Ok(())
} else {
Err(anyhow::anyhow!(
"shutting down acp session: teardown did not confirm; see the session log"
))
}
}
pub(crate) struct ControlledSession {
pub(crate) client: AcpClient,
binding: Option<LocalControllerBinding>,
reaped: futures::channel::oneshot::Receiver<()>,
controller: tokio::task::JoinHandle<std::result::Result<(), anyhow::Error>>,
}
const CONTROLLER_EXIT_TIMEOUT: Duration = Duration::from_secs(10);
impl ControlledSession {
pub(crate) async fn shutdown(&mut self) -> bool {
let mut clean = true;
if let Err(error) = self.client.shutdown().await {
tracing::warn!(%error, "acp teardown unconfirmed; the harness may not have terminated");
clean = false;
}
if tokio::time::timeout(bitrouter_sdk::acp::up::REAP_CONFIRM, &mut self.reaped)
.await
.is_err()
{
tracing::warn!("harness child not confirmed reaped; a grandchild may have survived");
clean = false;
}
match tokio::time::timeout(CONTROLLER_EXIT_TIMEOUT, &mut self.controller).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(error))) => tracing::debug!(%error, "acp controller ended with an error"),
Ok(Err(error)) => {
tracing::warn!(%error, "acp controller task failed");
clean = false;
}
Err(_) => {
tracing::warn!("acp controller did not exit within {CONTROLLER_EXIT_TIMEOUT:?}");
self.controller.abort();
clean = false;
}
}
if let Some(binding) = &self.binding {
binding.revoke().await;
}
clean
}
}
async fn launch_controlled(
config: &Config,
agent_id: &str,
routed: &Routed,
options: LaunchOptions,
binding: Option<LocalControllerBinding>,
) -> Result<ControlledSession> {
let agent = config
.agents
.get(agent_id)
.with_context(|| format!("no acp agent configured for '{agent_id}'"))?;
agent
.validate()
.with_context(|| format!("invalid ACP agent '{agent_id}'"))?;
let AcpTransport::Stdio { command, args, env } = &agent.transport;
let identity = controller_identity(agent_id, command, args, routed.endpoint_plan.as_ref());
let mut controller_config = bitrouter_sdk::acp::controller::ControllerConfig::new(identity);
if let Some(endpoint) = routed.endpoint_plan.as_ref() {
controller_config = controller_config.endpoint(controller_endpoint(endpoint));
}
let mut process =
bitrouter_sdk::acp::up::AgentProcess::new(command.clone(), args.clone(), env.clone())
.strip_inherited_env(options.strip_inherited_env);
let reaped = process.reaped();
let mut controller =
bitrouter_sdk::acp::controller::Controller::new(process, controller_config);
if let Some(binding) = &binding {
controller = controller
.route_control(binding.route_control())
.session_cost(binding.session_cost());
}
let (manager_side, controller_side) = agent_client_protocol::Channel::duplex();
let controller = tokio::spawn(async move {
controller
.run(controller_side)
.await
.map_err(|error| anyhow::anyhow!("acp controller: {error}"))
});
let client = match AcpClient::connect(
manager_side,
ClientOptions {
turn_timeout: options.turn_timeout,
terminal_auth: options.terminal_auth,
},
)
.await
{
Ok(client) => client,
Err(error) => {
controller.abort();
if let Some(binding) = &binding {
binding.revoke().await;
}
return Err(error);
}
};
Ok(ControlledSession {
client,
binding,
reaped,
controller,
})
}
struct Turn<'a> {
client: &'a AcpClient,
session_id: &'a str,
agent_id: &'a str,
recorder: Option<Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>>,
wire: Wire<'a>,
permissions: std::pin::Pin<Box<dyn futures::Stream<Item = PendingPermission> + Send>>,
policy: Policy,
tally: PermissionTally,
}
async fn prompt_wait<W>(
turn: &mut Turn<'_>,
text: &str,
contract: Option<crate::result_contract::ResultContract>,
presenter: &mut Presenter<'_, W>,
) -> Result<()>
where
W: AsyncWrite + Unpin,
{
let mut updates = turn.client.subscribe_raw_updates();
let task = match &contract {
Some(c) => format!("{text}{}", c.instruction()),
None => text.to_string(),
};
let (response, reply) =
run_turn(turn, &mut updates, &task, contract.is_some(), presenter).await?;
let (response, result, schema_ok, raw) = match &contract {
None => (response, None, None, None),
Some(c) => match c.check(&reply) {
Ok(value) => (response, Some(value), Some(true), None),
Err(problem) => {
let (response, reply) = run_turn(
turn,
&mut updates,
&c.repair_prompt(&problem),
true,
presenter,
)
.await?;
match c.check(&reply) {
Ok(value) => (response, Some(value), Some(true), None),
Err(_) => (
response,
Some(serde_json::Value::Null),
Some(false),
Some(reply),
),
}
}
},
};
presenter
.result(&ResultLine {
kind: "result",
stop_reason: response.stop_reason,
result,
schema_ok,
raw,
})
.await
}
async fn run_turn<W>(
turn: &mut Turn<'_>,
updates: &mut (impl futures::Stream<Item = SessionUpdate> + Unpin),
text: &str,
capture: bool,
presenter: &mut Presenter<'_, W>,
) -> Result<(agent_client_protocol::schema::v1::PromptResponse, String)>
where
W: AsyncWrite + Unpin,
{
let mut reply = String::new();
let started = std::time::Instant::now();
let response = {
let prompt_future = turn.client.prompt(turn.session_id, text);
tokio::pin!(prompt_future);
loop {
tokio::select! {
biased;
result = &mut prompt_future => {
let response = result.context("acp prompt failed")?;
loop {
let maybe = tokio::select! {
biased;
v = updates.next() => v,
_ = std::future::ready(()) => None,
};
match maybe {
Some(update) => presenter.update(update, capture, &mut reply).await?,
None => break,
}
}
break response;
}
maybe_update = updates.next() => {
if let Some(update) = maybe_update {
presenter.update(update, capture, &mut reply).await?;
}
}
request = turn.permissions.next() => {
if let Some(request) = request {
let (decision, prompt) = turn.wire.answer(request, &turn.policy).await;
turn.tally.record(decision);
presenter.permission(&prompt, decision).await?;
}
}
}
}
};
presenter.turn_settled().await?;
report_turn(
turn.client,
turn.agent_id,
turn.recorder.as_ref(),
&response,
started.elapsed(),
);
Ok((response, reply))
}
pub(crate) fn report_turn(
client: &AcpClient,
agent_id: &str,
recorder: Option<&Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>>,
response: &agent_client_protocol::schema::v1::PromptResponse,
latency: Duration,
) {
let record = RequestCompleted {
agent: agent_id.to_string(),
stop_reason: format!("{:?}", response.stop_reason),
latency_ms: u64::try_from(latency.as_millis()).unwrap_or(u64::MAX),
context: client.context_usage().lock().ok().and_then(|slot| *slot),
};
if let Some(recorder) = recorder {
recorder.turn_completed(&bitrouter_telemetry::otel::acp::TurnRecord {
stop_reason: record.stop_reason.clone(),
latency,
context_used: record.context.map(|c| c.used),
context_size: record.context.map(|c| c.size),
});
}
drain_telemetry_record(record);
}
enum Presenter<'a, W> {
Json(&'a mut W),
Quiet { out: &'a mut W, contracted: bool },
Text {
out: &'a mut W,
transcript: Box<bitrouter_tui::plain::Transcript>,
},
}
impl<'a, W> Presenter<'a, W>
where
W: AsyncWrite + Unpin,
{
fn new(format: PromptFormat, contracted: bool, out: &'a mut W) -> Self {
match format {
PromptFormat::Json => Presenter::Json(out),
PromptFormat::Quiet => Presenter::Quiet { out, contracted },
PromptFormat::Text => Presenter::Text {
out,
transcript: Box::default(),
},
}
}
async fn routing_error(&mut self, error: &RoutingError) -> Result<()> {
if let Presenter::Json(out) = self {
write_ndjson_line(out, &error.ndjson()).await?;
out.flush().await.ok();
}
Ok(())
}
async fn session(&mut self, line: &serde_json::Value) -> Result<()> {
match self {
Presenter::Json(out) => write_ndjson_line(out, line).await,
Presenter::Quiet { .. } => Ok(()),
Presenter::Text { out, .. } => {
let field = |name: &str| {
line.get(name)
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string()
};
let via = line
.get("via")
.and_then(|v| v.as_str())
.map_or_else(|| "direct".to_string(), |via| format!("via {via}"));
write_line(
out,
&format!(
"session {} · agent {} · {via}",
field("session_id"),
field("agent")
),
)
.await
}
}
}
async fn update(
&mut self,
update: SessionUpdate,
capture: bool,
reply: &mut String,
) -> Result<()> {
match self {
Presenter::Text { transcript, .. } => {
if capture {
if let Some(SessionUpdateKind::MessageChunk { text, .. }) =
translate(update.clone())
{
reply.push_str(&text);
}
}
transcript.apply(update);
Ok(())
}
Presenter::Json(out) => {
let Some(kind) = translate(update) else {
return Ok(());
};
if capture && let SessionUpdateKind::MessageChunk { text, .. } = &kind {
reply.push_str(text);
}
write_ndjson_line(out, &kind).await
}
Presenter::Quiet { out, contracted } => {
let Some(SessionUpdateKind::MessageChunk { text, .. }) = translate(update) else {
return Ok(());
};
if capture {
reply.push_str(&text);
}
if *contracted {
return Ok(());
}
out.write_all(text.as_bytes())
.await
.context("writing assistant text")
}
}
}
async fn permission(&mut self, prompt: &PermissionPrompt, decision: Decision) -> Result<()> {
match self {
Presenter::Json(out) => {
write_ndjson_line(
out,
&serde_json::json!({
"type": "permission",
"decision": decision.to_string(),
"title": prompt.title(),
"kind": prompt.kind(),
}),
)
.await
}
Presenter::Quiet { .. } => Ok(()),
Presenter::Text { out, .. } => {
write_line(out, &format!(" permission {decision}: {}", prompt.title())).await
}
}
}
async fn turn_settled(&mut self) -> Result<()> {
if let Presenter::Text { out, transcript } = self {
let mut rendered = Vec::new();
bitrouter_tui::plain::write(&mut rendered, &transcript.unwritten())
.context("rendering the transcript")?;
out.write_all(&rendered)
.await
.context("writing the transcript")?;
}
Ok(())
}
async fn result<S: Serialize>(&mut self, line: &ResultLine<S>) -> Result<()> {
match self {
Presenter::Json(out) => write_ndjson_line(out, line).await,
Presenter::Quiet { out, contracted } => {
let text = match (&line.result, &line.raw) {
(Some(result), None) => {
serde_json::to_string(result).context("serialising the result")?
}
(_, Some(raw)) => raw.clone(),
(None, None) => String::new(),
};
if *contracted || text.is_empty() {
write_line(out, &text).await
} else {
Ok(())
}
}
Presenter::Text { out, .. } => {
let stop = serde_json::to_value(&line.stop_reason)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
write_line(out, &format!("[{stop}]")).await?;
if let Some(result) = &line.result {
let pretty =
serde_json::to_string_pretty(result).context("serialising the result")?;
write_line(out, &format!("result: {pretty}")).await?;
}
if let Some(raw) = &line.raw {
write_line(out, &format!("raw: {raw}")).await?;
}
Ok(())
}
}
}
async fn submitted(&mut self) -> Result<()> {
match self {
Presenter::Json(out) => {
write_ndjson_line(out, &serde_json::json!({ "type": "submitted" })).await
}
Presenter::Quiet { .. } => Ok(()),
Presenter::Text { out, .. } => write_line(out, "[submitted]").await,
}
}
}
async fn write_line<W>(out: &mut W, line: &str) -> Result<()>
where
W: AsyncWrite + Unpin,
{
out.write_all(line.as_bytes())
.await
.context("writing to stdout")?;
out.write_all(b"\n").await.context("writing to stdout")
}
pub async fn commands(
ctx: SpawnContext<'_>,
wait_ms: u64,
) -> Result<bitrouter_mcp::actions::commands::CommandsReport> {
let SpawnContext {
source,
mut config,
agent_id,
options,
routing,
} = ctx;
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
let routed = apply_routing_with_cloud_credentials(
source,
&mut config,
agent_id,
&routing,
&cloud_credentials,
)
.await
.map_err(anyhow::Error::new)?;
let configured = crate::actions::session::prompt_commands(&config.chat)?;
let binding =
LocalControllerBinding::open(source, &config, &routed, routing.base_url.is_some());
let cwd = std::env::current_dir().context("resolving current directory")?;
let mcp_servers = options.mcp_servers.clone();
let mut session = launch_controlled(&config, agent_id, &routed, options, binding)
.await
.with_context(|| format!("launching acp session for agent '{agent_id}'"))?;
let mut updates = session.client.subscribe_raw_updates();
if let Err(error) = session.client.new_session(cwd, mcp_servers).await {
let context = if bitrouter_sdk::acp::client::is_auth_required(&error) {
unauthenticated_message(agent_id, session.client.auth_methods())
} else {
"opening the harness session".to_string()
};
session.shutdown().await;
return Err(error.context(context));
}
let mut advertised = Vec::new();
let mut received = false;
let deadline = tokio::time::sleep(std::time::Duration::from_millis(wait_ms));
tokio::pin!(deadline);
loop {
tokio::select! {
update = updates.next() => match update {
Some(agent_client_protocol::schema::v1::SessionUpdate::AvailableCommandsUpdate(
update,
)) => {
advertised = update.available_commands;
received = true;
}
Some(_) => continue,
None => break,
},
() = &mut deadline => break,
}
}
let report = crate::actions::commands::commands_report(
&crate::actions::session::offered_commands(&session.client),
&configured,
&advertised,
received,
);
session.shutdown().await;
Ok(report)
}
struct Observability {
exporter: Option<Arc<bitrouter_telemetry::otel::OtelExporter>>,
recorder: Option<Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>>,
}
async fn build_observability(
config: &Config,
agent_id: &str,
conversation_id: &str,
cloud_credentials: &crate::cloud::StandaloneCloudCredentials,
) -> Observability {
for message in crate::assemble::ignored_config_warnings(config) {
tracing::warn!("{message}");
}
let exporter =
crate::assemble::build_otel_exporter_standalone_with_credentials(config, cloud_credentials)
.await;
let recorder = exporter.as_ref().map(|exporter| {
Arc::new(bitrouter_telemetry::otel::acp::AcpSpanRecorder::new(
exporter,
agent_id,
conversation_id,
))
});
Observability { exporter, recorder }
}
fn spawn_tool_spans(
recorder: Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>,
mut updates: std::pin::Pin<Box<dyn futures::Stream<Item = SessionUpdateKind> + Send>>,
) {
tokio::spawn(async move {
use bitrouter_sdk::acp::translate::ToolStatus;
while let Some(update) = updates.next().await {
match update {
SessionUpdateKind::ToolCall {
id, title, status, ..
} => match status {
ToolStatus::Pending | ToolStatus::Running => {
recorder.tool_started(id, title);
}
ToolStatus::Ok => recorder.tool_finished(&id, true, Some(&title)),
ToolStatus::Failed => recorder.tool_finished(&id, false, Some(&title)),
},
SessionUpdateKind::ToolCallUpdate {
id, status, title, ..
} => match status {
Some(ToolStatus::Ok) => {
recorder.tool_finished(&id, true, title.as_deref());
}
Some(ToolStatus::Failed) => {
recorder.tool_finished(&id, false, title.as_deref());
}
_ => {}
},
_ => {}
}
}
});
}
#[cfg(test)]
mod standalone_cloud_credentials_tests {
use std::sync::Arc;
use bitrouter_providers::hosted::account::credentials::{Credentials, StoredCredential};
use bitrouter_providers::hosted::account::manager::CredentialManager;
use chrono::{Duration, Utc};
use serde_json::json;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::cloud::StandaloneCloudCredentials;
#[tokio::test]
async fn standalone_wiring_single_flights_routing_and_account_telemetry() -> anyhow::Result<()>
{
let server = MockServer::start().await;
let origin = server.uri();
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"issuer": origin,
"device_authorization_endpoint": format!("{origin}/oauth/device_authorization"),
"token_endpoint": format!("{origin}/oauth/token"),
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains("grant_type=refresh_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "rotated-access",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rotated-refresh",
"scope": "inference:invoke",
})))
.mount(&server)
.await;
let directory = tempfile::tempdir()?;
let manager = Arc::new(CredentialManager::with_client(
directory.path().join("account-credentials.json"),
reqwest::Client::new(),
));
manager
.save(StoredCredential::from(Credentials {
access_token: "stale-access".to_owned(),
refresh_token: Some("original-refresh".to_owned()),
expires_at: Utc::now() + Duration::seconds(10),
refresh_token_expires_at: None,
token_type: "Bearer".to_owned(),
scope: "inference:invoke".to_owned(),
client_id: "bitrouter-cli".to_owned(),
authorization_server: origin.clone(),
namespace_id: Some("ns-test".to_owned()),
subject: None,
}))
.await?;
let credentials = StandaloneCloudCredentials::with_manager(manager);
let telemetry = credentials
.telemetry_bearer(&origin)
.await
.ok_or_else(|| anyhow::anyhow!("account telemetry source was not built"))?;
let (routing, bearer) =
tokio::join!(credentials.routing_fallback(&origin), telemetry.bearer(),);
assert_eq!(routing.as_deref(), Some("rotated-access"));
assert_eq!(bearer.as_deref(), Some("rotated-access"));
let requests = server
.received_requests()
.await
.ok_or_else(|| anyhow::anyhow!("wiremock did not record requests"))?;
let metadata_requests = requests
.iter()
.filter(|request| request.url.path() == "/.well-known/oauth-authorization-server")
.count();
let refresh_requests = requests
.iter()
.filter(|request| request.url.path() == "/oauth/token")
.count();
assert_eq!(metadata_requests, 1);
assert_eq!(refresh_requests, 1);
Ok(())
}
}
fn drain_telemetry_record(r: RequestCompleted) {
tracing::info!(
agent = %r.agent,
stop_reason = %r.stop_reason,
latency_ms = r.latency_ms,
context_used = r.context.map(|c| c.used),
context_size = r.context.map(|c| c.size),
"acp turn completed"
);
}
#[derive(Clone, Debug, Default)]
pub struct LaunchOptions {
pub strip_inherited_env: Vec<String>,
pub turn_timeout: Option<Duration>,
pub mcp_servers: Vec<agent_client_protocol::schema::v1::McpServer>,
pub terminal_auth: bool,
}
#[derive(Debug, Clone)]
pub struct RequestCompleted {
pub agent: String,
pub stop_reason: String,
pub latency_ms: u64,
pub context: Option<bitrouter_sdk::acp::telemetry::ContextUsage>,
}
pub fn launch_options(turn_timeout_secs: Option<u64>) -> LaunchOptions {
LaunchOptions {
turn_timeout: turn_timeout_secs.map(std::time::Duration::from_secs),
..Default::default()
}
}
#[cfg(test)]
mod auth_report_tests {
use agent_client_protocol::schema::v1::{AuthMethod, AuthMethodAgent, AuthMethodTerminal};
use super::{
AuthChoice, LaunchOptions, choose_auth_method, classify_choice, unauthenticated_message,
};
fn two_methods() -> Vec<AuthMethod> {
vec![
AuthMethod::Agent(AuthMethodAgent::new("oauth", "Sign in with Anthropic")),
AuthMethod::Agent(AuthMethodAgent::new("api-key", "Paste an API key")),
]
}
#[test]
fn terminal_auth_is_off_unless_a_caller_opts_in() {
assert!(
!LaunchOptions::default().terminal_auth,
"the default must not claim a terminal this caller may not have"
);
assert!(
!super::launch_options(None).terminal_auth,
"the shared serve/prompt constructor must not claim it either"
);
}
#[test]
fn nothing_advertised_declines_without_prompting() {
let choice = choose_auth_method("pi-acp", &[]).expect("declining is not an error");
assert!(
matches!(choice, AuthChoice::Declined),
"an empty method list must decline"
);
}
#[test]
fn a_typed_zero_declines() {
assert!(
matches!(
classify_choice("0", &two_methods()),
Some(AuthChoice::Declined)
),
"'0' is the cancel entry the menu prints"
);
}
#[test]
fn a_bare_enter_takes_the_advertised_default() {
let Some(AuthChoice::Agent(id)) = classify_choice("\n", &two_methods()) else {
panic!("an empty line selects the first method")
};
assert_eq!(
id.to_string(),
"oauth",
"the default is the '[1]' on screen"
);
}
#[test]
fn a_digit_selects_that_numbered_method() {
let Some(AuthChoice::Agent(id)) = classify_choice("2", &two_methods()) else {
panic!("'2' selects the second method")
};
assert_eq!(id.to_string(), "api-key", "numbering starts at one");
}
#[test]
fn an_unoffered_index_is_not_a_selection() {
assert!(
classify_choice("9", &two_methods()).is_none(),
"'9' against two methods must re-prompt, not wrap"
);
}
#[test]
fn a_non_number_is_not_a_selection() {
assert!(
classify_choice("x", &two_methods()).is_none(),
"'x' must re-prompt"
);
}
#[test]
fn a_terminal_method_never_becomes_an_authenticate_call() {
let methods = vec![AuthMethod::Terminal(
AuthMethodTerminal::new("login", "Log in from the terminal")
.args(vec!["--login".to_string()]),
)];
let Some(AuthChoice::Terminal(descriptor)) = classify_choice("1", &methods) else {
panic!("a terminal method classifies to the out-of-band branch")
};
assert_eq!(
descriptor.args,
vec!["--login".to_string()],
"the descriptor's args are what the login appends"
);
assert!(
descriptor.env.is_empty(),
"an unset env overlays nothing onto the base configuration"
);
}
#[test]
fn the_message_names_the_harness_and_what_it_offers() {
let methods = vec![AuthMethod::Agent(
AuthMethodAgent::new("oauth", "Sign in with Anthropic")
.description("opens a browser".to_string()),
)];
let message = unauthenticated_message("claude-acp", &methods);
assert!(message.contains("'claude-acp'"), "{message}");
assert!(message.contains("Sign in with Anthropic"), "{message}");
assert!(message.contains("opens a browser"), "{message}");
}
#[test]
fn advertising_nothing_is_reported_as_nothing() {
let message = unauthenticated_message("pi-acp", &[]);
assert!(
message.contains("advertises no authentication method"),
"{message}"
);
}
#[test]
fn the_message_never_mentions_the_routing_axis() {
let methods = vec![AuthMethod::Agent(AuthMethodAgent::new("t", "Terminal"))];
for message in [
unauthenticated_message("claude-acp", &methods),
unauthenticated_message("claude-acp", &[]),
] {
let prose = message.replace("bitrouter", "");
for forbidden in ["providers login", "provider", "route", "--direct"] {
assert!(
!prose.contains(forbidden),
"an agent-auth message must not name the routing axis \
({forbidden:?}): {message}"
);
}
}
}
}
#[cfg(test)]
mod controller_tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use agent_client_protocol::schema::v1::Cost;
use bitrouter_sdk::acp::controller::SessionCost;
use super::{CachedSessionCost, attributed_cost, controller_identity};
struct StallingCost {
release: tokio::sync::Notify,
asked: AtomicUsize,
figure: Option<Cost>,
}
#[async_trait::async_trait]
impl SessionCost for StallingCost {
async fn attributed_cost(&self, _session_id: &str) -> Option<Cost> {
self.asked.fetch_add(1, Ordering::SeqCst);
self.release.notified().await;
self.figure.clone()
}
}
async fn settled(cache: &CachedSessionCost, session_id: &str) -> Option<Cost> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let held = cache.cache.held(session_id);
if held.is_some() || std::time::Instant::now() >= deadline {
return held;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
#[tokio::test]
async fn the_cached_cost_answers_without_awaiting_the_daemon() {
let daemon = Arc::new(StallingCost {
release: tokio::sync::Notify::new(),
asked: AtomicUsize::new(0),
figure: Some(Cost::new(0.42, "USD")),
});
let cache = CachedSessionCost::new(daemon.clone());
let first = tokio::time::timeout(Duration::from_millis(200), cache.attributed_cost("s1"))
.await
.expect("the forward path must not block on the daemon");
assert_eq!(first, None, "nothing measured yet is nothing, not zero");
let second = tokio::time::timeout(Duration::from_millis(200), cache.attributed_cost("s1"))
.await
.expect("still not blocked while the refresh is in flight");
assert_eq!(second, None);
tokio::task::yield_now().await;
assert_eq!(
daemon.asked.load(Ordering::SeqCst),
1,
"one refresh in flight per session, however many updates arrive"
);
daemon.release.notify_one();
assert_eq!(settled(&cache, "s1").await, Some(Cost::new(0.42, "USD")));
assert_eq!(
cache.attributed_cost("s1").await,
Some(Cost::new(0.42, "USD")),
"the figure the daemon confirmed is what the next update carries"
);
}
#[tokio::test]
async fn an_unmeasured_session_stays_absent_and_a_held_figure_survives() {
let daemon = Arc::new(StallingCost {
release: tokio::sync::Notify::new(),
asked: AtomicUsize::new(0),
figure: None,
});
let cache = CachedSessionCost::new(daemon.clone());
assert_eq!(cache.attributed_cost("never").await, None);
daemon.release.notify_one();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(
cache.attributed_cost("never").await,
None,
"no priced evidence is no figure — not $0.00"
);
cache
.cache
.settle_refresh("held", Some(Cost::new(0.25, "USD")));
cache.cache.settle_refresh("held", None);
assert_eq!(
cache.cache.held("held"),
Some(Cost::new(0.25, "USD")),
"a stale measured figure understates; it is never dropped for nothing"
);
}
#[test]
fn the_cost_marker_is_spelled_the_same_on_both_sides() {
assert_eq!(
bitrouter_tui::cost::COST_PROVENANCE_META_KEY,
bitrouter_sdk::acp::controller::COST_PROVENANCE_META_KEY
);
assert_eq!(
bitrouter_tui::cost::COST_PROVENANCE_ROUTER,
bitrouter_sdk::acp::controller::COST_PROVENANCE_ROUTER
);
}
#[test]
fn unattributed_or_unpriced_sessions_carry_no_figure() {
assert_eq!(attributed_cost(0, 0, 0), None);
assert_eq!(attributed_cost(0, 3, 3), None);
}
#[test]
fn priced_evidence_becomes_a_usd_figure() {
assert_eq!(attributed_cost(420_000, 2, 0), Some(Cost::new(0.42, "USD")));
assert_eq!(attributed_cost(250_000, 3, 2), Some(Cost::new(0.25, "USD")));
}
#[test]
fn direct_maintained_adapter_keeps_exact_identity() {
let identity = controller_identity(
"codex-acp",
"npx",
&[
"-y".to_string(),
"@agentclientprotocol/codex-acp@1.10.0".to_string(),
],
None,
);
assert_eq!(identity.harness_id, "codex-acp");
assert_eq!(identity.adapter_package, "@agentclientprotocol/codex-acp");
assert_eq!(identity.adapter_version, "1.10.0");
}
#[test]
fn custom_adapter_identity_exposes_no_launch_command() {
let identity = controller_identity(
"private-agent",
"/private/bin/agent-with-token-in-path",
&["--secret-argument".to_string()],
None,
);
assert_eq!(identity.harness_id, "private-agent");
assert_eq!(identity.adapter_package, "configured-acp-adapter");
assert_eq!(identity.adapter_version, "configured");
let rendered = format!("{identity:?}");
assert!(!rendered.contains("token"));
assert!(!rendered.contains("secret"));
}
#[test]
fn unpinned_catalog_invocation_is_not_reported_as_the_pin() {
let identity = controller_identity(
"codex-acp",
"npx",
&["@agentclientprotocol/codex-acp@1.6.0".to_string()],
None,
);
assert_eq!(identity.harness_id, "codex-acp");
assert_eq!(identity.adapter_package, "configured-acp-adapter");
assert_eq!(identity.adapter_version, "configured");
}
}