Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions crates/acp/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ pub struct AcpAgentDef {
/// Whether `command` resolves on PATH. Only filled in by `list_agents`.
#[serde(default)]
pub available: bool,
/// Whether the resolved launcher is the agent's own installed binary
/// rather than the `npx` download fallback. `available` alone says only
/// that *something* can be spawned: with Node present every npm-published
/// agent is "available", which is not what a caller asking "is this agent
/// installed?" means.
#[serde(default)]
pub local: bool,
}

/// One way to launch an agent. Presets list several, local binary first and
Expand Down Expand Up @@ -154,6 +161,7 @@ impl Preset {
.find(|launcher| which::which(launcher.command).is_ok());
let available = found.is_some();
let launcher = found.unwrap_or_else(|| self.launchers.last().expect("preset launcher"));
let local = available && launcher.command != "npx";

AcpAgentDef {
id: self.id.to_string(),
Expand All @@ -166,6 +174,7 @@ impl Preset {
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
available,
local,
}
}
}
Expand Down Expand Up @@ -217,6 +226,7 @@ fn user_agents() -> Vec<AcpAgentDef> {
id: entry.id.unwrap_or_else(|| slug(&name)),
name,
available: which::which(&entry.command).is_ok(),
local: which::which(&entry.command).is_ok(),
command: entry.command,
args: entry.args,
env: entry.env,
Expand Down Expand Up @@ -245,3 +255,76 @@ pub fn list_agents() -> Vec<AcpAgentDef> {
pub fn find_preset(id: &str) -> Option<AcpAgentDef> {
list_agents().into_iter().find(|a| a.id == id)
}

/// The npm package a preset would otherwise download on every run, read off
/// its `npx` launcher so there is only one place naming a package. `None` for
/// an agent that has no npm distribution — those can only be installed by hand.
fn npm_package(id: &str) -> Option<&'static str> {
let preset = PRESETS.iter().find(|p| p.id == id)?;
let npx = preset.launchers.iter().find(|l| l.command == "npx")?;
let package = npx.args.iter().find(|a| !a.starts_with('-'))?;
Some(package)
}

/// Install a preset globally with npm, so it resolves on PATH from then on.
///
/// The `npx` fallback launcher means an agent can run without this, but only
/// by re-downloading the package on every spawn — and only when Node is
/// installed at all. This is the one-off that makes the agent local.
pub fn install_preset(id: &str) -> Result<AcpAgentDef, String> {
let package = npm_package(id).ok_or_else(|| format!("{id} has no npm package to install"))?;
let npm = which::which("npm")
.map_err(|_| "npm was not found on PATH — install Node.js first.".to_string())?;

log::info!("acp: installing {id} via npm install -g {package}");
let output = std::process::Command::new(npm)
.args(["install", "-g", package])
.output()
.map_err(|e| format!("could not run npm: {e}"))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let message = stderr.trim();
return Err(if message.is_empty() {
format!("npm install -g {package} failed")
} else {
message.to_string()
});
}

let def = find_preset(id).ok_or_else(|| format!("{id} is not a known agent"))?;
log::info!(
"acp: installed {id}: command={} local={} available={}",
def.command,
def.local,
def.available
);
Ok(def)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn npm_package_comes_from_the_npx_launcher() {
assert_eq!(npm_package("keke"), Some("@milisp/keke@latest"));
// No npx launcher, so nothing to install for us.
assert_eq!(npm_package("kiro"), None);
assert_eq!(npm_package("nope"), None);
}

#[test]
fn npx_fallback_is_not_a_local_install() {
let def = AcpAgentDef {
id: "keke".into(),
name: "Keke".into(),
command: "npx".into(),
args: vec![],
env: Default::default(),
available: true,
local: false,
};
assert!(def.available && !def.local);
}
}
8 changes: 8 additions & 0 deletions crates/acp/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ pub struct AcpClient {
pub agent_id: String,
/// Display name of the agent, stored alongside persisted sessions.
pub agent_name: String,
/// The bot this process was started for, when it was started from the Bot
/// tab. Every session it opens is tagged with it, which is what keeps a
/// bot's conversations out of the per-project session lists.
pub bot_id: Option<String>,
/// Sessions opened on this connection, keyed by ACP session id. One agent
/// process can host several sessions at once.
sessions: Arc<DashMap<String, ()>>,
Expand All @@ -50,6 +54,7 @@ impl AcpClient {
connection_id: String,
agent: &AcpAgentDef,
cwd: Option<&str>,
bot_id: Option<String>,
sink: Arc<dyn EventSink>,
) -> Result<(Arc<Self>, Value), String> {
let mut cmd = Command::new(&agent.command);
Expand All @@ -74,6 +79,7 @@ impl AcpClient {
connection_id: connection_id.clone(),
agent_id: agent.id.clone(),
agent_name: agent.name.clone(),
bot_id,
sessions: Arc::new(DashMap::new()),
last_session: Mutex::new(None),
replaying: Arc::new(DashMap::new()),
Expand Down Expand Up @@ -159,6 +165,7 @@ impl AcpClient {
&self.agent_id,
Some(&self.agent_name),
cwd,
self.bot_id.as_deref(),
) {
log::warn!("acp: failed to record session: {e}");
}
Expand Down Expand Up @@ -186,6 +193,7 @@ impl AcpClient {
&self.agent_id,
Some(&self.agent_name),
cwd,
self.bot_id.as_deref(),
) {
log::warn!("acp: failed to record session: {e}");
}
Expand Down
2 changes: 1 addition & 1 deletion crates/acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub mod agents;
pub mod client;
pub mod state;

pub use agents::{AcpAgentDef, find_preset, list_agents};
pub use agents::{AcpAgentDef, find_preset, install_preset, list_agents};
/// Persisted session list and transcripts, stored by the client as it runs.
pub use codexia_db::acp_sessions::{
AcpSessionRecord, delete_session, get_updates, list_sessions,
Expand Down
4 changes: 3 additions & 1 deletion crates/acp/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ impl AcpState {
agent_id: &str,
cwd: &str,
custom: Option<AcpAgentDef>,
bot_id: Option<String>,
) -> Result<AcpStartResult, String> {
let agent = match custom {
Some(a) => a,
Expand All @@ -50,7 +51,8 @@ impl AcpState {

let connection_id = uuid::Uuid::new_v4().to_string();
let (client, initialize) =
AcpClient::spawn(connection_id.clone(), &agent, Some(cwd), self.sink.clone()).await?;
AcpClient::spawn(connection_id.clone(), &agent, Some(cwd), bot_id, self.sink.clone())
.await?;
self.connections.insert(connection_id.clone(), client.clone());

let (session, session_error) = match client.new_session(cwd).await {
Expand Down
4 changes: 2 additions & 2 deletions crates/acp/tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ impl EventSink for PrintSink {
async fn gemini_prompt_roundtrip() {
let state = AcpState::new(Arc::new(PrintSink));
let cwd = std::env::temp_dir().display().to_string();
let started = state.start("gemini", &cwd, None).await.expect("start");
let started = state.start("gemini", &cwd, None, None).await.expect("start");
println!("initialize: {}", started.initialize);
assert!(
started.session_id.is_some(),
Expand Down Expand Up @@ -66,7 +66,7 @@ async fn gemini_prompt_roundtrip() {
async fn grok_reports_session_config() {
let state = AcpState::new(Arc::new(PrintSink));
let cwd = std::env::temp_dir().display().to_string();
let started = state.start("grok", &cwd, None).await.expect("start");
let started = state.start("grok", &cwd, None, None).await.expect("start");
let session = started.session.expect("session/new result");
println!("models: {}", session.get("models").unwrap_or(&Value::Null));
println!("modes: {}", session.get("modes").unwrap_or(&Value::Null));
Expand Down
61 changes: 52 additions & 9 deletions crates/db/src/acp_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ pub struct AcpSessionRecord {
pub agent_id: String,
pub agent_title: Option<String>,
pub cwd: String,
/// The bot this conversation belongs to, when it was opened from the Bot
/// tab. `None` for the ordinary per-project ACP sessions.
pub bot_id: Option<String>,
/// First user message of the session, used as the list label.
pub title: Option<String>,
pub created_at: String,
Expand All @@ -27,18 +30,20 @@ pub fn upsert_session(
agent_id: &str,
agent_title: Option<&str>,
cwd: &str,
bot_id: Option<&str>,
) -> Result<(), String> {
let conn = get_connection()?;
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO acp_sessions (
session_id, agent_id, agent_title, cwd, title, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)
session_id, agent_id, agent_title, cwd, bot_id, title, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?6)
ON CONFLICT(session_id) DO UPDATE SET
agent_id = excluded.agent_id,
agent_title = excluded.agent_title,
cwd = excluded.cwd",
params![session_id, agent_id, agent_title, cwd, now],
cwd = excluded.cwd,
bot_id = excluded.bot_id",
params![session_id, agent_id, agent_title, cwd, bot_id, now],
)
.map_err(|e| format!("Failed to upsert ACP session: {}", e))?;
Ok(())
Expand Down Expand Up @@ -75,15 +80,17 @@ pub fn set_title_if_empty(session_id: &str, title: &str) -> Result<(), String> {
}

/// Sessions for `cwd`, or all of them when `cwd` is `None`, newest first.
/// Bot conversations are left out: they belong to a bot, not to a project, and
/// listing them beside the project's own sessions would show each twice.
pub fn list_sessions(cwd: Option<&str>, limit: usize) -> Result<Vec<AcpSessionRecord>, String> {
let conn = get_connection()?;
let limit = if limit == 0 { 100 } else { limit.min(500) } as i64;

let mut stmt = conn
.prepare(
"SELECT session_id, agent_id, agent_title, cwd, title, created_at, updated_at
"SELECT session_id, agent_id, agent_title, cwd, bot_id, title, created_at, updated_at
FROM acp_sessions
WHERE (?1 IS NULL OR cwd = ?1)
WHERE (?1 IS NULL OR cwd = ?1) AND bot_id IS NULL
ORDER BY updated_at DESC
LIMIT ?2",
)
Expand All @@ -96,9 +103,10 @@ pub fn list_sessions(cwd: Option<&str>, limit: usize) -> Result<Vec<AcpSessionRe
agent_id: row.get(1)?,
agent_title: row.get(2)?,
cwd: row.get(3)?,
title: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
bot_id: row.get(4)?,
title: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
})
.map_err(|e| format!("Failed to query ACP sessions: {}", e))?;
Expand All @@ -107,6 +115,41 @@ pub fn list_sessions(cwd: Option<&str>, limit: usize) -> Result<Vec<AcpSessionRe
.map_err(|e| format!("Failed to read ACP sessions: {}", e))
}

/// One bot's conversations, newest first. Reads the table only — the sidebar
/// must be able to show a bot without waking its agent process.
pub fn list_bot_sessions(bot_id: &str, limit: usize) -> Result<Vec<AcpSessionRecord>, String> {
let conn = get_connection()?;
let limit = if limit == 0 { 100 } else { limit.min(500) } as i64;

let mut stmt = conn
.prepare(
"SELECT session_id, agent_id, agent_title, cwd, bot_id, title, created_at, updated_at
FROM acp_sessions
WHERE bot_id = ?1
ORDER BY updated_at DESC
LIMIT ?2",
)
.map_err(|e| format!("Failed to prepare bot session list query: {}", e))?;

let rows = stmt
.query_map(params![bot_id, limit], |row| {
Ok(AcpSessionRecord {
session_id: row.get(0)?,
agent_id: row.get(1)?,
agent_title: row.get(2)?,
cwd: row.get(3)?,
bot_id: row.get(4)?,
title: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
})
.map_err(|e| format!("Failed to query bot sessions: {}", e))?;

rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to read bot sessions: {}", e))
}

/// The stored transcript, in arrival order. Each item is a `session/update`
/// payload the frontend replays through its normal update handler.
pub fn get_updates(session_id: &str) -> Result<Vec<serde_json::Value>, String> {
Expand Down
Loading