| 1 | //! An Agent Client Protocol (ACP) v1 server for Onde agents. |
| 2 | //! |
| 3 | //! `ed-acp` is the part of an ACP agent that is the same for every product: `initialize` and |
| 4 | //! version negotiation, terminal auth, `authenticate` and `logout`, durable sessions (`list`, |
| 5 | //! `load` with full replay, `resume`, `close`, `delete`), the model picker and auto-approve |
| 6 | //! config options, slash-command advertisement, prompt content (embedded and linked files, |
| 7 | //! images, audio), the turn loop against Onde Cloud (or any OpenAI API compatible endpoint) |
| 8 | //! with streaming, message ids and usage, tool approval, the workspace tools routed through the |
| 9 | //! client's `fs/*` and `terminal/*`, and MCP servers through [`ed_mcp`]. |
| 10 | //! |
| 11 | //! A product supplies a [`Profile`]: its name, prompt, tools, slash commands and built-in MCP |
| 12 | //! servers. Everything in the Onde Agent Platform §5.5 conformance table is implemented here |
| 13 | //! once. |
| 14 | //! |
| 15 | //! ```ignore |
| 16 | //! let profile = Arc::new(MyProfile); |
| 17 | //! ed_acp::serve_stdio(profile, ServeOptions::from_env(&info)).await?; |
| 18 | //! ``` |
| 19 | //! |
| 20 | //! To embed the agent in a host process instead (an app sandbox that can't launch a |
| 21 | //! subprocess, say), serve it over an in-memory channel and run an ACP client on the other end: |
| 22 | //! |
| 23 | //! ```ignore |
| 24 | //! let (agent_end, client_end) = agent_client_protocol::Channel::duplex(); |
| 25 | //! tokio::spawn(ed_acp::serve(profile, opts, agent_end)); |
| 26 | //! agent_client_protocol::Client |
| 27 | //! .builder() |
| 28 | //! .connect_with(client_end, |conn| async move { |
| 29 | //! conn.send_request(InitializeRequest::new(ProtocolVersion::V1)).block_task().await?; |
| 30 | //! Ok(()) |
| 31 | //! }) |
| 32 | //! .await?; |
| 33 | //! ``` |
| 34 | |
| 35 | mod agent; |
| 36 | pub mod auth; |
| 37 | pub mod cli; |
| 38 | pub mod content; |
| 39 | pub mod llm; |
| 40 | mod local; |
| 41 | mod server; |
| 42 | pub mod store; |
| 43 | pub mod tools; |
| 44 | mod workspace; |
| 45 | |
| 46 | use std::path::{Path, PathBuf}; |
| 47 | use std::sync::Arc; |
| 48 | |
| 49 | use agent_client_protocol::schema::v1::{AvailableCommand, PromptCapabilities}; |
| 50 | use async_trait::async_trait; |
| 51 | use ed_mcp::{Connection, McpTool, McpToolset, SharedServer}; |
| 52 | use serde_json::Value; |
| 53 | |
| 54 | pub use agent_client_protocol; |
| 55 | pub use auth::{AuthCtx, AuthProvider}; |
| 56 | pub use content::AudioHints; |
| 57 | pub use ed_mcp; |
| 58 | pub use llm::{LlmConfig, LlmEnv, Provider}; |
| 59 | pub use server::{ServeOptions, serve, serve_stdio}; |
| 60 | pub use tools::{ |
| 61 | DescribeCtx, ToolCtx, ToolOutcome, Toolset, absolutize, function_def, normalize, resolve_in, |
| 62 | truncate, |
| 63 | }; |
| 64 | pub use workspace::WorkspaceTools; |
| 65 | |
| 66 | /// Who the agent is. |
| 67 | #[derive(Debug, Clone, Copy)] |
| 68 | pub struct AgentInfo { |
| 69 | /// Binary and protocol name, e.g. `splitfire-agent`. Reported as `agentInfo.name` and in |
| 70 | /// `clientInfo` to MCP servers. |
| 71 | pub name: &'static str, |
| 72 | /// Name shown to people, e.g. `SplitFire`. |
| 73 | pub display_name: &'static str, |
| 74 | pub version: &'static str, |
| 75 | /// Where settings and credentials come from. |
| 76 | pub env: LlmEnv, |
| 77 | } |
| 78 | |
| 79 | /// What the system prompt is built from, every turn. |
| 80 | pub struct PromptCtx<'a> { |
| 81 | /// `acp` or `tui`; see [`ServeOptions::surface`]. |
| 82 | pub surface: &'a str, |
| 83 | pub cwd: &'a Path, |
| 84 | pub roots: &'a [PathBuf], |
| 85 | /// The session's MCP tools, so the prompt can mention optional capabilities only when |
| 86 | /// they are connected. |
| 87 | pub mcp: &'a McpToolset, |
| 88 | } |
| 89 | |
| 90 | /// What slash commands depend on. |
| 91 | pub struct SessionCtx<'a> { |
| 92 | pub mcp: &'a McpToolset, |
| 93 | } |
| 94 | |
| 95 | /// What a slash command answered without the model can report. |
| 96 | pub struct CommandCtx<'a> { |
| 97 | pub mcp: &'a McpToolset, |
| 98 | /// The session's model. |
| 99 | pub model: &'a str, |
| 100 | /// The models offered in the model picker. |
| 101 | pub models: &'a [String], |
| 102 | } |
| 103 | |
| 104 | /// An MCP call awaiting approval. |
| 105 | pub struct McpCall<'a> { |
| 106 | pub tool: &'a McpTool, |
| 107 | pub args: &'a Value, |
| 108 | /// The server, for products that consult it (e.g. to report a download size). |
| 109 | pub connection: Option<&'a Connection>, |
| 110 | } |
| 111 | |
| 112 | /// The product-specific part of an ACP agent. |
| 113 | #[async_trait] |
| 114 | pub trait Profile: Send + Sync + 'static { |
| 115 | fn info(&self) -> AgentInfo; |
| 116 | |
| 117 | /// The system prompt for one turn. |
| 118 | fn system_prompt(&self, ctx: &PromptCtx<'_>) -> String; |
| 119 | |
| 120 | /// The tools the model may call, besides MCP tools. Include [`WorkspaceTools`] to offer |
| 121 | /// file and shell access. |
| 122 | fn toolsets(&self) -> Vec<Arc<dyn Toolset>>; |
| 123 | |
| 124 | /// Commands advertised with `available_commands_update` after a session is created or |
| 125 | /// reopened. |
| 126 | fn slash_commands(&self, _ctx: &SessionCtx<'_>) -> Vec<AvailableCommand> { |
| 127 | Vec::new() |
| 128 | } |
| 129 | |
| 130 | /// When `text` starts with one of this profile's commands, the instruction the model gets |
| 131 | /// in its place. |
| 132 | fn expand_slash(&self, _text: &str, _ctx: &SessionCtx<'_>) -> Option<String> { |
| 133 | None |
| 134 | } |
| 135 | |
| 136 | /// When `text` is one of this profile's commands that needs no model (`/models`, `/setup`), |
| 137 | /// the reply shown in the thread. It is not added to the conversation. |
| 138 | fn answer_slash(&self, _text: &str, _ctx: &CommandCtx<'_>) -> Option<String> { |
| 139 | None |
| 140 | } |
| 141 | |
| 142 | /// Process-wide MCP servers offered to every session unless the client supplies one of |
| 143 | /// the same name. |
| 144 | fn builtin_mcp_servers(&self) -> Vec<&'static SharedServer> { |
| 145 | Vec::new() |
| 146 | } |
| 147 | |
| 148 | /// A sign-in of the product's own, for products whose credentials are not an API key in |
| 149 | /// the environment. With one, `initialize` advertises its method, the turn loop reads |
| 150 | /// its [`LlmConfig`], and `authenticate` and `logout` delegate to it. `None` keeps the |
| 151 | /// terminal `--setup` flow. |
| 152 | fn auth(&self) -> Option<Arc<dyn AuthProvider>> { |
| 153 | None |
| 154 | } |
| 155 | |
| 156 | /// Advertised in `initialize`. Audio is accepted only when this says so. |
| 157 | fn prompt_capabilities(&self) -> PromptCapabilities { |
| 158 | PromptCapabilities::new().embedded_context(true).image(true) |
| 159 | } |
| 160 | |
| 161 | /// How audio links and attachments are described to the model. |
| 162 | fn audio_hints(&self) -> AudioHints { |
| 163 | AudioHints::default() |
| 164 | } |
| 165 | |
| 166 | /// The permission prompt for a mutating MCP tool. `None` uses the default: the tool, its |
| 167 | /// server and its arguments. |
| 168 | async fn mcp_permission_preview(&self, _call: McpCall<'_>) -> Option<String> { |
| 169 | None |
| 170 | } |
| 171 | |
| 172 | /// Files an MCP result produced, from its `structuredContent`, shown as tool-call |
| 173 | /// locations. Relative paths are dropped. |
| 174 | fn mcp_result_locations(&self, _tool: &McpTool, _structured: &Value) -> Vec<PathBuf> { |
| 175 | Vec::new() |
| 176 | } |
| 177 | } |