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
+}