| 1 | //! An [`ed_acp`] agent, hosted inside a Tauri app. |
| 2 | //! |
| 3 | //! A sandboxed App Store build can't launch an ACP agent as a subprocess the way an editor |
| 4 | //! does, so the app runs it in-process: [`AgentState`] serves the agent's [`Profile`] with |
| 5 | //! `ed_acp::serve` over an in-memory channel and talks to it as a regular ACP client. Nothing |
| 6 | //! about the protocol changes; only the transport does. |
| 7 | //! |
| 8 | //! The frontend sees ACP as Tauri commands and events. Session updates are forwarded |
| 9 | //! untouched, so the page renders the ACP schema directly. This crate holds everything that |
| 10 | //! doesn't depend on the app; each app passes a [`HostConfig`] and wraps [`AgentState`]'s |
| 11 | //! methods in its own `#[tauri::command]`s. |
| 12 | //! |
| 13 | //! The client advertises `fs.readTextFile`, `fs.writeTextFile` and `elicitation.url`. Reads |
| 14 | //! and writes are served inside the session's [`Roots`]. A URL elicitation (a product's own |
| 15 | //! sign-in page) is opened in the system browser through `tauri-plugin-opener`, which the app |
| 16 | //! has to register. The client advertises no `terminal`, so the agent runs commands itself, |
| 17 | //! after asking for permission. |
| 18 | |
| 19 | mod client; |
| 20 | |
| 21 | use std::path::{Path, PathBuf}; |
| 22 | use std::sync::Arc; |
| 23 | |
| 24 | use ed_acp::agent_client_protocol::schema::v1::McpServer; |
| 25 | use ed_acp::{normalize, AgentInfo, Profile}; |
| 26 | use serde::{Deserialize, Serialize}; |
| 27 | use tauri::{AppHandle, Manager}; |
| 28 | use tsync::tsync; |
| 29 | |
| 30 | pub use client::{clear_api_key, set_api_key, status, AgentState}; |
| 31 | |
| 32 | /// Returned by commands when the agent isn't signed in or has no API key. The page offers |
| 33 | /// sign-in when it sees this. |
| 34 | pub const AUTH_REQUIRED: &str = "auth_required"; |
| 35 | |
| 36 | /// Names of the Tauri events the host emits to the webview. |
| 37 | #[derive(Clone, Copy)] |
| 38 | pub struct Events { |
| 39 | /// ACP `session/update` notifications, forwarded untouched. |
| 40 | pub session_update: &'static str, |
| 41 | /// `{ requestId, request }` for a permission prompt. |
| 42 | pub permission_requested: &'static str, |
| 43 | /// An [`AgentTurnEndedPayload`]. |
| 44 | pub turn_ended: &'static str, |
| 45 | } |
| 46 | |
| 47 | /// The directories a session may use: its working directory and extra roots. |
| 48 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 49 | pub struct Roots { |
| 50 | pub cwd: PathBuf, |
| 51 | pub extra: Vec<PathBuf>, |
| 52 | } |
| 53 | |
| 54 | impl Roots { |
| 55 | /// The app's own scratch directory (`<app data>/agent`), created if missing, plus `extra`. |
| 56 | /// What a host that has no project folder of its own wants as `cwd`. |
| 57 | pub fn app_scratch(app: &AppHandle, extra: Vec<PathBuf>) -> Result<Self, String> { |
| 58 | let cwd = app |
| 59 | .path() |
| 60 | .app_data_dir() |
| 61 | .map_err(|e| format!("No app data directory: {e}"))? |
| 62 | .join("agent"); |
| 63 | std::fs::create_dir_all(&cwd).map_err(|e| format!("{}: {e}", cwd.display()))?; |
| 64 | Ok(Self { cwd, extra }) |
| 65 | } |
| 66 | |
| 67 | /// A project folder as the working directory. It must be absolute and an existing |
| 68 | /// directory: it becomes the boundary the agent's file tools stay inside. |
| 69 | pub fn project(cwd: &Path) -> Result<Self, String> { |
| 70 | if !cwd.is_absolute() { |
| 71 | return Err(format!("{} is not an absolute path.", cwd.display())); |
| 72 | } |
| 73 | if !cwd.is_dir() { |
| 74 | return Err(format!("{} is not a directory.", cwd.display())); |
| 75 | } |
| 76 | Ok(Self { |
| 77 | cwd: cwd.to_path_buf(), |
| 78 | extra: Vec::new(), |
| 79 | }) |
| 80 | } |
| 81 | |
| 82 | /// Whether `path` is absolute and inside one of the roots, after removing `.` and `..`, |
| 83 | /// so the agent can't walk out of them. |
| 84 | pub fn contains(&self, path: &Path) -> bool { |
| 85 | if !path.is_absolute() { |
| 86 | return false; |
| 87 | } |
| 88 | let path = normalize(path); |
| 89 | std::iter::once(&self.cwd) |
| 90 | .chain(&self.extra) |
| 91 | .any(|root| path.starts_with(normalize(root))) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | /// Everything about the app that the host can't know. |
| 96 | #[derive(Clone)] |
| 97 | pub struct HostConfig { |
| 98 | /// Reported to the agent as `clientInfo.name`. |
| 99 | pub client_name: &'static str, |
| 100 | /// `clientInfo.version`. |
| 101 | pub client_version: &'static str, |
| 102 | /// `<PREFIX>_SURFACE` for the agent's prompt: `app`, neither an editor nor its TUI. |
| 103 | pub surface: &'static str, |
| 104 | /// The agent's profile, without what the platform can't run. |
| 105 | pub profile: fn() -> Arc<dyn Profile>, |
| 106 | /// The roots for a session. The argument is the folder the frontend asked for, if any; |
| 107 | /// `Roots::app_scratch` is the answer for an app without project folders. |
| 108 | pub roots: fn(&AppHandle, Option<&Path>) -> Result<Roots, String>, |
| 109 | /// The MCP servers for a new session, given the signed-in user's access token when the app |
| 110 | /// has one its agent should present. A profile's own built-in servers need none. |
| 111 | pub mcp_servers: fn(&AppHandle, Option<String>) -> Vec<McpServer>, |
| 112 | pub events: Events, |
| 113 | } |
| 114 | |
| 115 | impl HostConfig { |
| 116 | /// The profile's identity: where its env file lives and which variables it reads. |
| 117 | pub fn info(&self) -> AgentInfo { |
| 118 | (self.profile)().info() |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// Response returned by `agent_status`. |
| 123 | #[tsync] |
| 124 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 125 | pub struct AgentStatus { |
| 126 | /// `false` on platforms the agent isn't built for. |
| 127 | pub available: bool, |
| 128 | /// Whether the agent has credentials: a signed-in session or an API key. |
| 129 | pub has_key: bool, |
| 130 | /// The model the agent will use. |
| 131 | pub model: String, |
| 132 | } |
| 133 | |
| 134 | /// One earlier conversation, as `agent_list_sessions` returns it. |
| 135 | #[tsync] |
| 136 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 137 | pub struct AgentSessionSummary { |
| 138 | pub session_id: String, |
| 139 | /// Set by the agent once the conversation has a first exchange. |
| 140 | pub title: Option<String>, |
| 141 | /// ISO 8601, when the conversation last changed. |
| 142 | pub updated_at: Option<String>, |
| 143 | } |
| 144 | |
| 145 | /// Response returned by `agent_open_session`. |
| 146 | #[tsync] |
| 147 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 148 | pub struct AgentSessionOpened { |
| 149 | pub session_id: String, |
| 150 | /// `true` when an earlier conversation was loaded. Its history arrives as |
| 151 | /// `agent_session_update` events before this response. |
| 152 | pub restored: bool, |
| 153 | /// The session's ACP `SessionConfigOption`s (model picker, auto-approve), as the agent |
| 154 | /// sent them. `Value` is any JSON. |
| 155 | pub config_options: Vec<serde_json::Value>, |
| 156 | } |
| 157 | |
| 158 | /// Payload emitted with `agent_turn_ended`. |
| 159 | #[tsync] |
| 160 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 161 | pub struct AgentTurnEndedPayload { |
| 162 | pub session_id: String, |
| 163 | /// ACP stop reason (`end_turn`, `cancelled`, `max_tokens`, …), or `None` |
| 164 | /// on error. |
| 165 | pub stop_reason: Option<String>, |
| 166 | pub error: Option<String>, |
| 167 | } |
| 168 | |
| 169 | #[cfg(test)] |
| 170 | mod tests { |
| 171 | use super::*; |
| 172 | |
| 173 | #[test] |
| 174 | fn roots_keep_the_agent_inside() { |
| 175 | let roots = Roots { |
| 176 | cwd: PathBuf::from("/data/agent"), |
| 177 | extra: vec![PathBuf::from("/data/audio")], |
| 178 | }; |
| 179 | assert!(roots.contains(Path::new("/data/agent/notes.md"))); |
| 180 | assert!(roots.contains(Path::new("/data/audio/song/bass.wav"))); |
| 181 | assert!(!roots.contains(Path::new("/data/agent/../secrets"))); |
| 182 | assert!(!roots.contains(Path::new("/data/agentx/file"))); |
| 183 | assert!(!roots.contains(Path::new("relative/file"))); |
| 184 | } |
| 185 | |
| 186 | #[test] |
| 187 | fn a_project_root_must_be_an_absolute_existing_directory() { |
| 188 | let dir = std::env::temp_dir(); |
| 189 | assert_eq!(Roots::project(&dir).unwrap().cwd, dir); |
| 190 | assert!(Roots::project(Path::new("relative")).is_err()); |
| 191 | assert!(Roots::project(&dir.join("no-such-folder-ed-acp-tauri")).is_err()); |
| 192 | } |
| 193 | } |