use std::sync::Arc;
use anyhow::{Context, Result};
use bitrouter_sdk::acp::{
AcpRequest, AcpStdioExecutor, AcpTarget, ConfigAcpRoutingTable, Pipeline, PipelineBuilder,
};
use bitrouter_sdk::caller::CallerContext;
use bitrouter_sdk::config::Config;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::Mutex;
pub async fn run(config: Config, agent_id: &str) -> Result<()> {
let agent_cfg = config
.agents
.get(agent_id)
.cloned()
.with_context(|| format!("no acp agent '{agent_id}' in bitrouter.yaml `agents:`"))?;
let agent_name = agent_cfg.name.clone();
let agents = vec![(agent_id.to_string(), agent_cfg)];
let table = Arc::new(
ConfigAcpRoutingTable::from_configs(agents)
.context("building the ACP routing table for the agent-proxy bridge")?,
);
let executor = Arc::new(AcpStdioExecutor::new());
let target = AcpTarget {
agent_name: agent_name.clone(),
transport: table
.lookup(agent_id)
.cloned()
.expect("entry just built; lookup cannot miss"),
};
executor
.ensure_connected(&target)
.await
.context("spawning upstream agent process")?;
let upstream_messages = executor
.subscribe(agent_id)
.await
.expect("subscribe after ensure_connected");
let mut builder = PipelineBuilder::new();
builder
.routing_table(table.clone())
.executor(executor.clone());
let pipeline = Arc::new(
builder
.build()
.context("building the agent-proxy pipeline")?,
);
let stdout = Arc::new(Mutex::new(tokio::io::stdout()));
let stdout_for_notify = Arc::clone(&stdout);
let agent_for_notify = agent_id.to_string();
let mut notify_rx = upstream_messages;
let notify_task = tokio::spawn(async move {
loop {
match notify_rx.recv().await {
Ok(value) => {
let mut line = match serde_json::to_string(&value) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
agent = %agent_for_notify,
%e,
"agent-proxy: dropping unrenderable upstream notification"
);
continue;
}
};
line.push('\n');
let mut guard = stdout_for_notify.lock().await;
if let Err(e) = guard.write_all(line.as_bytes()).await {
tracing::warn!(agent = %agent_for_notify, %e, "agent-proxy: stdout write failed");
return;
}
if let Err(e) = guard.flush().await {
tracing::warn!(agent = %agent_for_notify, %e, "agent-proxy: stdout flush failed");
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
agent = %agent_for_notify,
skipped = n,
"agent-proxy: subscriber lagged; some upstream notifications were dropped"
);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
});
let inbound_task = tokio::spawn(forward_inbound_stdin(
pipeline,
target,
agent_name,
stdout.clone(),
));
let inbound_result = inbound_task.await.context("inbound task panicked")?;
notify_task.abort();
inbound_result
}
async fn forward_inbound_stdin(
pipeline: Arc<Pipeline>,
target: AcpTarget,
agent_name: String,
stdout: Arc<Mutex<tokio::io::Stdout>>,
) -> Result<()> {
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin).lines();
while let Some(line) = reader
.next_line()
.await
.context("agent-proxy: reading stdin")?
{
if line.is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
tracing::warn!(%e, "agent-proxy: unparseable inbound line; dropping");
continue;
}
};
let method = value
.get("method")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let id = value.get("id").cloned();
let params = value
.get("params")
.cloned()
.unwrap_or(serde_json::Value::Null);
match (id.is_some(), method.is_empty()) {
(true, false) => {
let request = AcpRequest::new(
target.agent_name.clone(),
method.clone(),
params,
CallerContext::local(),
);
let inbound_id = id.unwrap_or(serde_json::Value::Null);
let result = pipeline.execute(request).await;
let envelope = match result {
Ok(resp) => serde_json::json!({
"jsonrpc": "2.0",
"id": inbound_id,
"result": resp.result,
}),
Err(e) => serde_json::json!({
"jsonrpc": "2.0",
"id": inbound_id,
"error": {
"code": -32000,
"message": e.to_string(),
},
}),
};
write_envelope(&stdout, &envelope, &agent_name).await;
}
(false, false) => {
tracing::debug!(
%method,
"agent-proxy: dropping inbound notification (v1.0 does not relay editor → agent notifications)"
);
}
(true, true) => {
tracing::debug!(
"agent-proxy: dropping inbound response (v1.0 does not yet correlate server-initiated requests)"
);
}
(false, true) => {
tracing::warn!("agent-proxy: malformed inbound envelope (neither id nor method)");
}
}
}
Ok(())
}
async fn write_envelope(
stdout: &Arc<Mutex<tokio::io::Stdout>>,
envelope: &serde_json::Value,
agent_name: &str,
) {
let mut line = match serde_json::to_string(envelope) {
Ok(s) => s,
Err(e) => {
tracing::warn!(%e, agent = %agent_name, "agent-proxy: failed to serialise response");
return;
}
};
line.push('\n');
let mut guard = stdout.lock().await;
if let Err(e) = guard.write_all(line.as_bytes()).await {
tracing::warn!(%e, agent = %agent_name, "agent-proxy: stdout write failed");
return;
}
if let Err(e) = guard.flush().await {
tracing::warn!(%e, agent = %agent_name, "agent-proxy: stdout flush failed");
}
}