main
rs 193 lines 7.26 KB
Raw
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 }