Files
OneCAD/crates/ui-egui/src/agent_chat/acp_client.rs
T

266 lines
11 KiB
Rust

//! The ACP backend (desktop only): starts the agent the user picked as a child process and holds
//! the protocol's side of the conversation (initialize, the session, prompts, permission questions,
//! sign-in). The panel sees only [`Event`]s.
use std::collections::HashMap;
use serde_json::{Value, json};
use super::acp::{self, Incoming};
use super::backend::{ChatBackend, Event, PromptPart, StartContext};
use super::process::{AgentProcess, FromAgent};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pending {
Initialize,
SessionNew,
Prompt,
Authenticate,
SetMode,
SetModel,
}
/// Variables the agent doesn't inherit: a control token set for the app's own `--control` port
/// would otherwise reach the agent and outrank the chat endpoint's own token file.
const ENV_REMOVE: &[&str] = &["SOLIDCRAFT_CONTROL_TOKEN"];
pub struct AcpBackend {
proc: AgentProcess,
cwd: std::path::PathBuf,
bridge: Option<acp::McpBridge>,
next_id: u64,
pending: HashMap<u64, Pending>,
session: Option<String>,
agent: String,
images: bool,
auth: Vec<acp::AuthMethod>,
/// Open permission questions: our key → the agent's request id.
permissions: HashMap<u64, Value>,
next_key: u64,
exited: bool,
}
impl AcpBackend {
/// Start `argv` and say hello (`initialize`); the session follows when the agent answers.
pub fn start(argv: &[String], ctx: StartContext) -> Result<AcpBackend, String> {
let wake = ctx.wake;
let proc = AgentProcess::spawn(argv, &ctx.cwd, ENV_REMOVE, wake)?;
let mut b = AcpBackend {
proc,
cwd: ctx.cwd,
bridge: ctx.bridge,
next_id: 0,
pending: HashMap::new(),
session: None,
agent: String::new(),
images: false,
auth: Vec::new(),
permissions: HashMap::new(),
next_key: 0,
exited: false,
};
b.request(Pending::Initialize, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION")));
Ok(b)
}
fn request(&mut self, what: Pending, method: &str, params: Value) {
self.next_id += 1;
self.pending.insert(self.next_id, what);
self.proc.send(&acp::request(self.next_id, method, params));
}
fn new_session(&mut self) {
let params = acp::session_new_params(&self.cwd, self.bridge.as_ref());
self.request(Pending::SessionNew, "session/new", params);
}
fn handle(&mut self, msg: Incoming, out: &mut Vec<Event>) {
match msg {
Incoming::Reply { id, result } => {
let Some(what) = self.pending.remove(&id) else { return };
match (what, result) {
(Pending::Initialize, Ok(v)) => {
self.agent = acp::agent_info(&v);
self.images = acp::takes_images(&v);
self.auth = acp::auth_methods(&v);
self.new_session();
}
(Pending::SessionNew, Ok(v)) => match v.get("sessionId").and_then(Value::as_str) {
Some(s) => {
self.session = Some(s.to_string());
if let Some((current, available)) = acp::modes(&v) {
out.push(Event::Modes { current, available });
}
if let Some((current, available)) = acp::models(&v) {
out.push(Event::Models { current, available });
}
out.push(Event::Ready { agent: self.agent.clone(), images: self.images });
}
None => out.push(Event::Error("session/new: no sessionId".into())),
},
(Pending::Prompt, Ok(v)) => {
let reason = v.get("stopReason").and_then(Value::as_str).unwrap_or("");
out.push(Event::TurnEnded { reason: if reason == "end_turn" { String::new() } else { reason.to_string() } });
}
// Signed in: try the session again.
(Pending::Authenticate, Ok(_)) => self.new_session(),
(Pending::SetMode | Pending::SetModel, Ok(_)) => {}
(what, Err((code, message))) => {
let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth");
if auth {
self.session = None;
if what == Pending::Prompt {
out.push(Event::TurnEnded { reason: "auth".into() });
}
out.push(Event::AuthRequired { methods: self.auth.clone() });
} else {
if what == Pending::Prompt {
out.push(Event::TurnEnded { reason: "error".into() });
}
out.push(Event::Error(message));
}
}
}
}
Incoming::Text { thought, text } => out.push(Event::Text { thought, text }),
Incoming::Tool { id, title, kind, status, output } => out.push(Event::Tool { id, title, kind, status, output }),
Incoming::Plan(p) => out.push(Event::Plan(p)),
Incoming::Permission { rpc, title, options } => {
self.next_key += 1;
self.permissions.insert(self.next_key, rpc);
out.push(Event::Permission { key: self.next_key, title, options });
}
Incoming::AuthStatus(s) => out.push(Event::AuthStatus(s)),
Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }),
Incoming::ModelChanged(m) => out.push(Event::Models { current: m, available: Vec::new() }),
Incoming::Unsupported { rpc, method } => {
self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`")));
}
Incoming::Ignored => {}
}
}
}
impl ChatBackend for AcpBackend {
fn poll(&mut self) -> Vec<Event> {
let mut out = Vec::new();
if self.exited {
return out;
}
for _ in 0..512 {
match self.proc.from_agent.try_recv() {
Ok(FromAgent::Line(l)) => {
if let Some(m) = acp::parse(&l) {
self.handle(m, &mut out);
}
}
Ok(FromAgent::Exited(tail)) => {
self.exited = true;
self.session = None;
self.pending.clear();
out.push(Event::Exited { tail });
break;
}
Err(_) => break,
}
}
out
}
fn prompt(&mut self, parts: &[PromptPart]) {
if let Some(s) = self.session.clone() {
self.request(Pending::Prompt, "session/prompt", acp::prompt_params(&s, parts));
}
}
fn cancel(&mut self) {
// Open permission questions are answered "cancelled" first, as the protocol asks.
for (_, rpc) in self.permissions.drain() {
self.proc.send(&acp::permission_reply(rpc, None));
}
if let Some(s) = &self.session {
self.proc.send(&acp::notification("session/cancel", json!({"sessionId": s})));
}
}
fn new_session(&mut self) {
if self.pending.values().any(|p| *p == Pending::Prompt) {
self.cancel();
}
self.session = None;
self.new_session();
}
fn answer_permission(&mut self, key: u64, option: Option<&str>) {
if let Some(rpc) = self.permissions.remove(&key) {
self.proc.send(&acp::permission_reply(rpc, option));
}
}
fn authenticate(&mut self, method: &str) {
self.request(Pending::Authenticate, "authenticate", json!({"methodId": method}));
}
fn set_mode(&mut self, mode: &str) {
if let Some(s) = self.session.clone() {
self.request(Pending::SetMode, "session/set_mode", json!({"sessionId": s, "modeId": mode}));
}
}
fn set_model(&mut self, model: &str) {
if let Some(s) = self.session.clone() {
self.request(Pending::SetModel, "session/set_model", acp::set_model_params(&s, model));
}
}
fn pid(&self) -> Option<u32> {
self.proc.pid()
}
fn shutdown(&mut self, wait: bool) {
self.proc.shutdown(wait);
}
}
/// Test connection: start the agent, say hello, open a session without tools, end it. What it
/// answered (its name and version), or what went wrong (it isn't signed in, it didn't answer).
pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result<String, String> {
let mut p = AgentProcess::spawn(argv, cwd, ENV_REMOVE, || {})?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let wait_for = |p: &AgentProcess, id: u64| -> Result<Value, String> {
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match p.from_agent.recv_timeout(left) {
Ok(FromAgent::Line(l)) => {
if let Some(Incoming::Reply { id: got, result }) = acp::parse(&l)
&& got == id
{
return result.map_err(|(code, m)| {
if code == acp::AUTH_REQUIRED || m.to_ascii_lowercase().contains("auth") {
crate::i18n::tr("ui.agent_chat_state_auth").to_string()
} else {
m
}
});
}
}
Ok(FromAgent::Exited(tail)) => {
return Err(tail.last().cloned().unwrap_or_else(|| crate::i18n::tr("ui.agent_chat_exited").to_string()));
}
Err(_) => return Err(crate::i18n::tr("ui.ai_no_answer").to_string()),
}
}
};
p.send(&acp::request(1, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION"))));
let init = wait_for(&p, 1);
let result = init.and_then(|v| {
p.send(&acp::request(2, "session/new", acp::session_new_params(cwd, None)));
wait_for(&p, 2).map(|_| {
let who = acp::agent_info(&v);
crate::i18n::format("ui.ai_test_acp_ok", &[("agent", if who.is_empty() { "?" } else { &who })])
})
});
p.shutdown(true);
result
}