feature/sigit-agent-profile
claude/github-issue-40-0au5ua
claude/linked-issue-acp-tui-u4vvpa
claude/minor-release-cut-vkugow
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/acp-cancel-request
feature/acp-session-list-cursor
feature/acp-v1-conformance
feature/agent-tools-multiedit-glob-todos-remember
feature/cargo-workspace
feature/hooks
feature/load-local-model-explicitly
feature/sigit-agent-profile
feature/sigit-code-cloud
feature/xcode-mcp-server
fix/acp-replay-loaded-session-history
fix/acp-replay-reasoning-and-attachments
fix/dsml-doubled-bar-markers
main
release/v1.5.10
release/v1.5.6
release/v1.6.1
| 1 | //! siGit Code's [`Profile`] for `ed-acp`: who the agent is, its prompt and tools, its slash |
| 2 | //! commands, its sign-in, and how a host reaches the official MCP server. |
| 3 | |
| 4 | use std::sync::Arc; |
| 5 | |
| 6 | use agent_client_protocol::schema::v1::{ |
| 7 | AvailableCommand, AvailableCommandInput, HttpHeader, McpServer, McpServerHttp, |
| 8 | UnstructuredCommandInput, |
| 9 | }; |
| 10 | use async_trait::async_trait; |
| 11 | use ed_acp::{ |
| 12 | AgentInfo, AuthProvider, DescribeCtx, LlmEnv, Profile, PromptCtx, SessionCtx, ToolCtx, |
| 13 | ToolOutcome, Toolset, WorkspaceTools, |
| 14 | }; |
| 15 | use serde_json::Value; |
| 16 | |
| 17 | use crate::auth::{SiGitAuth, cloud_base_url}; |
| 18 | use crate::credentials; |
| 19 | use crate::prompt::{CO_AUTHOR_TRAILER_PLACEHOLDER, INIT_PROMPT, SYSTEM_PROMPT}; |
| 20 | |
| 21 | pub const INFO: AgentInfo = AgentInfo { |
| 22 | name: "sigit-agent", |
| 23 | display_name: "siGit Code", |
| 24 | version: env!("CARGO_PKG_VERSION"), |
| 25 | env: LlmEnv { |
| 26 | prefix: "SIGIT_AGENT", |
| 27 | dir_name: "sigit-agent", |
| 28 | default_model: "onde-nova", |
| 29 | }, |
| 30 | }; |
| 31 | |
| 32 | /// Name of the official server in a session, so its tools are `mcp__sigit__*`, which is what |
| 33 | /// [`SYSTEM_PROMPT`] tells the model to call. |
| 34 | const OFFICIAL_MCP: &str = "sigit"; |
| 35 | |
| 36 | /// The official siGit MCP server (issues, pull requests) as a session server for the signed-in |
| 37 | /// user, `None` when signed out. |
| 38 | /// |
| 39 | /// This is passed per session rather than offered as a process-wide built-in server: a shared |
| 40 | /// server would keep the bearer it started with, and a user who signed out and in as someone |
| 41 | /// else would keep reaching the first account's issues. A host calls this when it opens a |
| 42 | /// session and passes the result in `mcpServers`. |
| 43 | pub fn official_mcp_servers() -> Vec<McpServer> { |
| 44 | if matches!( |
| 45 | std::env::var("SIGIT_MCP_OFFICIAL").as_deref(), |
| 46 | Ok("off" | "0" | "false") |
| 47 | ) { |
| 48 | return Vec::new(); |
| 49 | } |
| 50 | let Some(token) = credentials::load_token() else { |
| 51 | return Vec::new(); |
| 52 | }; |
| 53 | let url = std::env::var("SIGIT_MCP_URL") |
| 54 | .ok() |
| 55 | .filter(|url| !url.trim().is_empty()) |
| 56 | .unwrap_or_else(|| format!("{}/mcp", cloud_base_url())); |
| 57 | vec![McpServer::Http( |
| 58 | McpServerHttp::new(OFFICIAL_MCP, url).headers(vec![HttpHeader::new( |
| 59 | "Authorization", |
| 60 | format!("Bearer {token}"), |
| 61 | )]), |
| 62 | )] |
| 63 | } |
| 64 | |
| 65 | /// What the model is told when the host cannot start processes. |
| 66 | const NO_SHELL_NOTE: &str = "\n\nThis host cannot run commands: there is no run_command tool, and no git or shell. The rule \ |
| 67 | above about running commands yourself does not apply here. When something needs a command, \ |
| 68 | say which one and let the user run it."; |
| 69 | |
| 70 | /// siGit Code as an `ed-acp` agent. |
| 71 | #[derive(Debug, Clone, Copy)] |
| 72 | pub struct SiGitCode { |
| 73 | shell: bool, |
| 74 | } |
| 75 | |
| 76 | impl SiGitCode { |
| 77 | /// With the workspace tools, `run_command` included. |
| 78 | pub fn new() -> Self { |
| 79 | Self { shell: true } |
| 80 | } |
| 81 | |
| 82 | /// For hosts that cannot start processes (an iOS app, a sandboxed Mac app): the workspace |
| 83 | /// tools without `run_command`, and a prompt that says so. |
| 84 | pub fn without_shell() -> Self { |
| 85 | Self { shell: false } |
| 86 | } |
| 87 | |
| 88 | /// The model-facing tool list for this host, in the system prompt. |
| 89 | fn host_note(&self) -> String { |
| 90 | let tools = if self.shell { |
| 91 | "read_file, write_file, edit_file, list_directory and run_command" |
| 92 | } else { |
| 93 | "read_file, write_file, edit_file and list_directory" |
| 94 | }; |
| 95 | format!( |
| 96 | "\n\nIn this host your file tools are {tools}, plus the tools of connected MCP \ |
| 97 | servers. Tools named above that are not in this list (such as read_website or \ |
| 98 | create_file) are not available; use write_file to create files." |
| 99 | ) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | impl Default for SiGitCode { |
| 104 | fn default() -> Self { |
| 105 | Self::new() |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// `WorkspaceTools` minus `run_command`. |
| 110 | struct FileTools; |
| 111 | |
| 112 | fn is_run_command(definition: &Value) -> bool { |
| 113 | definition["function"]["name"] == "run_command" |
| 114 | } |
| 115 | |
| 116 | #[async_trait] |
| 117 | impl Toolset for FileTools { |
| 118 | fn definitions(&self) -> Vec<Value> { |
| 119 | WorkspaceTools |
| 120 | .definitions() |
| 121 | .into_iter() |
| 122 | .filter(|definition| !is_run_command(definition)) |
| 123 | .collect() |
| 124 | } |
| 125 | |
| 126 | fn handles(&self, name: &str) -> bool { |
| 127 | name != "run_command" && WorkspaceTools.handles(name) |
| 128 | } |
| 129 | |
| 130 | fn describe( |
| 131 | &self, |
| 132 | name: &str, |
| 133 | args: &Value, |
| 134 | ctx: &DescribeCtx<'_>, |
| 135 | ) -> ( |
| 136 | String, |
| 137 | agent_client_protocol::schema::v1::ToolKind, |
| 138 | Vec<agent_client_protocol::schema::v1::ToolCallLocation>, |
| 139 | ) { |
| 140 | WorkspaceTools.describe(name, args, ctx) |
| 141 | } |
| 142 | |
| 143 | async fn execute( |
| 144 | &self, |
| 145 | ctx: &ToolCtx, |
| 146 | tool_call_id: &str, |
| 147 | name: &str, |
| 148 | args: Value, |
| 149 | ) -> anyhow::Result<ToolOutcome> { |
| 150 | if name == "run_command" { |
| 151 | anyhow::bail!("This host cannot run commands."); |
| 152 | } |
| 153 | WorkspaceTools.execute(ctx, tool_call_id, name, args).await |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | /// The co-author trailer for a commit made from `surface`, in the format `sigit` uses. |
| 158 | fn co_author_trailer(surface: &str) -> String { |
| 159 | format!( |
| 160 | "Co-Authored-By: siGit Code v{}-{surface} <noreply@sigit.si>", |
| 161 | INFO.version |
| 162 | ) |
| 163 | } |
| 164 | |
| 165 | /// `/init`'s name and what it does, advertised to the client. |
| 166 | fn init_command() -> AvailableCommand { |
| 167 | AvailableCommand::new("init", "Create or improve AGENTS.md for this repository").input( |
| 168 | AvailableCommandInput::Unstructured(UnstructuredCommandInput::new("")), |
| 169 | ) |
| 170 | } |
| 171 | |
| 172 | #[async_trait] |
| 173 | impl Profile for SiGitCode { |
| 174 | fn info(&self) -> AgentInfo { |
| 175 | INFO |
| 176 | } |
| 177 | |
| 178 | fn system_prompt(&self, ctx: &PromptCtx<'_>) -> String { |
| 179 | let mut prompt = SYSTEM_PROMPT.replace( |
| 180 | CO_AUTHOR_TRAILER_PLACEHOLDER, |
| 181 | &co_author_trailer(ctx.surface), |
| 182 | ); |
| 183 | prompt.push_str(&self.host_note()); |
| 184 | if !self.shell { |
| 185 | prompt.push_str(NO_SHELL_NOTE); |
| 186 | } |
| 187 | prompt.push_str(&format!("\n\nWorking directory: {}", ctx.cwd.display())); |
| 188 | if ctx.roots.len() > 1 { |
| 189 | let roots: Vec<String> = ctx.roots.iter().map(|r| r.display().to_string()).collect(); |
| 190 | prompt.push_str(&format!("\nProject roots: {}", roots.join(", "))); |
| 191 | } |
| 192 | prompt |
| 193 | } |
| 194 | |
| 195 | fn toolsets(&self) -> Vec<Arc<dyn Toolset>> { |
| 196 | if self.shell { |
| 197 | vec![Arc::new(WorkspaceTools)] |
| 198 | } else { |
| 199 | vec![Arc::new(FileTools)] |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | fn slash_commands(&self, _ctx: &SessionCtx<'_>) -> Vec<AvailableCommand> { |
| 204 | vec![init_command()] |
| 205 | } |
| 206 | |
| 207 | fn expand_slash(&self, text: &str, _ctx: &SessionCtx<'_>) -> Option<String> { |
| 208 | let rest = text.trim_start().strip_prefix("/init")?; |
| 209 | // `/initialize` is not `/init`. |
| 210 | if rest.is_empty() || rest.starts_with(char::is_whitespace) { |
| 211 | Some(INIT_PROMPT.to_string()) |
| 212 | } else { |
| 213 | None |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | fn auth(&self) -> Option<Arc<dyn AuthProvider>> { |
| 218 | Some(Arc::new(SiGitAuth)) |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | #[cfg(test)] |
| 223 | mod tests { |
| 224 | use std::path::Path; |
| 225 | |
| 226 | use super::*; |
| 227 | |
| 228 | fn names(toolsets: &[Arc<dyn Toolset>]) -> Vec<String> { |
| 229 | toolsets |
| 230 | .iter() |
| 231 | .flat_map(|t| t.definitions()) |
| 232 | .map(|d| d["function"]["name"].as_str().unwrap().to_string()) |
| 233 | .collect() |
| 234 | } |
| 235 | |
| 236 | #[test] |
| 237 | fn the_shell_variant_offers_run_command_and_the_other_does_not() { |
| 238 | let with = names(&SiGitCode::new().toolsets()); |
| 239 | let without = names(&SiGitCode::without_shell().toolsets()); |
| 240 | assert!(with.contains(&"run_command".to_string())); |
| 241 | assert!(!without.contains(&"run_command".to_string())); |
| 242 | assert!(without.contains(&"edit_file".to_string())); |
| 243 | assert!(!FileTools.handles("run_command")); |
| 244 | assert!(FileTools.handles("read_file")); |
| 245 | } |
| 246 | |
| 247 | #[test] |
| 248 | fn the_prompt_carries_the_surface_trailer_and_names_the_host() { |
| 249 | let mcp = ed_acp::ed_mcp::McpToolset::empty(ed_acp::ed_mcp::ToolsetConfig::new( |
| 250 | ed_acp::ed_mcp::ClientInfo::new("t", "0"), |
| 251 | )); |
| 252 | let roots = vec![Path::new("/work/a").to_path_buf()]; |
| 253 | let ctx = PromptCtx { |
| 254 | surface: "app", |
| 255 | cwd: Path::new("/work/a"), |
| 256 | roots: &roots, |
| 257 | mcp: &mcp, |
| 258 | }; |
| 259 | let prompt = SiGitCode::without_shell().system_prompt(&ctx); |
| 260 | assert!(prompt.contains("siGit Code v"), "{prompt}"); |
| 261 | assert!(prompt.contains("-app <noreply@sigit.si>")); |
| 262 | assert!(!prompt.contains(CO_AUTHOR_TRAILER_PLACEHOLDER)); |
| 263 | assert!(prompt.contains("cannot run commands")); |
| 264 | assert!(prompt.contains("Working directory: /work/a")); |
| 265 | let with_shell = SiGitCode::new().system_prompt(&ctx); |
| 266 | assert!(!with_shell.contains("cannot run commands")); |
| 267 | } |
| 268 | |
| 269 | #[test] |
| 270 | fn init_expands_to_the_init_prompt_and_only_init() { |
| 271 | let mcp = ed_acp::ed_mcp::McpToolset::empty(ed_acp::ed_mcp::ToolsetConfig::new( |
| 272 | ed_acp::ed_mcp::ClientInfo::new("t", "0"), |
| 273 | )); |
| 274 | let ctx = SessionCtx { mcp: &mcp }; |
| 275 | let profile = SiGitCode::new(); |
| 276 | assert_eq!( |
| 277 | profile.expand_slash("/init", &ctx).as_deref(), |
| 278 | Some(INIT_PROMPT) |
| 279 | ); |
| 280 | assert!(profile.expand_slash(" /init now", &ctx).is_some()); |
| 281 | assert!(profile.expand_slash("/initialize", &ctx).is_none()); |
| 282 | assert!(profile.expand_slash("hello", &ctx).is_none()); |
| 283 | } |
| 284 | |
| 285 | #[test] |
| 286 | fn the_profile_brings_its_own_sign_in() { |
| 287 | assert!(SiGitCode::new().auth().is_some()); |
| 288 | } |
| 289 | } |