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 }