1 //! Agent tools: schema definitions + execution for siGit Code.
2
3 use regex::Regex;
4 use serde_json::{Value, json};
5 use std::collections::HashMap;
6 use std::fs;
7 use std::path::{Path, PathBuf};
8 use std::process::Command;
9 use std::sync::atomic::{AtomicU64, Ordering};
10 use std::sync::{Arc, Mutex, OnceLock};
11
12 use crate::backend::{InferenceBackend, ToolResult, ToolSpec};
13 use crate::subagents;
14
15 const WEBSITE_READ_CHAR_LIMIT: usize = 20_000;
16 const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
17 const WEBSITE_USER_AGENT: &str =
18 "siGit/0.1 (+https://github.com/getsigit/sigit; website-reading tool)";
19
20 const READ_FILE_CHAR_LIMIT: usize = 10_000;
21 const SEARCH_FILES_MATCH_LIMIT: usize = 50;
22 /// Upper bound on `max_results` for `search_files` and the number of paths
23 /// returned by `glob`, so a broad pattern can't flood the context window.
24 const SEARCH_RESULTS_HARD_CAP: usize = 1_000;
25
26 // ── Tool schemas ─────────────────────────────────────────────────────────────
27
28 pub struct AgentTool {
29 pub name: &'static str,
30 pub description: &'static str,
31 pub parameters_schema: Value,
32 }
33
34 pub fn all_tools() -> Vec<AgentTool> {
35 vec![
36 AgentTool {
37 name: "read_file",
38 description: "Read the contents of a file at the given path. \
39 Prefer an absolute path when possible. Use start_line and \
40 end_line to read a specific range instead of the whole file — \
41 strongly prefer this when you already know which lines matter. \
42 Output is truncated to 10 000 characters.",
43 parameters_schema: json!({
44 "type": "object",
45 "properties": {
46 "path": {
47 "type": "string",
48 "description": "Absolute or relative path to the file to read."
49 },
50 "start_line": {
51 "type": "integer",
52 "description": "First line to read (1-based, inclusive). Omit to start from the beginning."
53 },
54 "end_line": {
55 "type": "integer",
56 "description": "Last line to read (1-based, inclusive). Omit to read to the end."
57 }
58 },
59 "required": ["path"],
60 "additionalProperties": false
61 }),
62 },
63 AgentTool {
64 name: "create_directory",
65 description: "Create a directory at the given path. \
66 Prefer an absolute path when possible. Missing parent \
67 directories are created automatically. Use this before \
68 create_file when the parent path does not exist. Succeeds \
69 if the directory already exists.",
70 parameters_schema: json!({
71 "type": "object",
72 "properties": {
73 "path": {
74 "type": "string",
75 "description": "Absolute or relative path to the directory to create."
76 }
77 },
78 "required": ["path"],
79 "additionalProperties": false
80 }),
81 },
82 AgentTool {
83 name: "list_directory",
84 description: "List files and directories at the given path. \
85 Prefer an absolute path when possible. Each entry is \
86 prefixed with [DIR] or [FILE]. Directories are listed \
87 first, sorted alphabetically.",
88 parameters_schema: json!({
89 "type": "object",
90 "properties": {
91 "path": {
92 "type": "string",
93 "description": "Absolute or relative path to the directory to list."
94 }
95 },
96 "required": ["path"],
97 "additionalProperties": false
98 }),
99 },
100 AgentTool {
101 name: "search_files",
102 description: "Search for a regex pattern across files in a directory tree. \
103 Prefer an absolute root path when possible. Returns matching \
104 lines in `file:line_number: content` format. Skips binary \
105 files and hidden directories. Pass `file_glob` to restrict the \
106 search to files whose name matches a glob (e.g. \"*.rs\"), and \
107 `max_results` to raise or lower the default cap of 50 matches.",
108 parameters_schema: json!({
109 "type": "object",
110 "properties": {
111 "pattern": {
112 "type": "string",
113 "description": "Regular expression pattern to search for."
114 },
115 "path": {
116 "type": "string",
117 "description": "Root directory to search in. Defaults to \".\" (current directory)."
118 },
119 "file_glob": {
120 "type": "string",
121 "description": "Optional glob on the file name (not the full path), e.g. \"*.rs\" or \"*.{ts,tsx}\". Only matching files are searched."
122 },
123 "max_results": {
124 "type": "integer",
125 "description": "Maximum number of matching lines to return (default 50, capped at 1000)."
126 }
127 },
128 "required": ["pattern"],
129 "additionalProperties": false
130 }),
131 },
132 AgentTool {
133 name: "read_website",
134 description: "Fetch a web page and return readable text content. \
135 Use this when the user gives you a URL and asks you to read, \
136 summarize, inspect, or extract information from the page. \
137 Supports normal http and https URLs. Output is truncated if the \
138 page is very large.",
139 parameters_schema: json!({
140 "type": "object",
141 "properties": {
142 "url": {
143 "type": "string",
144 "description": "Absolute http or https URL to fetch."
145 }
146 },
147 "required": ["url"],
148 "additionalProperties": false
149 }),
150 },
151 AgentTool {
152 name: "create_file",
153 description: "Create a new file at the given path with the provided content. \
154 Prefer an absolute path when possible. Parent directories are \
155 created automatically if they do not exist. Fails if the file \
156 already exists — use edit_file to modify existing files.",
157 parameters_schema: json!({
158 "type": "object",
159 "properties": {
160 "path": {
161 "type": "string",
162 "description": "Absolute or relative path for the new file."
163 },
164 "content": {
165 "type": "string",
166 "description": "The full text content to write into the new file."
167 }
168 },
169 "required": ["path", "content"],
170 "additionalProperties": false
171 }),
172 },
173 AgentTool {
174 name: "edit_file",
175 description: "Edit an existing file by replacing an exact substring (old_text) with \
176 new text (new_text). Prefer an absolute path when possible. By \
177 default old_text must appear exactly once; set replace_all to true \
178 to replace every occurrence (useful for renaming a symbol). Use \
179 read_file first to see the current content and identify the exact \
180 text to replace. To append to a file, match the last few lines as \
181 old_text and include them plus the new content as new_text.",
182 parameters_schema: json!({
183 "type": "object",
184 "properties": {
185 "path": {
186 "type": "string",
187 "description": "Path to the existing file to edit."
188 },
189 "old_text": {
190 "type": "string",
191 "description": "The exact text span to find and replace. Must match exactly once unless replace_all is true."
192 },
193 "new_text": {
194 "type": "string",
195 "description": "The replacement text that will take the place of old_text."
196 },
197 "replace_all": {
198 "type": "boolean",
199 "description": "Replace every occurrence of old_text instead of requiring a unique match. Defaults to false."
200 }
201 },
202 "required": ["path", "old_text", "new_text"],
203 "additionalProperties": false
204 }),
205 },
206 AgentTool {
207 name: "delete_file",
208 description: "Delete a file or empty directory at the given path. \
209 Prefer an absolute path when possible. Refuses to delete \
210 non-empty directories to prevent accidental data loss. \
211 Use read_file or list_directory first to confirm the target.",
212 parameters_schema: json!({
213 "type": "object",
214 "properties": {
215 "path": {
216 "type": "string",
217 "description": "Absolute or relative path to the file or empty directory to delete."
218 }
219 },
220 "required": ["path"],
221 "additionalProperties": false
222 }),
223 },
224 AgentTool {
225 name: "run_command",
226 description: "Run a shell command and return its combined stdout and stderr output. \
227 The command runs in the given working directory (defaults to the \
228 user's home directory). Always use an absolute working directory \
229 path. Use this for build tools (cargo, npm, make), package managers, \
230 linters, test runners, and git commands, including git init, \
231 porcelain commands like status/add/commit/checkout, and plumbing \
232 commands like rev-parse, hash-object, update-ref, and cat-file. \
233 For `git clone`, always specify the full absolute destination path \
234 as the last argument (e.g. `git clone <url> /absolute/path/to/dir`) \
235 and set cwd to the parent directory. Never run `git clone` without \
236 an explicit destination. If the user asks for a new repo or scaffold, \
237 use this for `git clone`, `git init`, and normal repo setup steps. \
238 In smbCloud repos, prefer existing workspace commands, Rails \
239 conventions, and deploy flows over inventing new command sequences. \
240 Foreground commands are killed after 120 seconds — run servers, \
241 watchers, builds, test suites, and anything that may exceed two \
242 minutes with run_in_background set to true, then poll with \
243 command_output.",
244 parameters_schema: json!({
245 "type": "object",
246 "properties": {
247 "command": {
248 "type": "string",
249 "description": "The shell command to execute (e.g. \"cargo update\", \"git status\", \"git rev-parse HEAD\")."
250 },
251 "cwd": {
252 "type": "string",
253 "description": "Working directory for the command. Defaults to \".\" (current directory)."
254 },
255 "run_in_background": {
256 "type": "boolean",
257 "description": "Start the command as a background task and return a task id immediately instead of waiting for it to finish. Set this to true for servers, watchers, builds, test suites, and anything that may run longer than two minutes (foreground commands are killed after 120 seconds). Poll the task's output and status with command_output, and stop it with kill_command. Background tasks are killed when sigit exits. Defaults to false."
258 }
259 },
260 "required": ["command"],
261 "additionalProperties": false
262 }),
263 },
264 AgentTool {
265 name: "multi_edit",
266 description: "Apply several exact-substring edits to a single file in one call. \
267 Edits are applied in order, each to the result of the previous one, \
268 and the whole batch is atomic — if any edit fails to match, the file \
269 is left untouched and an error explains which edit failed. Prefer \
270 this over multiple edit_file calls when changing several spots in the \
271 same file. Each edit has old_text (must match exactly once, or every \
272 time when replace_all is true) and new_text.",
273 parameters_schema: json!({
274 "type": "object",
275 "properties": {
276 "path": {
277 "type": "string",
278 "description": "Path to the existing file to edit."
279 },
280 "edits": {
281 "type": "array",
282 "description": "Ordered list of edits to apply to the file.",
283 "items": {
284 "type": "object",
285 "properties": {
286 "old_text": {
287 "type": "string",
288 "description": "The exact text span to find and replace."
289 },
290 "new_text": {
291 "type": "string",
292 "description": "The replacement text."
293 },
294 "replace_all": {
295 "type": "boolean",
296 "description": "Replace every occurrence instead of requiring a unique match. Defaults to false."
297 }
298 },
299 "required": ["old_text", "new_text"],
300 "additionalProperties": false
301 }
302 }
303 },
304 "required": ["path", "edits"],
305 "additionalProperties": false
306 }),
307 },
308 AgentTool {
309 name: "glob",
310 description: "Find files by name using a glob pattern (e.g. \"**/*.rs\", \
311 \"src/**/*.{ts,tsx}\", \"Cargo.toml\"). Returns matching file paths, \
312 most-recently-modified first. Supports `*` (any run of non-separator \
313 characters), `**` (any number of directories), `?` (one character), \
314 and `{a,b}` alternation. Use this to locate files by name; use \
315 search_files to search file contents.",
316 parameters_schema: json!({
317 "type": "object",
318 "properties": {
319 "pattern": {
320 "type": "string",
321 "description": "Glob pattern matched against paths relative to the search root."
322 },
323 "path": {
324 "type": "string",
325 "description": "Root directory to search in. Defaults to \".\" (current directory)."
326 }
327 },
328 "required": ["pattern"],
329 "additionalProperties": false
330 }),
331 },
332 AgentTool {
333 name: "write_todos",
334 description: "Record or update a checklist of the steps for the current task. \
335 Use this for any multi-step task to plan the work and show progress: \
336 call it once up front with all the steps as `pending`, then call it \
337 again whenever a step's status changes. Mark exactly one step \
338 `in_progress` at a time and `completed` as soon as it is done. \
339 Keep the list short and outcome-focused.",
340 parameters_schema: json!({
341 "type": "object",
342 "properties": {
343 "todos": {
344 "type": "array",
345 "description": "The full, current checklist (replaces any previous list).",
346 "items": {
347 "type": "object",
348 "properties": {
349 "content": {
350 "type": "string",
351 "description": "Short imperative description of the step."
352 },
353 "status": {
354 "type": "string",
355 "enum": ["pending", "in_progress", "completed"],
356 "description": "Current status of the step."
357 }
358 },
359 "required": ["content", "status"],
360 "additionalProperties": false
361 }
362 }
363 },
364 "required": ["todos"],
365 "additionalProperties": false
366 }),
367 },
368 AgentTool {
369 name: "remember",
370 description: "Persist a durable note, preference, or convention by appending it to \
371 this project's instruction file (AGENTS.md / CLAUDE.md). Use this \
372 when the user asks you to remember something for next time, or states \
373 a lasting preference about how to work in this project. The note is \
374 written to the nearest existing instruction file, or a new CLAUDE.md \
375 at the repository root if none exists yet.",
376 parameters_schema: json!({
377 "type": "object",
378 "properties": {
379 "note": {
380 "type": "string",
381 "description": "The fact or preference to remember, phrased as a standalone instruction."
382 }
383 },
384 "required": ["note"],
385 "additionalProperties": false
386 }),
387 },
388 AgentTool {
389 name: "command_output",
390 description: "Get the output a background task (started with run_command's \
391 run_in_background) has produced since your last check, plus its \
392 status: still running, or exited with an exit code. Poll this \
393 periodically to follow builds, test suites, and servers. Between \
394 polls output is buffered up to 50 000 bytes per task; older \
395 output beyond that is dropped and the truncation is noted.",
396 parameters_schema: json!({
397 "type": "object",
398 "properties": {
399 "task_id": {
400 "type": "integer",
401 "description": "Id of the background task, as returned by run_command with run_in_background."
402 }
403 },
404 "required": ["task_id"],
405 "additionalProperties": false
406 }),
407 },
408 AgentTool {
409 name: "kill_command",
410 description: "Kill a background task started with run_command's run_in_background. \
411 Reports the tail of the task's unread output and confirms it was \
412 killed. Use this to stop servers or watchers you no longer need and \
413 runaway commands. Background tasks are also killed automatically \
414 when sigit exits.",
415 parameters_schema: json!({
416 "type": "object",
417 "properties": {
418 "task_id": {
419 "type": "integer",
420 "description": "Id of the background task, as returned by run_command with run_in_background."
421 }
422 },
423 "required": ["task_id"],
424 "additionalProperties": false
425 }),
426 },
427 ]
428 }
429
430 // ── Tool execution ───────────────────────────────────────────────────────────
431
432 pub async fn execute_tool(name: &str, arguments: &str) -> String {
433 // Settings are read once per call (not once per hook phase) so a
434 // deployment without any [hooks] configured pays one cheap TOML parse
435 // per tool call rather than two.
436 let hook_settings = crate::settings::load().hooks;
437 let cwd = std::env::current_dir().ok();
438
439 if let Some(cwd) = &cwd
440 && !hook_settings.pre_tool_use.is_empty()
441 {
442 crate::hooks::run_pre_tool_use_hooks(&hook_settings, name, arguments, cwd);
443 }
444
445 let result = execute_tool_impl(name, arguments).await;
446
447 if let Some(cwd) = &cwd
448 && !hook_settings.post_tool_use.is_empty()
449 {
450 crate::hooks::run_post_tool_use_hooks(&hook_settings, name, &result, cwd);
451 }
452
453 result
454 }
455
456 async fn execute_tool_impl(name: &str, arguments: &str) -> String {
457 match name {
458 "read_file" => exec_read_file(arguments),
459 "list_directory" => exec_list_directory(arguments),
460 "search_files" => exec_search_files(arguments),
461 "read_website" => {
462 // reqwest::blocking panics inside a tokio runtime, so run on the blocking pool.
463 let args = arguments.to_owned();
464 tokio::task::spawn_blocking(move || exec_read_website(&args))
465 .await
466 .unwrap_or_else(|err| format!("Error: read_website task failed: {err}"))
467 }
468 "create_directory" => exec_create_directory(arguments),
469 "create_file" => exec_create_file(arguments),
470 "edit_file" => exec_edit_file(arguments),
471 "multi_edit" => exec_multi_edit(arguments),
472 "glob" => exec_glob(arguments),
473 "write_todos" => exec_write_todos(arguments),
474 "remember" => exec_remember(arguments),
475 "delete_file" => exec_delete_file(arguments),
476 "run_command" => exec_run_command(arguments),
477 "command_output" => exec_command_output(arguments),
478 "kill_command" => exec_kill_command(arguments),
479 "skill" => crate::skills::activate_skill(arguments),
480 TASK_TOOL_NAME => exec_task(arguments).await,
481 WEB_SEARCH_TOOL_NAME => exec_web_search(arguments).await,
482 // Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>`
483 // and forwarded to the owning server.
484 _ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await,
485 _ => format!("Unknown tool: {name}"),
486 }
487 }
488
489 // ── web_search ───────────────────────────────────────────────────────────────
490 //
491 // siGit Code Cloud runs a Brave-Search-backed `web_search` tool on its official
492 // MCP server (`mcp.rs`'s baked-in "sigit" server, discovered as
493 // `mcp__sigit__web_search`) — the API key and billing live server-side, so
494 // there is no on-device/local equivalent. This is a thin native wrapper
495 // around that MCP tool rather than a second implementation of it, for two
496 // reasons: (1) `mcp.rs` already owns the auth, transport, and error handling,
497 // so re-doing that here would just be a second copy of the same HTTP/JSON-RPC
498 // code; (2) every `mcp__*` tool is classified `Mutating` by
499 // `permissions::classify` (an unknown external tool's side effects can't be
500 // assumed safe), which would put an "ask" prompt in front of a harmless
501 // read-only search on every call. Presenting it under the clean `web_search`
502 // name instead lets `permissions::classify` treat it as read-only, matching
503 // `read_website`'s zero-friction behavior, while `execute_tool_impl` still
504 // delegates the actual call to the exact same `mcp::call_tool` codepath as
505 // any other MCP tool.
506 //
507 // Advertised only when the official server has actually discovered a
508 // `web_search` tool (see `web_search_available`) — the same conditional
509 // pattern as `skill` and `task` — so a signed-out or MCP-disabled session
510 // never sees a capability it can't use.
511
512 pub const WEB_SEARCH_TOOL_NAME: &str = "web_search";
513
514 /// The discovered MCP tool this delegates to: siGit Code Cloud's official
515 /// server is always registered under the name "sigit" (see `mcp.rs`), so its
516 /// tools are namespaced `mcp__sigit__<tool>`.
517 const WEB_SEARCH_MCP_DELEGATE: &str = "mcp__sigit__web_search";
518
519 /// Whether `web_search` should be offered this turn: the official MCP server
520 /// has to have actually discovered a `web_search` tool, which in turn only
521 /// happens when the user is signed in to siGit Code Cloud (an unauthenticated
522 /// request to the official server fails during `mcp::init`'s discovery and
523 /// contributes no tools — see `mcp.rs`). No separate sign-in check is needed
524 /// here: this is exactly the same conditional-advertisement pattern as
525 /// `subagent_available`, just checking a tool name instead of a factory.
526 pub fn web_search_available() -> bool {
527 crate::mcp::tool_specs()
528 .iter()
529 .any(|spec| spec.name == WEB_SEARCH_MCP_DELEGATE)
530 }
531
532 /// Whether `name` is the raw MCP tool [`web_search_tool_spec`] delegates to.
533 /// Callers assembling the full tool list should exclude this name from
534 /// whatever they append from `mcp::tool_specs()`, so the model sees the one
535 /// clean `web_search` option rather than that *and* the raw `mcp__*` name for
536 /// the same underlying tool.
537 pub fn is_web_search_delegate(name: &str) -> bool {
538 name == WEB_SEARCH_MCP_DELEGATE
539 }
540
541 /// Spec for the `web_search` tool. Lives in the `*_as_specs`/`build_tool_specs`
542 /// layer (like `skill` and `task`), not in [`all_tools`], because it is only
543 /// offered when [`web_search_available`] is true. The schema mirrors
544 /// siGit Code Cloud's actual `web_search` MCP tool exactly (`query` + `count`)
545 /// so arguments pass through to [`exec_web_search`] unchanged.
546 pub fn web_search_tool_spec() -> ToolSpec {
547 ToolSpec {
548 name: WEB_SEARCH_TOOL_NAME.to_string(),
549 description: "Search the public web and return matching pages as title, URL, and \
550 snippet. Use this to find pages when you don't already know the URL — for \
551 current events, library/API documentation, error messages, or anything else \
552 outside this repository. Once you have a promising URL, use read_website to \
553 fetch its full content. Requires a signed-in siGit Code Cloud account (the \
554 search runs server-side); rate-limited per account."
555 .to_string(),
556 parameters_schema: json!({
557 "type": "object",
558 "properties": {
559 "query": {
560 "type": "string",
561 "description": "The search query."
562 },
563 "count": {
564 "type": "integer",
565 "description": "Number of results to return (default 5, max 10)."
566 }
567 },
568 "required": ["query"],
569 "additionalProperties": false
570 })
571 .to_string(),
572 }
573 }
574
575 /// The `web_search` tool entry point used by [`execute_tool`]: forward
576 /// verbatim to the discovered MCP tool. `mcp::call_tool` already handles a
577 /// missing/uninitialized server gracefully (an in-band error string, never a
578 /// panic), so there's nothing extra to do here — see the module doc above for
579 /// why this delegates instead of reimplementing the call.
580 async fn exec_web_search(arguments: &str) -> String {
581 crate::mcp::call_tool(WEB_SEARCH_MCP_DELEGATE, arguments).await
582 }
583
584 // ── task (subagent) ──────────────────────────────────────────────────────────
585 //
586 // The `task` tool delegates a self-contained research question to a *nested*
587 // agent loop running in a fresh conversation, so the main thread receives only
588 // the final answer instead of every intermediate file read. The subagent's
589 // toolset is strictly read-only and never includes `task` itself (no
590 // recursion), so a delegated agent can research but not mutate state.
591 //
592 // A caller may optionally pick a *configurable subagent type* (see
593 // `crate::subagents`, `.sigit/agents/*.md`) via the `subagent_type` argument.
594 // A type can only ever replace the system prompt and *narrow* the toolset —
595 // never grant tools outside `SUBAGENT_TOOL_NAMES` — so this stays exactly as
596 // safe as the default research subagent: a `.sigit/agents/*.md` file cannot
597 // grant itself `edit_file` or `run_command` and bypass the permission system.
598 //
599 // This module is backend-agnostic and cannot construct a backend, so the
600 // surface that knows the active provider (`run_acp_server` / `run_interactive`
601 // in `main.rs`) registers a factory at startup. The factory returns `None`
602 // when inference runs on-device: onde has a single shared conversation
603 // history, so a second concurrent context is not possible yet.
604
605 pub const TASK_TOOL_NAME: &str = "task";
606
607 /// Hard cap on how many configurable subagent types are embedded in the
608 /// `task` tool's description, to bound the tool description size — same
609 /// rationale as `skills::MAX_ADVERTISED_SKILLS`.
610 const MAX_ADVERTISED_AGENT_TYPES: usize = 100;
611
612 /// The tool names a subagent may call, filtered from [`all_tools`]. This is
613 /// the hard ceiling for every subagent, default or custom-typed — see the
614 /// module doc above.
615 const SUBAGENT_TOOL_NAMES: &[&str] = &[
616 "read_file",
617 "list_directory",
618 "search_files",
619 "glob",
620 "read_website",
621 ];
622
623 /// System prompt seeding a subagent conversation when no `subagent_type` is
624 /// given (or it doesn't resolve to a configured type).
625 pub const SUBAGENT_SYSTEM_PROMPT: &str = "You are a focused research subagent. \
626 You are given one self-contained task by a calling agent. Investigate it using \
627 the read-only tools available to you (read_file, list_directory, search_files, \
628 glob, read_website) and answer it thoroughly but concisely. You cannot modify \
629 files or run commands. Your final message is returned verbatim to the caller, \
630 so make it a complete, self-contained answer — include the concrete facts, \
631 paths, and code excerpts the caller needs, and nothing else.";
632
633 /// Rounds of tool calls a subagent may use before it is forced to answer.
634 const SUBAGENT_MAX_TOOL_ROUNDS: usize = 8;
635 /// Cap on the answer text returned to the caller.
636 const SUBAGENT_RESULT_CHAR_LIMIT: usize = 8_000;
637
638 /// Returned when `task` is called but no subagent backend can be built.
639 const SUBAGENT_UNAVAILABLE: &str = "The task tool is not available on-device \
640 yet: on-device inference has a single conversation context. Do the research \
641 yourself with the read-only tools.";
642
643 /// Builds a fresh backend for one subagent run seeded with the given system
644 /// prompt, or `None` when the active inference cannot host a second
645 /// conversation (on-device). Takes the prompt per call (rather than baking
646 /// one in at registration) so a configurable subagent type's prompt can
647 /// replace [`SUBAGENT_SYSTEM_PROMPT`] on a call-by-call basis.
648 pub type SubagentFactory = Box<dyn Fn(&str) -> Option<Arc<dyn InferenceBackend>> + Send + Sync>;
649
650 static SUBAGENT_FACTORY: OnceLock<SubagentFactory> = OnceLock::new();
651
652 /// Register the process-wide subagent factory. Called once at startup by the
653 /// surface that resolved the inference provider; later calls are ignored.
654 pub fn set_subagent_factory(factory: SubagentFactory) {
655 let _ = SUBAGENT_FACTORY.set(factory);
656 }
657
658 /// Whether a `task` call could run right now: a factory is registered and it
659 /// can build a backend. The spec builders (`agent_tools_as_specs` /
660 /// `build_tool_specs`) use this to offer the tool only when it works, the same
661 /// conditional pattern as the `skill` tool. Building a backend just to probe
662 /// is cheap (no I/O happens until the first message), so this passes a
663 /// placeholder prompt and discards the result.
664 pub fn subagent_available() -> bool {
665 SUBAGENT_FACTORY
666 .get()
667 .is_some_and(|factory| factory(SUBAGENT_SYSTEM_PROMPT).is_some())
668 }
669
670 /// The read-only toolset offered to a subagent, filtered from [`all_tools`] by
671 /// name. `allowed` must already be a subset of [`SUBAGENT_TOOL_NAMES`] —
672 /// see [`effective_subagent_tools`], the only intended way to build one.
673 fn subagent_tool_specs(allowed: &[String]) -> Vec<ToolSpec> {
674 all_tools()
675 .into_iter()
676 .filter(|tool| allowed.iter().any(|name| name == tool.name))
677 .map(|tool| ToolSpec {
678 name: tool.name.to_string(),
679 description: tool.description.to_string(),
680 parameters_schema: tool.parameters_schema.to_string(),
681 })
682 .collect()
683 }
684
685 /// The toolset a subagent call should actually get: [`SUBAGENT_TOOL_NAMES`]
686 /// by default, or — when `agent_type` sets a `tools:` allow-list — its
687 /// *intersection* with that ceiling. A type can never add a tool outside the
688 /// base read-only set; anything else it lists is dropped with a warning
689 /// rather than silently granted, since that's the whole security property
690 /// this function exists to enforce (see the module doc above).
691 fn effective_subagent_tools(
692 agent_type: Option<&subagents::AgentType>,
693 ) -> Result<Vec<String>, String> {
694 let Some(agent_type) = agent_type else {
695 return Ok(SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect());
696 };
697 let Some(requested) = &agent_type.tools else {
698 // No `tools:` field: inherit the full default read-only set.
699 return Ok(SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect());
700 };
701
702 let mut effective = Vec::new();
703 for name in requested {
704 if SUBAGENT_TOOL_NAMES.contains(&name.as_str()) {
705 effective.push(name.clone());
706 } else {
707 log::warn!(
708 "agent type \"{}\" at {} lists tool \"{name}\", which is not in the \
709 subagent read-only allow-list ({}) — ignoring it",
710 agent_type.name,
711 agent_type.path.display(),
712 SUBAGENT_TOOL_NAMES.join(", ")
713 );
714 }
715 }
716
717 // A `tools:` field that intersects the ceiling to nothing is a
718 // misconfiguration, not a request for a toolless subagent: running it
719 // anyway would answer from the model's priors with no file access and no
720 // signal beyond a log line. Surface it as an error instead.
721 if effective.is_empty() {
722 return Err(format!(
723 "Error: subagent type \"{}\" at {} sets `tools: {}`, none of which \
724 is in the subagent read-only set ({}). Fix the type's `tools:` \
725 field, or omit it to inherit the default read-only tools.",
726 agent_type.name,
727 agent_type.path.display(),
728 requested.join(", "),
729 SUBAGENT_TOOL_NAMES.join(", "),
730 ));
731 }
732 Ok(effective)
733 }
734
735 /// Spec for the `task` tool. Lives in the `*_as_specs`/`build_tool_specs`
736 /// layer (like `skill` and MCP tools), not in [`all_tools`], because it is
737 /// only offered when [`subagent_available`] is true.
738 pub fn task_tool_spec() -> ToolSpec {
739 let types = subagents::discover_agent_types();
740 let mut description = "Delegate a self-contained task to a subagent that \
741 runs in a fresh conversation and returns only its final answer. The \
742 subagent's tools are read-only by default (read files, list \
743 directories, search, glob, read websites) — it cannot modify \
744 anything. Use this for exploratory questions whose intermediate \
745 file reads would otherwise clutter this conversation (e.g. \"where \
746 is X implemented and how does it work?\"). The subagent cannot see \
747 this conversation, so the prompt must be fully self-contained: \
748 include absolute paths, symbol names, and exactly what the answer \
749 should contain."
750 .to_string();
751 if !types.is_empty() {
752 description.push_str(
753 "\n\nOptionally pass `subagent_type` to use one of these specialized \
754 subagent types instead of the general-purpose default:\n",
755 );
756 // Same cap as `skills::MAX_ADVERTISED_SKILLS`: this list is baked into
757 // every turn's tool spec, so bound its context cost even though a
758 // pathological number of type files is unlikely. `/agents` (see
759 // `subagents::format_agent_types_list`) shows the full list on request.
760 for agent_type in types.iter().take(MAX_ADVERTISED_AGENT_TYPES) {
761 description.push_str("- ");
762 description.push_str(&agent_type.name);
763 description.push_str(": ");
764 description.push_str(&agent_type.description);
765 description.push('\n');
766 }
767 }
768
769 ToolSpec {
770 name: TASK_TOOL_NAME.to_string(),
771 description,
772 parameters_schema: json!({
773 "type": "object",
774 "properties": {
775 "description": {
776 "type": "string",
777 "description": "A short (3-5 word) summary of the task, for progress display."
778 },
779 "prompt": {
780 "type": "string",
781 "description": "The full task for the subagent. Must be self-contained: the subagent sees nothing of this conversation."
782 },
783 "subagent_type": {
784 "type": "string",
785 "description": "Optional: the name of a configured subagent type (listed in this tool's description) to use instead of the general-purpose default."
786 }
787 },
788 "required": ["description", "prompt"],
789 "additionalProperties": false
790 })
791 .to_string(),
792 }
793 }
794
795 /// The `task` tool entry point used by [`execute_tool`].
796 async fn exec_task(arguments: &str) -> String {
797 exec_task_with(arguments, SUBAGENT_FACTORY.get()).await
798 }
799
800 /// Core of the `task` tool, parameterized on the factory so tests can exercise
801 /// the unavailable path without touching the process-global `OnceLock`.
802 async fn exec_task_with(arguments: &str, factory: Option<&SubagentFactory>) -> String {
803 let args: Value = match serde_json::from_str(arguments) {
804 Ok(v) => v,
805 Err(err) => return format!("Error: failed to parse arguments: {err}"),
806 };
807
808 let prompt = match args.get("prompt").and_then(Value::as_str) {
809 Some(p) if !p.trim().is_empty() => p,
810 _ => return "Error: missing required parameter \"prompt\"".to_string(),
811 };
812 let description = args
813 .get("description")
814 .and_then(Value::as_str)
815 .unwrap_or("(no description)");
816 let subagent_type_name = args
817 .get("subagent_type")
818 .and_then(Value::as_str)
819 .filter(|s| !s.trim().is_empty());
820
821 let agent_type = match subagent_type_name {
822 Some(name) => match subagents::resolve_agent_type(name) {
823 Some(agent_type) => Some(agent_type),
824 None => {
825 let available = subagents::discover_agent_types()
826 .into_iter()
827 .map(|t| t.name)
828 .collect::<Vec<_>>();
829 return if available.is_empty() {
830 format!(
831 "Error: no subagent type named \"{name}\" (none are configured). \
832 Omit subagent_type to use the default research subagent."
833 )
834 } else {
835 format!(
836 "Error: no subagent type named \"{name}\". Available: {}. Omit \
837 subagent_type to use the default research subagent.",
838 available.join(", ")
839 )
840 };
841 }
842 },
843 None => None,
844 };
845
846 let system_prompt = agent_type
847 .as_ref()
848 .map(|t| t.system_prompt.as_str())
849 .unwrap_or(SUBAGENT_SYSTEM_PROMPT);
850 let allowed_tools = match effective_subagent_tools(agent_type.as_ref()) {
851 Ok(tools) => tools,
852 Err(err) => return err,
853 };
854
855 let Some(backend) = factory.and_then(|build| build(system_prompt)) else {
856 return SUBAGENT_UNAVAILABLE.to_string();
857 };
858
859 log::info!(
860 "task: running subagent — {description} (type: {})",
861 agent_type
862 .as_ref()
863 .map(|t| t.name.as_str())
864 .unwrap_or("default")
865 );
866 run_subagent(backend.as_ref(), prompt, &allowed_tools).await
867 }
868
869 /// The nested agent loop: a fresh conversation, a round cap, and only the
870 /// final text returned. Mirrors the main loops in `main.rs` / `chat.rs`:
871 /// offer tools each round, and pass `tools = None` on the last round to
872 /// force a text answer. `allowed` is the toolset for this run — the default
873 /// read-only set, or a configured type's narrowed subset of it (see
874 /// [`effective_subagent_tools`]).
875 async fn run_subagent(backend: &dyn InferenceBackend, prompt: &str, allowed: &[String]) -> String {
876 let specs = subagent_tool_specs(allowed);
877
878 let mut result = match backend.send_message_with_tools(prompt, &specs, None).await {
879 Ok(r) => r,
880 Err(err) => return format!("Error: subagent inference failed: {err}"),
881 };
882
883 let mut round = 0;
884 while !result.tool_calls.is_empty() && round < SUBAGENT_MAX_TOOL_ROUNDS {
885 round += 1;
886 log::info!(
887 "task: subagent tool round {round} — {} call(s)",
888 result.tool_calls.len()
889 );
890
891 let mut tool_results = Vec::with_capacity(result.tool_calls.len());
892 for call in &result.tool_calls {
893 // Hard gate, not just advertisement: even if the model asks for a
894 // tool outside the offered set, only the allowed tools execute here.
895 let content = if allowed.iter().any(|name| name == &call.name) {
896 // Boxed to break the async cycle: execute_tool → task →
897 // run_subagent → execute_tool.
898 Box::pin(execute_tool(&call.name, &call.arguments)).await
899 } else {
900 format!(
901 "Error: `{}` is not available to this subagent. Only these \
902 tools are: {}.",
903 call.name,
904 allowed.join(", ")
905 )
906 };
907 tool_results.push(ToolResult {
908 tool_call_id: call.id.clone(),
909 content,
910 });
911 }
912
913 // On the last round, offer no tools so the model must produce text.
914 let next_tools = if round < SUBAGENT_MAX_TOOL_ROUNDS {
915 Some(specs.as_slice())
916 } else {
917 None
918 };
919 result = match backend
920 .send_tool_results(tool_results, next_tools, None)
921 .await
922 {
923 Ok(r) => r,
924 Err(err) => return format!("Error: subagent inference failed: {err}"),
925 };
926 }
927
928 let text = result.text.trim();
929 if text.is_empty() {
930 return "The subagent finished without a text answer.".to_string();
931 }
932
933 let total = text.chars().count();
934 if total > SUBAGENT_RESULT_CHAR_LIMIT {
935 let truncated: String = text.chars().take(SUBAGENT_RESULT_CHAR_LIMIT).collect();
936 return format!(
937 "{truncated}\n\n--- truncated (showing {SUBAGENT_RESULT_CHAR_LIMIT} of {total} \
938 characters of the subagent's answer) ---"
939 );
940 }
941 text.to_string()
942 }
943
944 fn absolute_path(path: &Path) -> PathBuf {
945 if path.is_absolute() {
946 path.to_path_buf()
947 } else {
948 std::env::current_dir()
949 .unwrap_or_else(|_| PathBuf::from("."))
950 .join(path)
951 }
952 }
953
954 fn absolute_path_string(path: &Path) -> String {
955 absolute_path(path).display().to_string()
956 }
957
958 // ── read_file ────────────────────────────────────────────────────────────────
959
960 fn exec_read_file(arguments: &str) -> String {
961 let args: Value = match serde_json::from_str(arguments) {
962 Ok(v) => v,
963 Err(err) => return format!("Error: failed to parse arguments: {err}"),
964 };
965
966 let path_str = match args.get("path").and_then(Value::as_str) {
967 Some(p) => p,
968 None => return "Error: missing required parameter \"path\"".to_string(),
969 };
970
971 let start_line = args
972 .get("start_line")
973 .and_then(Value::as_u64)
974 .map(|n| n as usize);
975 let end_line = args
976 .get("end_line")
977 .and_then(Value::as_u64)
978 .map(|n| n as usize);
979
980 let path = Path::new(path_str);
981 let absolute_path = absolute_path(path);
982 let absolute_path_str = absolute_path.display().to_string();
983
984 if !absolute_path.exists() {
985 return format!("Error: path does not exist: {absolute_path_str}");
986 }
987
988 if !absolute_path.is_file() {
989 return format!("Error: path is not a file: {absolute_path_str}");
990 }
991
992 match fs::read_to_string(&absolute_path) {
993 Ok(contents) => {
994 if start_line.is_some() || end_line.is_some() {
995 let lines: Vec<&str> = contents.lines().collect();
996 let total = lines.len();
997 let start = start_line.unwrap_or(1).max(1);
998 let end = end_line.unwrap_or(total).min(total);
999
1000 if start > total {
1001 return format!(
1002 "Error: start_line {start} is beyond end of file ({total} lines)"
1003 );
1004 }
1005
1006 let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
1007 let range_text = selected.join("\n");
1008 format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
1009 } else if contents.len() > READ_FILE_CHAR_LIMIT {
1010 let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
1011 format!(
1012 "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
1013 contents.len()
1014 )
1015 } else {
1016 contents
1017 }
1018 }
1019 Err(err) => format!("Error: could not read file: {err}"),
1020 }
1021 }
1022
1023 // ── list_directory ───────────────────────────────────────────────────────────
1024
1025 fn exec_list_directory(arguments: &str) -> String {
1026 let args: Value = match serde_json::from_str(arguments) {
1027 Ok(v) => v,
1028 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1029 };
1030
1031 let path_str = match args.get("path").and_then(Value::as_str) {
1032 Some(p) => p,
1033 None => return "Error: missing required parameter \"path\"".to_string(),
1034 };
1035
1036 let path = Path::new(path_str);
1037 let absolute_path = absolute_path(path);
1038 let absolute_path_str = absolute_path.display().to_string();
1039
1040 if !absolute_path.exists() {
1041 return format!("Error: path does not exist: {absolute_path_str}");
1042 }
1043
1044 if !absolute_path.is_dir() {
1045 return format!("Error: path is not a directory: {absolute_path_str}");
1046 }
1047
1048 let entries = match fs::read_dir(&absolute_path) {
1049 Ok(rd) => rd,
1050 Err(err) => return format!("Error: could not read directory: {err}"),
1051 };
1052
1053 let mut dirs: Vec<String> = Vec::new();
1054 let mut files: Vec<String> = Vec::new();
1055
1056 for entry in entries {
1057 let entry = match entry {
1058 Ok(e) => e,
1059 Err(err) => {
1060 files.push(format!("[ERR] {err}"));
1061 continue;
1062 }
1063 };
1064
1065 let name = entry.file_name().to_string_lossy().to_string();
1066
1067 let is_dir = match entry.file_type() {
1068 Ok(ft) => ft.is_dir(),
1069 Err(_) => false,
1070 };
1071
1072 if is_dir {
1073 dirs.push(format!("[DIR] {name}"));
1074 } else {
1075 files.push(format!("[FILE] {name}"));
1076 }
1077 }
1078
1079 dirs.sort();
1080 files.sort();
1081
1082 dirs.extend(files);
1083
1084 if dirs.is_empty() {
1085 return format!("(empty directory: {absolute_path_str})");
1086 }
1087
1088 dirs.join("\n")
1089 }
1090
1091 // ── search_files ─────────────────────────────────────────────────────────────
1092
1093 fn exec_search_files(arguments: &str) -> String {
1094 let args: Value = match serde_json::from_str(arguments) {
1095 Ok(v) => v,
1096 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1097 };
1098
1099 let pattern_str = match args.get("pattern").and_then(Value::as_str) {
1100 Some(p) => p,
1101 None => return "Error: missing required parameter \"pattern\"".to_string(),
1102 };
1103
1104 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1105
1106 let re = match Regex::new(pattern_str) {
1107 Ok(r) => r,
1108 Err(err) => return format!("Error: invalid regex pattern: {err}"),
1109 };
1110
1111 // Optional file-name filter compiled from a glob (e.g. "*.rs").
1112 let name_filter = match args.get("file_glob").and_then(Value::as_str) {
1113 Some(glob) => match Regex::new(&glob_to_regex(glob)) {
1114 Ok(r) => Some(r),
1115 Err(err) => return format!("Error: invalid file_glob: {err}"),
1116 },
1117 None => None,
1118 };
1119
1120 let limit = args
1121 .get("max_results")
1122 .and_then(Value::as_u64)
1123 .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
1124 .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
1125
1126 let root = Path::new(root_str);
1127 let absolute_root = absolute_path(root);
1128 let absolute_root_str = absolute_root.display().to_string();
1129
1130 if !absolute_root.exists() {
1131 return format!("Error: path does not exist: {absolute_root_str}");
1132 }
1133
1134 if !absolute_root.is_dir() {
1135 return format!("Error: path is not a directory: {absolute_root_str}");
1136 }
1137
1138 let mut matches: Vec<String> = Vec::new();
1139 walk_and_search(
1140 &absolute_root,
1141 &re,
1142 name_filter.as_ref(),
1143 limit,
1144 &mut matches,
1145 );
1146
1147 if matches.is_empty() {
1148 return format!("No matches found for pattern: {pattern_str}");
1149 }
1150
1151 let total = matches.len();
1152 if total > limit {
1153 matches.truncate(limit);
1154 matches.push(format!(
1155 "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
1156 ));
1157 }
1158
1159 matches.join("\n")
1160 }
1161
1162 /// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
1163 /// pattern can't walk an entire tree once enough hits are found.
1164 fn walk_and_search(
1165 dir: &Path,
1166 re: &Regex,
1167 name_filter: Option<&Regex>,
1168 limit: usize,
1169 matches: &mut Vec<String>,
1170 ) {
1171 let entries = match fs::read_dir(dir) {
1172 Ok(rd) => rd,
1173 Err(_) => return,
1174 };
1175
1176 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1177 sorted.sort_by_key(|e| e.file_name());
1178
1179 for entry in sorted {
1180 if matches.len() > limit {
1181 return;
1182 }
1183
1184 let path = entry.path();
1185 let name = entry.file_name();
1186 let name_str = name.to_string_lossy();
1187
1188 if name_str.starts_with('.') {
1189 continue;
1190 }
1191
1192 if path.is_dir() {
1193 walk_and_search(&path, re, name_filter, limit, matches);
1194 } else if path.is_file() {
1195 if let Some(filter) = name_filter
1196 && !filter.is_match(&name_str)
1197 {
1198 continue;
1199 }
1200 search_file(&path, re, matches);
1201 }
1202 }
1203 }
1204
1205 /// skips non-UTF-8 files (probably binary).
1206 fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
1207 let contents = match fs::read_to_string(path) {
1208 Ok(c) => c,
1209 Err(_) => return,
1210 };
1211
1212 let display_path = absolute_path_string(path);
1213
1214 for (line_idx, line) in contents.lines().enumerate() {
1215 if re.is_match(line) {
1216 let line_number = line_idx + 1;
1217 matches.push(format!("{display_path}:{line_number}: {line}"));
1218 }
1219 }
1220 }
1221
1222 // ── read_website ─────────────────────────────────────────────────────────────
1223
1224 fn exec_read_website(arguments: &str) -> String {
1225 let args: Value = match serde_json::from_str(arguments) {
1226 Ok(v) => v,
1227 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1228 };
1229
1230 let url = match args.get("url").and_then(Value::as_str) {
1231 Some(u) => u,
1232 None => return "Error: missing required parameter \"url\"".to_string(),
1233 };
1234
1235 if !(url.starts_with("http://") || url.starts_with("https://")) {
1236 return format!("Error: url must start with http:// or https://: {url}");
1237 }
1238
1239 let client = match reqwest::blocking::Client::builder()
1240 .timeout(WEBSITE_READ_TIMEOUT)
1241 .user_agent(WEBSITE_USER_AGENT)
1242 .build()
1243 {
1244 Ok(client) => client,
1245 Err(err) => return format!("Error: failed to build website client: {err}"),
1246 };
1247
1248 let response = match client.get(url).send() {
1249 Ok(r) => r,
1250 Err(err) => return format!("Error: failed to fetch website: {err}"),
1251 };
1252
1253 let final_url = response.url().to_string();
1254 let status = response.status();
1255 if !status.is_success() {
1256 return format!("Error: website returned HTTP {status} for {final_url}");
1257 }
1258
1259 let body = match response.text() {
1260 Ok(text) => text,
1261 Err(err) => return format!("Error: failed to read website body: {err}"),
1262 };
1263
1264 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
1265 .unwrap()
1266 .captures(&body)
1267 .and_then(|captures| captures.get(1))
1268 .map(|m| {
1269 Regex::new(r"\s+")
1270 .unwrap()
1271 .replace_all(m.as_str(), " ")
1272 .trim()
1273 .to_string()
1274 })
1275 .filter(|title| !title.is_empty());
1276
1277 let with_block_breaks = Regex::new(
1278 r"(?is)</?(?:p|div|section|article|main|aside|header|footer|nav|li|ul|ol|h1|h2|h3|h4|h5|h6|br|tr|td|th)[^>]*>",
1279 )
1280 .unwrap()
1281 .replace_all(&body, "\n");
1282 let without_scripts = Regex::new(r"(?is)<script[^>]*>.*?</script>")
1283 .unwrap()
1284 .replace_all(&with_block_breaks, " ");
1285 let without_styles = Regex::new(r"(?is)<style[^>]*>.*?</style>")
1286 .unwrap()
1287 .replace_all(&without_scripts, " ");
1288 let without_tags = Regex::new(r"(?is)<[^>]+>")
1289 .unwrap()
1290 .replace_all(&without_styles, " ");
1291 let normalized_newlines = without_tags
1292 .replace("&nbsp;", " ")
1293 .replace("&amp;", "&")
1294 .replace("&lt;", "<")
1295 .replace("&gt;", ">")
1296 .replace("&quot;", "\"")
1297 .replace("&#39;", "'");
1298 let collapsed_lines = Regex::new(r"[ \t]+")
1299 .unwrap()
1300 .replace_all(&normalized_newlines, " ");
1301 let collapsed_breaks = Regex::new(r"\n\s*\n+")
1302 .unwrap()
1303 .replace_all(&collapsed_lines, "\n\n");
1304 let cleaned = collapsed_breaks
1305 .lines()
1306 .map(str::trim)
1307 .filter(|line| !line.is_empty())
1308 .collect::<Vec<_>>()
1309 .join("\n");
1310
1311 if cleaned.is_empty() {
1312 return format!("Fetched {url}, but no readable text content was found.");
1313 }
1314
1315 let mut metadata = vec![format!("URL: {final_url}")];
1316 if let Some(title) = &title {
1317 metadata.push(format!("Title: {title}"));
1318 }
1319
1320 let body_text = match title {
1321 Some(title) if !cleaned.starts_with(&title) => cleaned,
1322 _ => cleaned,
1323 };
1324
1325 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
1326
1327 if output.len() > WEBSITE_READ_CHAR_LIMIT {
1328 let truncated: String = output.chars().take(WEBSITE_READ_CHAR_LIMIT).collect();
1329 return format!(
1330 "{truncated}\n\n--- truncated (showing {WEBSITE_READ_CHAR_LIMIT} of {} characters) ---",
1331 output.len()
1332 );
1333 }
1334
1335 output
1336 }
1337
1338 // ── create_directory ─────────────────────────────────────────────────────────
1339
1340 fn exec_create_directory(arguments: &str) -> String {
1341 let args: Value = match serde_json::from_str(arguments) {
1342 Ok(v) => v,
1343 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1344 };
1345
1346 let path_str = match args.get("path").and_then(Value::as_str) {
1347 Some(p) => p,
1348 None => return "Error: missing required parameter \"path\"".to_string(),
1349 };
1350
1351 let path = Path::new(path_str);
1352 let absolute_path = absolute_path(path);
1353 let absolute_path_str = absolute_path.display().to_string();
1354
1355 if absolute_path.exists() {
1356 if absolute_path.is_dir() {
1357 return format!("Directory already exists: {absolute_path_str}");
1358 }
1359 return format!("Error: path exists and is not a directory: {absolute_path_str}");
1360 }
1361
1362 match fs::create_dir_all(&absolute_path) {
1363 Ok(()) => format!("Created directory: {absolute_path_str}"),
1364 Err(err) => format!("Error: could not create directory: {err}"),
1365 }
1366 }
1367
1368 /// fails if file exists so the LLM is forced to use `edit_file` for modifications.
1369 fn exec_create_file(arguments: &str) -> String {
1370 let args: Value = match serde_json::from_str(arguments) {
1371 Ok(v) => v,
1372 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1373 };
1374
1375 let path_str = match args.get("path").and_then(Value::as_str) {
1376 Some(p) => p,
1377 None => return "Error: missing required parameter \"path\"".to_string(),
1378 };
1379
1380 let content = match args.get("content").and_then(Value::as_str) {
1381 Some(c) => c,
1382 None => return "Error: missing required parameter \"content\"".to_string(),
1383 };
1384
1385 let path = Path::new(path_str);
1386 let absolute_path = absolute_path(path);
1387 let absolute_path_str = absolute_path.display().to_string();
1388
1389 if absolute_path.exists() {
1390 return format!(
1391 "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files"
1392 );
1393 }
1394
1395 if let Some(parent) = absolute_path.parent()
1396 && !parent.as_os_str().is_empty()
1397 && !parent.exists()
1398 && let Err(err) = fs::create_dir_all(parent)
1399 {
1400 return format!("Error: could not create parent directories: {err}");
1401 }
1402
1403 match fs::write(&absolute_path, content) {
1404 Ok(()) => format!(
1405 "Created file: {absolute_path_str} ({} bytes)",
1406 content.len()
1407 ),
1408 Err(err) => format!("Error: could not write file: {err}"),
1409 }
1410 }
1411
1412 // ── edit_file / multi_edit ─────────────────────────────────────────────────
1413
1414 /// Apply one exact-substring replacement to `contents`. Returns the updated
1415 /// string, or a human-readable explanation of why the match failed so the model
1416 /// can correct itself in a single follow-up instead of guessing blindly.
1417 fn apply_edit(
1418 contents: &str,
1419 old_text: &str,
1420 new_text: &str,
1421 replace_all: bool,
1422 ) -> Result<String, String> {
1423 if old_text.is_empty() {
1424 return Err("old_text is empty; nothing to match".to_string());
1425 }
1426 if old_text == new_text {
1427 return Err("old_text and new_text are identical; no change to make".to_string());
1428 }
1429
1430 let occurrences = contents.matches(old_text).count();
1431
1432 if occurrences == 0 {
1433 return Err(format!(
1434 "old_text not found. Use read_file to copy the exact text \
1435 (including whitespace and indentation) to replace.{}",
1436 nearest_line_hint(contents, old_text)
1437 ));
1438 }
1439
1440 if occurrences > 1 && !replace_all {
1441 return Err(format!(
1442 "old_text appears {occurrences} times; include more surrounding context so it \
1443 matches exactly once, or set replace_all to true to change every occurrence."
1444 ));
1445 }
1446
1447 if replace_all {
1448 Ok(contents.replace(old_text, new_text))
1449 } else {
1450 Ok(contents.replacen(old_text, new_text, 1))
1451 }
1452 }
1453
1454 /// When `old_text` doesn't match verbatim, point at the line whose trimmed text
1455 /// equals the first trimmed line of `old_text` — the usual culprit is a
1456 /// whitespace/indentation mismatch, and naming the line lets the model fix it.
1457 fn nearest_line_hint(contents: &str, old_text: &str) -> String {
1458 let first = old_text.lines().find(|l| !l.trim().is_empty());
1459 let Some(first) = first.map(str::trim) else {
1460 return String::new();
1461 };
1462 for (idx, line) in contents.lines().enumerate() {
1463 if line.trim() == first {
1464 return format!(
1465 " (the first line of old_text appears at line {}, so the difference is likely \
1466 whitespace or indentation)",
1467 idx + 1
1468 );
1469 }
1470 }
1471 String::new()
1472 }
1473
1474 fn exec_edit_file(arguments: &str) -> String {
1475 let args: Value = match serde_json::from_str(arguments) {
1476 Ok(v) => v,
1477 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1478 };
1479
1480 let path_str = match args.get("path").and_then(Value::as_str) {
1481 Some(p) => p,
1482 None => return "Error: missing required parameter \"path\"".to_string(),
1483 };
1484
1485 let old_text = match args.get("old_text").and_then(Value::as_str) {
1486 Some(t) => t,
1487 None => return "Error: missing required parameter \"old_text\"".to_string(),
1488 };
1489
1490 let new_text = match args.get("new_text").and_then(Value::as_str) {
1491 Some(t) => t,
1492 None => return "Error: missing required parameter \"new_text\"".to_string(),
1493 };
1494
1495 let replace_all = args
1496 .get("replace_all")
1497 .and_then(Value::as_bool)
1498 .unwrap_or(false);
1499
1500 let path = Path::new(path_str);
1501 let absolute_path = absolute_path(path);
1502 let absolute_path_str = absolute_path.display().to_string();
1503
1504 if !absolute_path.exists() {
1505 return format!(
1506 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1507 );
1508 }
1509
1510 if !absolute_path.is_file() {
1511 return format!("Error: path is not a file: {absolute_path_str}");
1512 }
1513
1514 let contents = match fs::read_to_string(&absolute_path) {
1515 Ok(c) => c,
1516 Err(err) => return format!("Error: could not read file: {err}"),
1517 };
1518
1519 let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
1520 Ok(updated) => updated,
1521 Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
1522 };
1523
1524 match fs::write(&absolute_path, &updated) {
1525 Ok(()) => format!(
1526 "Edited file: {absolute_path_str} ({} bytes written)",
1527 updated.len()
1528 ),
1529 Err(err) => format!("Error: could not write file: {err}"),
1530 }
1531 }
1532
1533 /// Apply a batch of edits to one file atomically: each edit is applied to the
1534 /// result of the previous one, and the file is only written if *every* edit
1535 /// matches. A failure leaves the file untouched.
1536 fn exec_multi_edit(arguments: &str) -> String {
1537 let args: Value = match serde_json::from_str(arguments) {
1538 Ok(v) => v,
1539 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1540 };
1541
1542 let path_str = match args.get("path").and_then(Value::as_str) {
1543 Some(p) => p,
1544 None => return "Error: missing required parameter \"path\"".to_string(),
1545 };
1546
1547 let edits = match args.get("edits").and_then(Value::as_array) {
1548 Some(e) if !e.is_empty() => e,
1549 Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1550 None => return "Error: missing required parameter \"edits\"".to_string(),
1551 };
1552
1553 let path = Path::new(path_str);
1554 let absolute_path = absolute_path(path);
1555 let absolute_path_str = absolute_path.display().to_string();
1556
1557 if !absolute_path.exists() {
1558 return format!(
1559 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1560 );
1561 }
1562
1563 if !absolute_path.is_file() {
1564 return format!("Error: path is not a file: {absolute_path_str}");
1565 }
1566
1567 let mut working = match fs::read_to_string(&absolute_path) {
1568 Ok(c) => c,
1569 Err(err) => return format!("Error: could not read file: {err}"),
1570 };
1571
1572 for (idx, edit) in edits.iter().enumerate() {
1573 let old_text = match edit.get("old_text").and_then(Value::as_str) {
1574 Some(t) => t,
1575 None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1576 };
1577 let new_text = match edit.get("new_text").and_then(Value::as_str) {
1578 Some(t) => t,
1579 None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1580 };
1581 let replace_all = edit
1582 .get("replace_all")
1583 .and_then(Value::as_bool)
1584 .unwrap_or(false);
1585
1586 match apply_edit(&working, old_text, new_text, replace_all) {
1587 Ok(updated) => working = updated,
1588 Err(why) => {
1589 return format!(
1590 "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1591 idx + 1
1592 );
1593 }
1594 }
1595 }
1596
1597 match fs::write(&absolute_path, &working) {
1598 Ok(()) => format!(
1599 "Applied {} edits to {absolute_path_str} ({} bytes written)",
1600 edits.len(),
1601 working.len()
1602 ),
1603 Err(err) => format!("Error: could not write file: {err}"),
1604 }
1605 }
1606
1607 // ── glob ─────────────────────────────────────────────────────────────────────
1608
1609 /// Translate a shell-style glob into an anchored regex. Supports `*`
1610 /// (non-separator run), `**` (any number of directories), `?` (one
1611 /// non-separator), and `{a,b}` alternation; everything else is matched
1612 /// literally. Used by the `glob` tool (against relative paths), by
1613 /// `search_files`' `file_glob` filter (against bare file names), and by
1614 /// `crate::permissions` rule patterns (which re-anchor the result).
1615 pub(crate) fn glob_to_regex(glob: &str) -> String {
1616 let chars: Vec<char> = glob.chars().collect();
1617 let mut re = String::from("^");
1618 let mut brace_depth = 0usize;
1619 let mut i = 0;
1620
1621 while i < chars.len() {
1622 let c = chars[i];
1623 match c {
1624 '*' => {
1625 if i + 1 < chars.len() && chars[i + 1] == '*' {
1626 i += 1; // consume the second '*'
1627 if i + 1 < chars.len() && chars[i + 1] == '/' {
1628 // `**/` matches zero or more leading directories.
1629 re.push_str("(?:.*/)?");
1630 i += 1; // consume the '/'
1631 } else {
1632 re.push_str(".*");
1633 }
1634 } else {
1635 re.push_str("[^/]*");
1636 }
1637 }
1638 '?' => re.push_str("[^/]"),
1639 '{' => {
1640 brace_depth += 1;
1641 re.push_str("(?:");
1642 }
1643 '}' if brace_depth > 0 => {
1644 brace_depth -= 1;
1645 re.push(')');
1646 }
1647 ',' if brace_depth > 0 => re.push('|'),
1648 // Escape regex metacharacters so they match literally. (`{` is always
1649 // consumed by the brace arm above; an unmatched `}` lands here.)
1650 '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1651 re.push('\\');
1652 re.push(c);
1653 }
1654 other => re.push(other),
1655 }
1656 i += 1;
1657 }
1658
1659 re.push('$');
1660 re
1661 }
1662
1663 fn exec_glob(arguments: &str) -> String {
1664 let args: Value = match serde_json::from_str(arguments) {
1665 Ok(v) => v,
1666 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1667 };
1668
1669 let pattern = match args.get("pattern").and_then(Value::as_str) {
1670 Some(p) => p,
1671 None => return "Error: missing required parameter \"pattern\"".to_string(),
1672 };
1673
1674 let re = match Regex::new(&glob_to_regex(pattern)) {
1675 Ok(r) => r,
1676 Err(err) => return format!("Error: invalid glob pattern: {err}"),
1677 };
1678
1679 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1680 let absolute_root = absolute_path(Path::new(root_str));
1681 let absolute_root_str = absolute_root.display().to_string();
1682
1683 if !absolute_root.exists() {
1684 return format!("Error: path does not exist: {absolute_root_str}");
1685 }
1686 if !absolute_root.is_dir() {
1687 return format!("Error: path is not a directory: {absolute_root_str}");
1688 }
1689
1690 let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1691 glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1692
1693 if found.is_empty() {
1694 return format!("No files match glob: {pattern}");
1695 }
1696
1697 // Most-recently-modified first.
1698 found.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
1699
1700 let total = found.len();
1701 let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1702 if total > SEARCH_RESULTS_HARD_CAP {
1703 paths.truncate(SEARCH_RESULTS_HARD_CAP);
1704 paths.push(format!(
1705 "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1706 ));
1707 }
1708
1709 paths.join("\n")
1710 }
1711
1712 fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1713 if out.len() > SEARCH_RESULTS_HARD_CAP {
1714 return;
1715 }
1716
1717 let entries = match fs::read_dir(dir) {
1718 Ok(rd) => rd,
1719 Err(_) => return,
1720 };
1721
1722 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1723 sorted.sort_by_key(|e| e.file_name());
1724
1725 for entry in sorted {
1726 if out.len() > SEARCH_RESULTS_HARD_CAP {
1727 return;
1728 }
1729
1730 let path = entry.path();
1731 let name = entry.file_name();
1732 if name.to_string_lossy().starts_with('.') {
1733 continue;
1734 }
1735
1736 if path.is_dir() {
1737 glob_walk(root, &path, re, out);
1738 } else if path.is_file() {
1739 let relative = path
1740 .strip_prefix(root)
1741 .unwrap_or(&path)
1742 .to_string_lossy()
1743 .replace('\\', "/");
1744 if re.is_match(&relative) {
1745 let mtime = entry
1746 .metadata()
1747 .and_then(|m| m.modified())
1748 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1749 out.push((mtime, absolute_path_string(&path)));
1750 }
1751 }
1752 }
1753 }
1754
1755 // ── write_todos ──────────────────────────────────────────────────────────────
1756
1757 /// Renders the model's task checklist back as the tool result so the surface
1758 /// (TUI / ACP client) can show live progress. Pure presentation — the list is
1759 /// owned by the model, not persisted here.
1760 fn exec_write_todos(arguments: &str) -> String {
1761 let args: Value = match serde_json::from_str(arguments) {
1762 Ok(v) => v,
1763 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1764 };
1765
1766 let todos = match args.get("todos").and_then(Value::as_array) {
1767 Some(t) if !t.is_empty() => t,
1768 Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1769 None => return "Error: missing required parameter \"todos\"".to_string(),
1770 };
1771
1772 let mut lines = Vec::with_capacity(todos.len());
1773 let mut completed = 0usize;
1774
1775 for (idx, todo) in todos.iter().enumerate() {
1776 let content = match todo.get("content").and_then(Value::as_str) {
1777 Some(c) => c.trim(),
1778 None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1779 };
1780 let status = todo
1781 .get("status")
1782 .and_then(Value::as_str)
1783 .unwrap_or("pending");
1784
1785 let marker = match status {
1786 "completed" => {
1787 completed += 1;
1788 "[x]"
1789 }
1790 "in_progress" => "[~]",
1791 _ => "[ ]",
1792 };
1793 lines.push(format!("{marker} {content}"));
1794 }
1795
1796 format!(
1797 "Task list updated ({completed}/{} done):\n{}",
1798 todos.len(),
1799 lines.join("\n")
1800 )
1801 }
1802
1803 // ── remember ─────────────────────────────────────────────────────────────────
1804
1805 /// Appends a durable note to the project's instruction file so it persists
1806 /// across sessions (the always-on counterpart to a one-off chat message).
1807 fn exec_remember(arguments: &str) -> String {
1808 let args: Value = match serde_json::from_str(arguments) {
1809 Ok(v) => v,
1810 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1811 };
1812
1813 let note = match args.get("note").and_then(Value::as_str) {
1814 Some(n) if !n.trim().is_empty() => n.trim(),
1815 Some(_) => return "Error: \"note\" must not be empty".to_string(),
1816 None => return "Error: missing required parameter \"note\"".to_string(),
1817 };
1818
1819 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1820 remember_at(&cwd, note)
1821 }
1822
1823 /// Core of `remember`, parameterized on the working directory so it can be
1824 /// tested without mutating the process-global current directory.
1825 fn remember_at(cwd: &Path, note: &str) -> String {
1826 let target = crate::instructions::memory_file(cwd);
1827 let target_str = target.display().to_string();
1828
1829 let existed = target.exists();
1830 let mut body = if existed {
1831 match fs::read_to_string(&target) {
1832 Ok(c) => c,
1833 Err(err) => return format!("Error: could not read {target_str}: {err}"),
1834 }
1835 } else {
1836 if let Some(parent) = target.parent()
1837 && !parent.as_os_str().is_empty()
1838 && !parent.exists()
1839 && let Err(err) = fs::create_dir_all(parent)
1840 {
1841 return format!("Error: could not create parent directories: {err}");
1842 }
1843 String::new()
1844 };
1845
1846 // Keep remembered notes grouped under one heading so the file stays tidy.
1847 const HEADING: &str = "## Remembered notes";
1848 if !body.contains(HEADING) {
1849 if !body.is_empty() && !body.ends_with('\n') {
1850 body.push('\n');
1851 }
1852 if !body.is_empty() {
1853 body.push('\n');
1854 }
1855 body.push_str(HEADING);
1856 body.push('\n');
1857 }
1858 if !body.ends_with('\n') {
1859 body.push('\n');
1860 }
1861 body.push_str("- ");
1862 body.push_str(note);
1863 body.push('\n');
1864
1865 match fs::write(&target, &body) {
1866 Ok(()) => {
1867 let verb = if existed { "Appended to" } else { "Created" };
1868 format!("{verb} {target_str}: remembered \"{note}\"")
1869 }
1870 Err(err) => format!("Error: could not write {target_str}: {err}"),
1871 }
1872 }
1873
1874 // ── delete_file ──────────────────────────────────────────────────────────────
1875
1876 /// only removes files or *empty* directories — no recursive deletes.
1877 fn exec_delete_file(arguments: &str) -> String {
1878 let args: Value = match serde_json::from_str(arguments) {
1879 Ok(v) => v,
1880 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1881 };
1882
1883 let path_str = match args.get("path").and_then(Value::as_str) {
1884 Some(p) => p,
1885 None => return "Error: missing required parameter \"path\"".to_string(),
1886 };
1887
1888 let path = Path::new(path_str);
1889 let absolute_path = absolute_path(path);
1890 let absolute_path_str = absolute_path.display().to_string();
1891
1892 if !absolute_path.exists() {
1893 return format!("Error: path does not exist: {absolute_path_str}");
1894 }
1895
1896 if absolute_path.is_dir() {
1897 match fs::remove_dir(&absolute_path) {
1898 Ok(()) => format!("Deleted empty directory: {absolute_path_str}"),
1899 Err(err) => format!(
1900 "Error: could not delete directory: {err}. \
1901 Only empty directories can be deleted."
1902 ),
1903 }
1904 } else {
1905 match fs::remove_file(&absolute_path) {
1906 Ok(()) => format!("Deleted file: {absolute_path_str}"),
1907 Err(err) => format!("Error: could not delete file: {err}"),
1908 }
1909 }
1910 }
1911
1912 // ── run_command ──────────────────────────────────────────────────────────────
1913
1914 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1915 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1916
1917 /// Spawn `command_str` through the platform shell with piped stdout/stderr.
1918 /// Shared by the foreground and background paths of `run_command`.
1919 fn spawn_shell(command_str: &str, cwd_path: &Path) -> std::io::Result<std::process::Child> {
1920 #[cfg(unix)]
1921 let (shell, flag) = ("sh", "-c");
1922 #[cfg(windows)]
1923 let (shell, flag) = ("cmd", "/C");
1924
1925 Command::new(shell)
1926 .arg(flag)
1927 .arg(command_str)
1928 .current_dir(cwd_path)
1929 .stdout(std::process::Stdio::piped())
1930 .stderr(std::process::Stdio::piped())
1931 .spawn()
1932 }
1933
1934 /// Trailer identifying siGit Code as the co-author of commits it creates.
1935 /// GitHub detects `Co-authored-by:` trailers on the last lines of a commit
1936 /// message (separated from the body by a blank line) and lists the agent
1937 /// alongside the human author; `297239231+sigitc@users.noreply.github.com`
1938 /// is the noreply address for the <https://github.com/sigitc> account
1939 /// ("siGit Code"), so the co-author is rendered with that account's avatar
1940 /// and profile link. The system prompt asks the model to add this itself;
1941 /// [`ensure_commit_co_author`] is the safety net when it forgets.
1942 pub const COMMIT_CO_AUTHOR_TRAILER: &str =
1943 "Co-Authored-By: siGit Code <297239231+sigitc@users.noreply.github.com>";
1944
1945 /// Run `git <args>` in `cwd`, returning trimmed stdout on success.
1946 fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
1947 let output = Command::new("git")
1948 .args(args)
1949 .current_dir(cwd)
1950 .output()
1951 .ok()?;
1952 output
1953 .status
1954 .success()
1955 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1956 }
1957
1958 fn git_head(cwd: &Path) -> Option<String> {
1959 git_stdout(cwd, &["rev-parse", "HEAD"])
1960 }
1961
1962 /// Deterministic co-author attribution: if the commit at HEAD lacks the
1963 /// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which
1964 /// places it after a blank line — the format GitHub detects). Never rewrites
1965 /// a commit that is already on a remote. Returns a note describing the amend
1966 /// so the model and user can see it happened.
1967 fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
1968 let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?;
1969 if message
1970 .to_lowercase()
1971 .contains("co-authored-by: sigit code")
1972 {
1973 return None;
1974 }
1975 // Amending changes the commit id; a commit that any remote ref already
1976 // contains must be left alone or the branch diverges from its upstream.
1977 match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) {
1978 Some(remotes) if remotes.is_empty() => {}
1979 _ => return None,
1980 }
1981 let amend = Command::new("git")
1982 .args(["commit", "--amend", "--no-edit", "--trailer"])
1983 .arg(COMMIT_CO_AUTHOR_TRAILER)
1984 .current_dir(cwd)
1985 .output()
1986 .ok()?;
1987 if amend.status.success() {
1988 log::info!(
1989 "appended co-author trailer to the new commit in {}",
1990 cwd.display()
1991 );
1992 Some(format!(
1993 "[siGit Code] The new commit was amended to append the co-author trailer \
1994 \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)."
1995 ))
1996 } else {
1997 log::warn!(
1998 "could not append co-author trailer in {}: {}",
1999 cwd.display(),
2000 String::from_utf8_lossy(&amend.stderr).trim()
2001 );
2002 None
2003 }
2004 }
2005
2006 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT unless
2007 /// `run_in_background` is set, in which case the child is registered as a
2008 /// background task and polled with `command_output` / stopped with
2009 /// `kill_command`.
2010 fn exec_run_command(arguments: &str) -> String {
2011 let args: Value = match serde_json::from_str(arguments) {
2012 Ok(v) => v,
2013 Err(err) => return format!("Error: failed to parse arguments: {err}"),
2014 };
2015
2016 let command_str = match args.get("command").and_then(Value::as_str) {
2017 Some(c) => c,
2018 None => return "Error: missing required parameter \"command\"".to_string(),
2019 };
2020
2021 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
2022 let cwd = args
2023 .get("cwd")
2024 .and_then(Value::as_str)
2025 .unwrap_or(&default_cwd);
2026 let cwd_path = absolute_path(Path::new(cwd));
2027 let cwd_str = cwd_path.display().to_string();
2028
2029 if !cwd_path.exists() {
2030 return format!("Error: working directory does not exist: {cwd_str}");
2031 }
2032
2033 let run_in_background = args
2034 .get("run_in_background")
2035 .and_then(Value::as_bool)
2036 .unwrap_or(false);
2037
2038 log::info!("run_command: `{command_str}` in `{cwd_str}` (background: {run_in_background})");
2039
2040 if run_in_background {
2041 return start_background_task(command_str, &cwd_path);
2042 }
2043
2044 // Co-author attribution: note where HEAD is before a command that looks
2045 // like it may commit, so a new commit can be detected afterwards. The
2046 // string check is only a cheap trigger — a false positive costs one
2047 // `git rev-parse` and nothing else. Background tasks skip the gate: their
2048 // commits finish after the tool returns, when there is nothing to amend
2049 // from.
2050 let may_commit = command_str.contains("git") && command_str.contains("commit");
2051 let head_before = if may_commit {
2052 git_head(&cwd_path)
2053 } else {
2054 None
2055 };
2056
2057 let mut child = match spawn_shell(command_str, &cwd_path) {
2058 Ok(c) => c,
2059 Err(err) => return format!("Error: failed to spawn command: {err}"),
2060 };
2061
2062 let start = std::time::Instant::now();
2063 loop {
2064 match child.try_wait() {
2065 Ok(Some(_status)) => break,
2066 Ok(None) => {
2067 if start.elapsed() >= COMMAND_TIMEOUT {
2068 let _ = child.kill();
2069 return format!(
2070 "Error: command timed out after {} seconds and was killed.",
2071 COMMAND_TIMEOUT.as_secs()
2072 );
2073 }
2074 std::thread::sleep(std::time::Duration::from_millis(100));
2075 }
2076 Err(err) => return format!("Error: failed to wait on command: {err}"),
2077 }
2078 }
2079
2080 let output = match child.wait_with_output() {
2081 Ok(o) => o,
2082 Err(err) => return format!("Error: failed to read command output: {err}"),
2083 };
2084
2085 let exit_code = output.status.code().unwrap_or(-1);
2086 let mut combined = String::new();
2087 combined.push_str(&String::from_utf8_lossy(&output.stdout));
2088 combined.push_str(&String::from_utf8_lossy(&output.stderr));
2089
2090 // A new commit appeared under this command: make sure it carries the
2091 // siGit co-author trailer (see `ensure_commit_co_author`).
2092 if may_commit {
2093 let head_after = git_head(&cwd_path);
2094 if head_after.is_some()
2095 && head_after != head_before
2096 && let Some(note) = ensure_commit_co_author(&cwd_path)
2097 {
2098 if !combined.is_empty() && !combined.ends_with('\n') {
2099 combined.push('\n');
2100 }
2101 combined.push_str(&note);
2102 }
2103 }
2104
2105 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
2106 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
2107 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
2108 } else {
2109 combined
2110 };
2111
2112 if output.status.success() {
2113 if truncated.is_empty() {
2114 format!("Command succeeded (exit code {exit_code}) with no output.")
2115 } else {
2116 format!("Exit code {exit_code}:\n{truncated}")
2117 }
2118 } else {
2119 format!("Command failed (exit code {exit_code}):\n{truncated}")
2120 }
2121 }
2122
2123 // ── background tasks (command_output / kill_command) ─────────────────────────
2124
2125 /// A command started with `run_in_background`. The child is never detached,
2126 /// so background tasks die with the sigit process.
2127 struct BackgroundTask {
2128 command: String,
2129 child: std::process::Child,
2130 /// Combined stdout+stderr drained by the reader threads, shared with them.
2131 output: Arc<Mutex<TaskOutput>>,
2132 /// Exit code cached once observed; `-1` when there is no code (killed by
2133 /// a signal). `None` while the task is still running.
2134 exit_code: Option<i32>,
2135 /// Set when the task was stopped via `kill_command`.
2136 killed: bool,
2137 }
2138
2139 /// Output accumulated for a background task since the last poll.
2140 #[derive(Default)]
2141 struct TaskOutput {
2142 buf: String,
2143 /// Whether the oldest output was dropped because `buf` hit the cap.
2144 dropped: bool,
2145 }
2146
2147 /// Process-global background task table (same pattern as `mcp::MCP`).
2148 fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> {
2149 static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new();
2150 TASKS.get_or_init(|| Mutex::new(HashMap::new()))
2151 }
2152
2153 fn lock_tasks() -> std::sync::MutexGuard<'static, HashMap<u64, BackgroundTask>> {
2154 tasks()
2155 .lock()
2156 .unwrap_or_else(|poisoned| poisoned.into_inner())
2157 }
2158
2159 fn next_task_id() -> u64 {
2160 static NEXT_ID: AtomicU64 = AtomicU64::new(1);
2161 NEXT_ID.fetch_add(1, Ordering::Relaxed)
2162 }
2163
2164 /// Drain a child's stream into the task's shared buffer on a plain std thread,
2165 /// capping the buffer at COMMAND_OUTPUT_LIMIT by dropping the oldest output.
2166 fn spawn_output_reader<R: std::io::Read + Send + 'static>(
2167 mut stream: R,
2168 output: Arc<Mutex<TaskOutput>>,
2169 ) {
2170 std::thread::spawn(move || {
2171 let mut chunk = [0u8; 8192];
2172 loop {
2173 match stream.read(&mut chunk) {
2174 Ok(0) | Err(_) => break,
2175 Ok(n) => {
2176 let text = String::from_utf8_lossy(&chunk[..n]).into_owned();
2177 let mut out = output
2178 .lock()
2179 .unwrap_or_else(|poisoned| poisoned.into_inner());
2180 out.buf.push_str(&text);
2181 if out.buf.len() > COMMAND_OUTPUT_LIMIT {
2182 let mut cut = out.buf.len() - COMMAND_OUTPUT_LIMIT;
2183 while !out.buf.is_char_boundary(cut) {
2184 cut += 1;
2185 }
2186 out.buf.drain(..cut);
2187 out.dropped = true;
2188 }
2189 }
2190 }
2191 }
2192 });
2193 }
2194
2195 /// Background branch of `run_command`: spawn, register, return immediately.
2196 fn start_background_task(command_str: &str, cwd_path: &Path) -> String {
2197 let mut child = match spawn_shell(command_str, cwd_path) {
2198 Ok(c) => c,
2199 Err(err) => return format!("Error: failed to spawn command: {err}"),
2200 };
2201
2202 let output = Arc::new(Mutex::new(TaskOutput::default()));
2203 if let Some(stdout) = child.stdout.take() {
2204 spawn_output_reader(stdout, Arc::clone(&output));
2205 }
2206 if let Some(stderr) = child.stderr.take() {
2207 spawn_output_reader(stderr, Arc::clone(&output));
2208 }
2209
2210 let task_id = next_task_id();
2211 lock_tasks().insert(
2212 task_id,
2213 BackgroundTask {
2214 command: command_str.to_string(),
2215 child,
2216 output,
2217 exit_code: None,
2218 killed: false,
2219 },
2220 );
2221
2222 format!(
2223 "Started background task {task_id}: `{command_str}`. Poll its output and status \
2224 with command_output (task_id: {task_id}); stop it with kill_command. The task is \
2225 killed when sigit exits."
2226 )
2227 }
2228
2229 /// Check (and cache) whether a task's child has exited. Returns the exit code,
2230 /// or `None` while it is still running.
2231 fn poll_exit_code(task: &mut BackgroundTask) -> Option<i32> {
2232 if task.exit_code.is_none()
2233 && let Ok(Some(status)) = task.child.try_wait()
2234 {
2235 task.exit_code = Some(status.code().unwrap_or(-1));
2236 }
2237 task.exit_code
2238 }
2239
2240 /// Take everything the task has printed since the last poll, plus whether
2241 /// older output was dropped at the buffer cap.
2242 fn drain_task_output(task: &BackgroundTask) -> (String, bool) {
2243 let mut out = task
2244 .output
2245 .lock()
2246 .unwrap_or_else(|poisoned| poisoned.into_inner());
2247 (
2248 std::mem::take(&mut out.buf),
2249 std::mem::take(&mut out.dropped),
2250 )
2251 }
2252
2253 fn parse_task_id(arguments: &str) -> Result<u64, String> {
2254 let args: Value = serde_json::from_str(arguments)
2255 .map_err(|err| format!("Error: failed to parse arguments: {err}"))?;
2256 args.get("task_id")
2257 .and_then(Value::as_u64)
2258 .ok_or_else(|| "Error: missing required parameter \"task_id\"".to_string())
2259 }
2260
2261 fn unknown_task(task_id: u64) -> String {
2262 format!(
2263 "Error: no background task with id {task_id}. Start one with run_command and \
2264 run_in_background set to true."
2265 )
2266 }
2267
2268 fn dropped_note(dropped: bool) -> &'static str {
2269 if dropped {
2270 "\n(note: earlier output was dropped after exceeding the 50000-byte buffer)"
2271 } else {
2272 ""
2273 }
2274 }
2275
2276 /// `command_output` tool: output since the last poll + running/exited status.
2277 fn exec_command_output(arguments: &str) -> String {
2278 let task_id = match parse_task_id(arguments) {
2279 Ok(id) => id,
2280 Err(err) => return err,
2281 };
2282
2283 let mut map = lock_tasks();
2284 let Some(task) = map.get_mut(&task_id) else {
2285 return unknown_task(task_id);
2286 };
2287
2288 let was_running = task.exit_code.is_none();
2289 let exit_code = poll_exit_code(task);
2290 if was_running && exit_code.is_some() {
2291 // The child just exited; give the reader threads a moment to flush
2292 // the final output through the pipes before draining the buffer.
2293 std::thread::sleep(std::time::Duration::from_millis(100));
2294 }
2295 let (new_output, dropped) = drain_task_output(task);
2296
2297 let command = &task.command;
2298 let status = match exit_code {
2299 None => format!("Task {task_id} (`{command}`) is still running."),
2300 Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."),
2301 Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."),
2302 };
2303
2304 if new_output.is_empty() {
2305 format!(
2306 "{status} No new output since the last check.{}",
2307 dropped_note(dropped)
2308 )
2309 } else {
2310 format!(
2311 "{status} New output since the last check:{}\n{new_output}",
2312 dropped_note(dropped)
2313 )
2314 }
2315 }
2316
2317 /// `kill_command` tool: stop a background task and report its output tail.
2318 fn exec_kill_command(arguments: &str) -> String {
2319 let task_id = match parse_task_id(arguments) {
2320 Ok(id) => id,
2321 Err(err) => return err,
2322 };
2323
2324 let mut map = lock_tasks();
2325 let Some(task) = map.get_mut(&task_id) else {
2326 return unknown_task(task_id);
2327 };
2328
2329 if let Some(code) = poll_exit_code(task) {
2330 let (new_output, dropped) = drain_task_output(task);
2331 let command = &task.command;
2332 let tail = if new_output.is_empty() {
2333 String::new()
2334 } else {
2335 format!(" Unread output:\n{new_output}")
2336 };
2337 return format!(
2338 "Task {task_id} (`{command}`) had already exited with code {code}; nothing to \
2339 kill.{}{tail}",
2340 dropped_note(dropped)
2341 );
2342 }
2343
2344 if let Err(err) = task.child.kill() {
2345 return format!("Error: failed to kill task {task_id}: {err}");
2346 }
2347 match task.child.wait() {
2348 Ok(status) => task.exit_code = Some(status.code().unwrap_or(-1)),
2349 Err(_) => task.exit_code = Some(-1),
2350 }
2351 task.killed = true;
2352
2353 // Let the reader threads flush whatever was in flight before reporting.
2354 std::thread::sleep(std::time::Duration::from_millis(100));
2355 let (new_output, dropped) = drain_task_output(task);
2356
2357 let command = &task.command;
2358 let tail = if new_output.is_empty() {
2359 " It produced no unread output.".to_string()
2360 } else {
2361 format!(" Last output:\n{new_output}")
2362 };
2363 format!(
2364 "Killed task {task_id} (`{command}`).{}{tail}",
2365 dropped_note(dropped)
2366 )
2367 }
2368
2369 #[cfg(test)]
2370 mod tests {
2371 use super::*;
2372 use std::fs;
2373
2374 #[tokio::test]
2375 async fn test_execute_unknown_tool() {
2376 let result = execute_tool("nonexistent", "{}").await;
2377 assert!(result.starts_with("Unknown tool:"));
2378 }
2379
2380 #[test]
2381 fn test_read_file_missing_path_param() {
2382 let result = exec_read_file("{}");
2383 assert!(result.contains("missing required parameter"));
2384 }
2385
2386 #[test]
2387 fn test_read_file_nonexistent() {
2388 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
2389 assert!(result.contains("does not exist"));
2390 }
2391
2392 #[test]
2393 fn test_read_file_success() {
2394 let dir = std::env::temp_dir().join("sigit_test_read_file");
2395 let _ = fs::create_dir_all(&dir);
2396 let file_path = dir.join("hello.txt");
2397 fs::write(&file_path, "hello world").unwrap();
2398
2399 let args = serde_json::json!({ "path": file_path }).to_string();
2400 let result = exec_read_file(&args);
2401 assert_eq!(result, "hello world");
2402
2403 let _ = fs::remove_dir_all(&dir);
2404 }
2405
2406 #[test]
2407 fn test_list_directory_missing_path_param() {
2408 let result = exec_list_directory("{}");
2409 assert!(result.contains("missing required parameter"));
2410 }
2411
2412 #[test]
2413 fn test_list_directory_success() {
2414 let dir = std::env::temp_dir().join("sigit_test_list_dir");
2415 let _ = fs::remove_dir_all(&dir);
2416 fs::create_dir_all(dir.join("subdir")).unwrap();
2417 fs::write(dir.join("aaa.txt"), "").unwrap();
2418 fs::write(dir.join("bbb.rs"), "").unwrap();
2419
2420 let args = serde_json::json!({ "path": dir }).to_string();
2421 let result = exec_list_directory(&args);
2422
2423 assert!(result.contains("[DIR] subdir"));
2424 assert!(result.contains("[FILE] aaa.txt"));
2425 assert!(result.contains("[FILE] bbb.rs"));
2426
2427 // Directories should appear before files.
2428 let dir_pos = result.find("[DIR]").unwrap();
2429 let file_pos = result.find("[FILE]").unwrap();
2430 assert!(dir_pos < file_pos);
2431
2432 let _ = fs::remove_dir_all(&dir);
2433 }
2434
2435 #[test]
2436 fn test_search_files_invalid_regex() {
2437 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
2438 assert!(result.contains("invalid regex"));
2439 }
2440
2441 #[test]
2442 fn test_search_files_success() {
2443 let dir = std::env::temp_dir().join("sigit_test_search");
2444 let _ = fs::remove_dir_all(&dir);
2445 fs::create_dir_all(&dir).unwrap();
2446 fs::write(
2447 dir.join("code.rs"),
2448 "fn main() {\n println!(\"hello\");\n}\n",
2449 )
2450 .unwrap();
2451 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
2452
2453 let args = serde_json::json!({
2454 "pattern": "println",
2455 "path": dir
2456 })
2457 .to_string();
2458 let result = exec_search_files(&args);
2459
2460 assert!(result.contains("code.rs:2:"));
2461 assert!(result.contains("println"));
2462 assert!(!result.contains("other.txt"));
2463
2464 let _ = fs::remove_dir_all(&dir);
2465 }
2466
2467 #[test]
2468 fn test_search_files_no_matches() {
2469 let dir = std::env::temp_dir().join("sigit_test_search_none");
2470 let _ = fs::remove_dir_all(&dir);
2471 fs::create_dir_all(&dir).unwrap();
2472 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
2473
2474 let args = serde_json::json!({
2475 "pattern": "zzz_will_not_match_42",
2476 "path": dir
2477 })
2478 .to_string();
2479 let result = exec_search_files(&args);
2480 assert!(result.contains("No matches found"));
2481
2482 let _ = fs::remove_dir_all(&dir);
2483 }
2484
2485 #[test]
2486 fn test_all_tools_count() {
2487 let tools = all_tools();
2488 assert_eq!(tools.len(), 15);
2489 assert_eq!(tools[0].name, "read_file");
2490 assert_eq!(tools[1].name, "create_directory");
2491 assert_eq!(tools[2].name, "list_directory");
2492 assert_eq!(tools[3].name, "search_files");
2493 assert_eq!(tools[4].name, "read_website");
2494 assert_eq!(tools[5].name, "create_file");
2495 assert_eq!(tools[6].name, "edit_file");
2496 assert_eq!(tools[7].name, "delete_file");
2497 assert_eq!(tools[8].name, "run_command");
2498 assert_eq!(tools[9].name, "multi_edit");
2499 assert_eq!(tools[10].name, "glob");
2500 assert_eq!(tools[11].name, "write_todos");
2501 assert_eq!(tools[12].name, "remember");
2502 assert_eq!(tools[13].name, "command_output");
2503 assert_eq!(tools[14].name, "kill_command");
2504 }
2505
2506 #[test]
2507 fn test_edit_file_replace_all() {
2508 let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
2509 let _ = fs::remove_dir_all(&dir);
2510 fs::create_dir_all(&dir).unwrap();
2511 let file = dir.join("f.txt");
2512 fs::write(&file, "foo foo foo").unwrap();
2513
2514 // Without replace_all an ambiguous match is rejected.
2515 let args =
2516 serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
2517 let result = exec_edit_file(&args);
2518 assert!(result.contains("appears 3 times"), "{result}");
2519
2520 // With replace_all every occurrence is changed.
2521 let args = serde_json::json!({
2522 "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
2523 })
2524 .to_string();
2525 let result = exec_edit_file(&args);
2526 assert!(result.starts_with("Edited file:"), "{result}");
2527 assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
2528
2529 let _ = fs::remove_dir_all(&dir);
2530 }
2531
2532 #[test]
2533 fn test_edit_file_whitespace_hint() {
2534 let dir = std::env::temp_dir().join("sigit_test_edit_hint");
2535 let _ = fs::remove_dir_all(&dir);
2536 fs::create_dir_all(&dir).unwrap();
2537 let file = dir.join("f.txt");
2538 fs::write(&file, "line one\n indented\nline three\n").unwrap();
2539
2540 // old_text has more indentation than the file, so it isn't a substring,
2541 // but its trimmed content still locates the intended line.
2542 let args = serde_json::json!({
2543 "path": &file, "old_text": " indented", "new_text": "x"
2544 })
2545 .to_string();
2546 let result = exec_edit_file(&args);
2547 assert!(result.contains("line 2"), "{result}");
2548 assert!(result.contains("whitespace"), "{result}");
2549
2550 let _ = fs::remove_dir_all(&dir);
2551 }
2552
2553 #[test]
2554 fn test_multi_edit_atomic_on_failure() {
2555 let dir = std::env::temp_dir().join("sigit_test_multi_edit");
2556 let _ = fs::remove_dir_all(&dir);
2557 fs::create_dir_all(&dir).unwrap();
2558 let file = dir.join("f.txt");
2559 fs::write(&file, "alpha beta gamma").unwrap();
2560
2561 // Second edit can't match -> nothing should be written.
2562 let args = serde_json::json!({
2563 "path": &file,
2564 "edits": [
2565 { "old_text": "alpha", "new_text": "ALPHA" },
2566 { "old_text": "nope", "new_text": "x" }
2567 ]
2568 })
2569 .to_string();
2570 let result = exec_multi_edit(&args);
2571 assert!(result.contains("edit #2 failed"), "{result}");
2572 assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
2573
2574 // All-matching batch applies in sequence.
2575 let args = serde_json::json!({
2576 "path": &file,
2577 "edits": [
2578 { "old_text": "alpha", "new_text": "ALPHA" },
2579 { "old_text": "gamma", "new_text": "GAMMA" }
2580 ]
2581 })
2582 .to_string();
2583 let result = exec_multi_edit(&args);
2584 assert!(result.contains("Applied 2 edits"), "{result}");
2585 assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
2586
2587 let _ = fs::remove_dir_all(&dir);
2588 }
2589
2590 #[test]
2591 fn test_glob_to_regex() {
2592 let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
2593 assert!(re.is_match("src/tools.rs"));
2594 assert!(re.is_match("main.rs")); // `**/` matches zero directories too
2595 assert!(!re.is_match("src/tools.txt"));
2596
2597 let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
2598 assert!(re.is_match("app.ts"));
2599 assert!(re.is_match("app.tsx"));
2600 assert!(!re.is_match("app.js"));
2601 }
2602
2603 #[test]
2604 fn test_glob_tool_success() {
2605 let dir = std::env::temp_dir().join("sigit_test_glob");
2606 let _ = fs::remove_dir_all(&dir);
2607 fs::create_dir_all(dir.join("src")).unwrap();
2608 fs::write(dir.join("Cargo.toml"), "").unwrap();
2609 fs::write(dir.join("src/main.rs"), "").unwrap();
2610 fs::write(dir.join("src/lib.rs"), "").unwrap();
2611
2612 let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
2613 let result = exec_glob(&args);
2614 assert!(result.contains("main.rs"), "{result}");
2615 assert!(result.contains("lib.rs"), "{result}");
2616 assert!(!result.contains("Cargo.toml"), "{result}");
2617
2618 let _ = fs::remove_dir_all(&dir);
2619 }
2620
2621 #[test]
2622 fn test_search_files_file_glob_filter() {
2623 let dir = std::env::temp_dir().join("sigit_test_search_glob");
2624 let _ = fs::remove_dir_all(&dir);
2625 fs::create_dir_all(&dir).unwrap();
2626 fs::write(dir.join("code.rs"), "needle here\n").unwrap();
2627 fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
2628
2629 let args = serde_json::json!({
2630 "pattern": "needle", "path": &dir, "file_glob": "*.rs"
2631 })
2632 .to_string();
2633 let result = exec_search_files(&args);
2634 assert!(result.contains("code.rs"), "{result}");
2635 assert!(!result.contains("notes.txt"), "{result}");
2636
2637 let _ = fs::remove_dir_all(&dir);
2638 }
2639
2640 #[test]
2641 fn test_write_todos_renders_checklist() {
2642 let args = serde_json::json!({
2643 "todos": [
2644 { "content": "Read code", "status": "completed" },
2645 { "content": "Make change", "status": "in_progress" },
2646 { "content": "Run tests", "status": "pending" }
2647 ]
2648 })
2649 .to_string();
2650 let result = exec_write_todos(&args);
2651 assert!(result.contains("1/3 done"), "{result}");
2652 assert!(result.contains("[x] Read code"), "{result}");
2653 assert!(result.contains("[~] Make change"), "{result}");
2654 assert!(result.contains("[ ] Run tests"), "{result}");
2655 }
2656
2657 #[test]
2658 fn test_remember_appends_to_instruction_file() {
2659 let dir = std::env::temp_dir().join("sigit_test_remember");
2660 let _ = fs::remove_dir_all(&dir);
2661 fs::create_dir_all(dir.join(".git")).unwrap();
2662 let claude_md = dir.join("CLAUDE.md");
2663 fs::write(&claude_md, "# Project\n").unwrap();
2664
2665 let target = crate::instructions::memory_file(&dir);
2666 // Should pick the existing CLAUDE.md at the repo root.
2667 assert_eq!(
2668 target.canonicalize().unwrap(),
2669 claude_md.canonicalize().unwrap()
2670 );
2671
2672 let result = remember_at(&dir, "remembered text");
2673 assert!(result.contains("remembered"), "{result}");
2674
2675 let updated = fs::read_to_string(&claude_md).unwrap();
2676 assert!(updated.contains("## Remembered notes"), "{updated}");
2677 assert!(updated.contains("- remembered text"), "{updated}");
2678
2679 let _ = fs::remove_dir_all(&dir);
2680 }
2681
2682 #[test]
2683 fn test_all_tools_schemas_are_valid_json_objects() {
2684 for tool in all_tools() {
2685 assert!(
2686 tool.parameters_schema.is_object(),
2687 "schema for {} is not an object",
2688 tool.name
2689 );
2690 let obj = tool.parameters_schema.as_object().unwrap();
2691 assert!(obj.contains_key("type"));
2692 assert!(obj.contains_key("properties"));
2693 assert!(obj.contains_key("required"));
2694 }
2695 }
2696
2697 // ── read_website tests ───────────────────────────────────────────────
2698
2699 #[test]
2700 fn test_read_website_missing_url() {
2701 let result = exec_read_website("{}");
2702 assert!(result.contains("missing required parameter"));
2703 }
2704
2705 #[test]
2706 fn test_read_website_invalid_scheme() {
2707 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
2708 assert!(result.contains("url must start with http:// or https://"));
2709 }
2710
2711 #[test]
2712 fn test_read_website_extracts_title_from_html() {
2713 let body = r#"
2714 <html>
2715 <head>
2716 <title>Qwen 3.6 27B</title>
2717 </head>
2718 <body>
2719 <h1>Model card</h1>
2720 <p>Large language model.</p>
2721 </body>
2722 </html>
2723 "#;
2724
2725 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
2726 .unwrap()
2727 .captures(body)
2728 .and_then(|captures| captures.get(1))
2729 .map(|m| {
2730 Regex::new(r"\s+")
2731 .unwrap()
2732 .replace_all(m.as_str(), " ")
2733 .trim()
2734 .to_string()
2735 })
2736 .filter(|title| !title.is_empty());
2737
2738 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
2739 }
2740
2741 #[test]
2742 fn test_read_website_metadata_includes_final_url_header() {
2743 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
2744 let title = Some("Qwen 3.6 27B".to_string());
2745 let cleaned = "Model card\nLarge language model.".to_string();
2746
2747 let mut metadata = vec![format!("URL: {final_url}")];
2748 if let Some(title) = &title {
2749 metadata.push(format!("Title: {title}"));
2750 }
2751
2752 let body_text = match title {
2753 Some(_) => cleaned,
2754 None => cleaned,
2755 };
2756
2757 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
2758
2759 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
2760 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
2761 }
2762
2763 // ── create_directory tests ───────────────────────────────────────────
2764
2765 #[test]
2766 fn test_create_directory_missing_path() {
2767 let result = exec_create_directory("{}");
2768 assert!(result.contains("missing required parameter"));
2769 }
2770
2771 #[test]
2772 fn test_create_directory_success() {
2773 let dir = std::env::temp_dir()
2774 .join("sigit_test_create_directory")
2775 .join("nested")
2776 .join("child");
2777 let _ = fs::remove_dir_all(dir.parent().unwrap());
2778
2779 let args = serde_json::json!({ "path": dir }).to_string();
2780 let result = exec_create_directory(&args);
2781 assert!(result.starts_with("Created directory:"), "got: {result}");
2782 assert!(dir.exists());
2783 assert!(dir.is_dir());
2784
2785 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
2786 }
2787
2788 #[test]
2789 fn test_create_directory_already_exists() {
2790 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
2791 let _ = fs::remove_dir_all(&dir);
2792 fs::create_dir_all(&dir).unwrap();
2793
2794 let args = serde_json::json!({ "path": dir }).to_string();
2795 let result = exec_create_directory(&args);
2796 assert!(result.contains("Directory already exists"), "got: {result}");
2797
2798 let _ = fs::remove_dir_all(&dir);
2799 }
2800
2801 // ── create_file tests ────────────────────────────────────────────────
2802
2803 #[test]
2804 fn test_create_file_missing_path() {
2805 let result = exec_create_file(r#"{"content": "hello"}"#);
2806 assert!(result.contains("missing required parameter"));
2807 }
2808
2809 #[test]
2810 fn test_create_file_missing_content() {
2811 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
2812 assert!(result.contains("missing required parameter"));
2813 }
2814
2815 #[test]
2816 fn test_create_file_success() {
2817 let dir = std::env::temp_dir().join("sigit_test_create_file");
2818 let _ = fs::remove_dir_all(&dir);
2819
2820 let file_path = dir.join("sub").join("new_file.txt");
2821 let args = serde_json::json!({
2822 "path": file_path,
2823 "content": "hello world"
2824 })
2825 .to_string();
2826
2827 let result = exec_create_file(&args);
2828 assert!(result.starts_with("Created file:"), "got: {result}");
2829 assert!(file_path.exists());
2830 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
2831
2832 let _ = fs::remove_dir_all(&dir);
2833 }
2834
2835 #[test]
2836 fn test_create_file_already_exists() {
2837 let dir = std::env::temp_dir().join("sigit_test_create_exists");
2838 let _ = fs::remove_dir_all(&dir);
2839 fs::create_dir_all(&dir).unwrap();
2840
2841 let file_path = dir.join("existing.txt");
2842 fs::write(&file_path, "original").unwrap();
2843
2844 let args = serde_json::json!({
2845 "path": file_path,
2846 "content": "overwrite attempt"
2847 })
2848 .to_string();
2849
2850 let result = exec_create_file(&args);
2851 assert!(result.contains("already exists"), "got: {result}");
2852 // Original content untouched.
2853 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2854
2855 let _ = fs::remove_dir_all(&dir);
2856 }
2857
2858 // ── edit_file tests ──────────────────────────────────────────────────
2859
2860 #[test]
2861 fn test_edit_file_missing_params() {
2862 let result = exec_edit_file(r#"{"path": "x"}"#);
2863 assert!(result.contains("missing required parameter"));
2864
2865 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
2866 assert!(result.contains("missing required parameter"));
2867 }
2868
2869 #[test]
2870 fn test_edit_file_nonexistent() {
2871 let result = exec_edit_file(
2872 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
2873 );
2874 assert!(result.contains("does not exist"));
2875 }
2876
2877 #[test]
2878 fn test_edit_file_success() {
2879 let dir = std::env::temp_dir().join("sigit_test_edit_file");
2880 let _ = fs::remove_dir_all(&dir);
2881 fs::create_dir_all(&dir).unwrap();
2882
2883 let file_path = dir.join("code.rs");
2884 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
2885
2886 let args = serde_json::json!({
2887 "path": file_path,
2888 "old_text": "println!(\"hello\")",
2889 "new_text": "println!(\"world\")"
2890 })
2891 .to_string();
2892
2893 let result = exec_edit_file(&args);
2894 assert!(result.starts_with("Edited file:"), "got: {result}");
2895
2896 let updated = fs::read_to_string(&file_path).unwrap();
2897 assert!(updated.contains("println!(\"world\")"));
2898 assert!(!updated.contains("println!(\"hello\")"));
2899
2900 let _ = fs::remove_dir_all(&dir);
2901 }
2902
2903 #[test]
2904 fn test_edit_file_old_text_not_found() {
2905 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
2906 let _ = fs::remove_dir_all(&dir);
2907 fs::create_dir_all(&dir).unwrap();
2908
2909 let file_path = dir.join("data.txt");
2910 fs::write(&file_path, "aaa bbb ccc").unwrap();
2911
2912 let args = serde_json::json!({
2913 "path": file_path,
2914 "old_text": "zzz",
2915 "new_text": "yyy"
2916 })
2917 .to_string();
2918
2919 let result = exec_edit_file(&args);
2920 assert!(result.contains("old_text not found"), "got: {result}");
2921
2922 let _ = fs::remove_dir_all(&dir);
2923 }
2924
2925 #[test]
2926 fn test_edit_file_ambiguous_match() {
2927 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
2928 let _ = fs::remove_dir_all(&dir);
2929 fs::create_dir_all(&dir).unwrap();
2930
2931 let file_path = dir.join("repeat.txt");
2932 fs::write(&file_path, "foo bar foo bar foo").unwrap();
2933
2934 let args = serde_json::json!({
2935 "path": file_path,
2936 "old_text": "foo",
2937 "new_text": "baz"
2938 })
2939 .to_string();
2940
2941 let result = exec_edit_file(&args);
2942 assert!(result.contains("appears 3 times"), "got: {result}");
2943 // File should be unchanged.
2944 assert_eq!(
2945 fs::read_to_string(&file_path).unwrap(),
2946 "foo bar foo bar foo"
2947 );
2948
2949 let _ = fs::remove_dir_all(&dir);
2950 }
2951
2952 // ── delete_file tests ────────────────────────────────────────────────
2953
2954 #[test]
2955 fn test_delete_file_missing_path() {
2956 let result = exec_delete_file("{}");
2957 assert!(
2958 result.contains("missing required parameter"),
2959 "got: {result}"
2960 );
2961 }
2962
2963 #[test]
2964 fn test_delete_file_nonexistent() {
2965 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
2966 assert!(result.contains("does not exist"), "got: {result}");
2967 }
2968
2969 #[test]
2970 fn test_delete_file_success() {
2971 let dir = std::env::temp_dir().join("sigit_test_delete_file");
2972 let _ = fs::remove_dir_all(&dir);
2973 fs::create_dir_all(&dir).unwrap();
2974
2975 let file_path = dir.join("to_delete.txt");
2976 fs::write(&file_path, "bye").unwrap();
2977 assert!(file_path.exists());
2978
2979 let args = serde_json::json!({ "path": file_path }).to_string();
2980 let result = exec_delete_file(&args);
2981 assert!(result.contains("Deleted file"), "got: {result}");
2982 assert!(!file_path.exists());
2983
2984 let _ = fs::remove_dir_all(&dir);
2985 }
2986
2987 #[test]
2988 fn test_delete_empty_directory() {
2989 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
2990 let _ = fs::remove_dir_all(&dir);
2991 fs::create_dir_all(&dir).unwrap();
2992
2993 let args = serde_json::json!({ "path": dir }).to_string();
2994 let result = exec_delete_file(&args);
2995 assert!(result.contains("Deleted empty directory"), "got: {result}");
2996 assert!(!dir.exists());
2997 }
2998
2999 #[test]
3000 fn test_delete_nonempty_directory() {
3001 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
3002 let _ = fs::remove_dir_all(&dir);
3003 fs::create_dir_all(&dir).unwrap();
3004 fs::write(dir.join("child.txt"), "content").unwrap();
3005
3006 let args = serde_json::json!({ "path": dir }).to_string();
3007 let result = exec_delete_file(&args);
3008 assert!(result.contains("Error"), "got: {result}");
3009 assert!(dir.exists(), "directory should not have been deleted");
3010
3011 let _ = fs::remove_dir_all(&dir);
3012 }
3013
3014 // ── run_command tests ────────────────────────────────────────────────
3015
3016 #[test]
3017 fn test_run_command_missing_command() {
3018 let result = exec_run_command("{}");
3019 assert!(
3020 result.contains("missing required parameter"),
3021 "got: {result}"
3022 );
3023 }
3024
3025 #[test]
3026 fn test_run_command_success() {
3027 let result = exec_run_command(r#"{"command": "echo hello"}"#);
3028 assert!(result.contains("hello"), "got: {result}");
3029 assert!(result.contains("Exit code 0"), "got: {result}");
3030 }
3031
3032 /// Fresh git repo with one commit, test identity, and signing off (the
3033 /// developer's global gpgsign must not leak into sandbox commits).
3034 fn init_test_repo(name: &str) -> std::path::PathBuf {
3035 let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id()));
3036 let _ = fs::remove_dir_all(&dir);
3037 fs::create_dir_all(&dir).unwrap();
3038 test_git(&dir, &["init", "-q", "-b", "main"]);
3039 test_git(&dir, &["config", "user.name", "Test User"]);
3040 test_git(&dir, &["config", "user.email", "test@example.com"]);
3041 test_git(&dir, &["config", "commit.gpgsign", "false"]);
3042 fs::write(dir.join("file.txt"), "one\n").unwrap();
3043 test_git(&dir, &["add", "file.txt"]);
3044 test_git(&dir, &["commit", "-q", "-m", "Initial"]);
3045 dir
3046 }
3047
3048 fn test_git(dir: &Path, args: &[&str]) {
3049 let out = Command::new("git")
3050 .args(args)
3051 .current_dir(dir)
3052 .output()
3053 .unwrap();
3054 assert!(
3055 out.status.success(),
3056 "git {args:?} failed: {}",
3057 String::from_utf8_lossy(&out.stderr)
3058 );
3059 }
3060
3061 #[test]
3062 fn run_command_appends_co_author_trailer_to_new_commits() {
3063 let dir = init_test_repo("coauthor_append");
3064 fs::write(dir.join("file.txt"), "two\n").unwrap();
3065 // Quote-free command: `cmd /C` does not strip double quotes the way
3066 // `sh -c` does, so quoted arguments would break on Windows.
3067 let args = serde_json::json!({
3068 "command": "git add file.txt && git commit -m Update",
3069 "cwd": dir.display().to_string(),
3070 })
3071 .to_string();
3072
3073 let result = exec_run_command(&args);
3074 assert!(result.contains("co-author trailer"), "got: {result}");
3075
3076 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
3077 assert!(
3078 message.ends_with(COMMIT_CO_AUTHOR_TRAILER),
3079 "trailer must be the last line: {message:?}"
3080 );
3081 assert!(
3082 message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")),
3083 "trailer needs a blank line before it for GitHub to detect it: {message:?}"
3084 );
3085 let _ = fs::remove_dir_all(&dir);
3086 }
3087
3088 #[test]
3089 fn run_command_keeps_existing_co_author_trailer() {
3090 let dir = init_test_repo("coauthor_present");
3091 fs::write(dir.join("file.txt"), "two\n").unwrap();
3092 // The trailer contains spaces and angle brackets, which `cmd /C`
3093 // mis-tokenizes (`<` is redirection), so create the trailer-carrying
3094 // commit with direct git args and let run_command amend it without
3095 // editing: HEAD changes, the message already has the trailer, and the
3096 // gate must leave it alone.
3097 test_git(&dir, &["add", "file.txt"]);
3098 test_git(
3099 &dir,
3100 &[
3101 "commit",
3102 "-q",
3103 "-m",
3104 &format!("Update file\n\n{COMMIT_CO_AUTHOR_TRAILER}"),
3105 ],
3106 );
3107 let args = serde_json::json!({
3108 "command": "git commit --amend --no-edit",
3109 "cwd": dir.display().to_string(),
3110 })
3111 .to_string();
3112
3113 let result = exec_run_command(&args);
3114 assert!(
3115 !result.contains("[siGit Code]"),
3116 "no amend expected: {result}"
3117 );
3118
3119 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
3120 assert_eq!(
3121 message.matches("Co-Authored-By: siGit Code").count(),
3122 1,
3123 "trailer must not be duplicated: {message:?}"
3124 );
3125 let _ = fs::remove_dir_all(&dir);
3126 }
3127
3128 #[test]
3129 fn run_command_never_amends_pushed_commits() {
3130 let dir = init_test_repo("coauthor_pushed");
3131 let remote = std::env::temp_dir().join(format!(
3132 "sigit_test_coauthor_remote_{}.git",
3133 std::process::id()
3134 ));
3135 let _ = fs::remove_dir_all(&remote);
3136 fs::create_dir_all(&remote).unwrap();
3137 test_git(&remote, &["init", "-q", "--bare"]);
3138 test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]);
3139
3140 fs::write(dir.join("file.txt"), "two\n").unwrap();
3141 let args = serde_json::json!({
3142 "command": "git add file.txt && git commit -m Update && git push -q origin main",
3143 "cwd": dir.display().to_string(),
3144 })
3145 .to_string();
3146
3147 let result = exec_run_command(&args);
3148 assert!(
3149 !result.contains("[siGit Code]"),
3150 "no amend expected: {result}"
3151 );
3152
3153 // Already on the remote when the gate ran, so it must be untouched.
3154 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
3155 assert!(
3156 !message.contains("Co-Authored-By"),
3157 "pushed commit must not be rewritten: {message:?}"
3158 );
3159 let _ = fs::remove_dir_all(&dir);
3160 let _ = fs::remove_dir_all(&remote);
3161 }
3162
3163 #[test]
3164 fn test_run_command_failure() {
3165 #[cfg(unix)]
3166 let command = "false";
3167 #[cfg(windows)]
3168 let command = "exit /b 1";
3169
3170 let args = serde_json::json!({ "command": command }).to_string();
3171 let result = exec_run_command(&args);
3172 assert!(result.contains("failed"), "got: {result}");
3173 }
3174
3175 #[test]
3176 fn test_run_command_with_cwd() {
3177 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
3178 let _ = fs::remove_dir_all(&dir);
3179 fs::create_dir_all(&dir).unwrap();
3180
3181 #[cfg(unix)]
3182 let command = "pwd";
3183 #[cfg(windows)]
3184 let command = "cd";
3185
3186 let args = serde_json::json!({
3187 "command": command,
3188 "cwd": dir
3189 })
3190 .to_string();
3191 let result = exec_run_command(&args);
3192 // The output should contain the temp dir path.
3193 assert!(
3194 result.contains(&dir.to_string_lossy().to_string()),
3195 "got: {result}"
3196 );
3197
3198 let _ = fs::remove_dir_all(&dir);
3199 }
3200
3201 #[test]
3202 fn test_run_command_bad_cwd() {
3203 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
3204 let _ = fs::remove_dir_all(&missing_dir);
3205
3206 let args = serde_json::json!({
3207 "command": "echo hi",
3208 "cwd": missing_dir
3209 })
3210 .to_string();
3211 let result = exec_run_command(&args);
3212 assert!(result.contains("does not exist"), "got: {result}");
3213 }
3214
3215 #[test]
3216 fn test_run_command_captures_stderr() {
3217 #[cfg(unix)]
3218 let command = "echo err >&2";
3219 #[cfg(windows)]
3220 let command = "echo err 1>&2";
3221
3222 let args = serde_json::json!({ "command": command }).to_string();
3223 let result = exec_run_command(&args);
3224 assert!(result.contains("err"), "got: {result}");
3225 }
3226
3227 // ── background command tests ─────────────────────────────────────────
3228
3229 /// Extract the task id from a "Started background task {id}: ..." result.
3230 fn background_task_id(result: &str) -> u64 {
3231 let digits: String = result
3232 .strip_prefix("Started background task ")
3233 .unwrap_or_else(|| panic!("unexpected spawn result: {result}"))
3234 .chars()
3235 .take_while(char::is_ascii_digit)
3236 .collect();
3237 digits.parse().expect("task id")
3238 }
3239
3240 /// Whether the task's child has exited, without draining its output.
3241 fn background_task_exited(task_id: u64) -> bool {
3242 let mut map = lock_tasks();
3243 match map.get_mut(&task_id) {
3244 Some(task) => poll_exit_code(task).is_some(),
3245 None => true,
3246 }
3247 }
3248
3249 /// Wait (bounded) for a background task's child to exit.
3250 fn wait_for_background_exit(task_id: u64) {
3251 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
3252 while !background_task_exited(task_id) {
3253 assert!(
3254 std::time::Instant::now() < deadline,
3255 "task {task_id} did not exit in time"
3256 );
3257 std::thread::sleep(std::time::Duration::from_millis(50));
3258 }
3259 }
3260
3261 #[test]
3262 fn test_run_command_background_lifecycle() {
3263 #[cfg(unix)]
3264 let command = "echo start; sleep 2; echo done";
3265 #[cfg(windows)]
3266 let command = "echo start&& ping -n 3 127.0.0.1 > nul&& echo done";
3267
3268 let args = serde_json::json!({
3269 "command": command,
3270 "cwd": std::env::temp_dir(),
3271 "run_in_background": true
3272 })
3273 .to_string();
3274 let spawned = std::time::Instant::now();
3275 let result = exec_run_command(&args);
3276 // Spawning must return immediately, not wait the ~2s the command takes.
3277 assert!(
3278 spawned.elapsed() < std::time::Duration::from_secs(1),
3279 "background spawn blocked for {:?}",
3280 spawned.elapsed()
3281 );
3282 assert!(result.contains("command_output"), "got: {result}");
3283 let task_id = background_task_id(&result);
3284
3285 // Polling while the command is still sleeping reports it as running.
3286 let poll_args = serde_json::json!({ "task_id": task_id }).to_string();
3287 let poll = exec_command_output(&poll_args);
3288 assert!(poll.contains("still running"), "got: {poll}");
3289 let mut combined = poll;
3290
3291 wait_for_background_exit(task_id);
3292 // Grace period so the reader threads finish draining the pipes.
3293 std::thread::sleep(std::time::Duration::from_millis(300));
3294 let final_poll = exec_command_output(&poll_args);
3295 assert!(
3296 final_poll.contains("exited with code 0"),
3297 "got: {final_poll}"
3298 );
3299 combined.push_str(&final_poll);
3300
3301 // Across the polls, all output the command printed was delivered.
3302 assert!(combined.contains("start"), "got: {combined}");
3303 assert!(combined.contains("done"), "got: {combined}");
3304 }
3305
3306 #[test]
3307 fn test_kill_command_stops_background_task() {
3308 #[cfg(unix)]
3309 let command = "sleep 30";
3310 #[cfg(windows)]
3311 let command = "ping -n 31 127.0.0.1 > nul";
3312
3313 let args = serde_json::json!({
3314 "command": command,
3315 "cwd": std::env::temp_dir(),
3316 "run_in_background": true
3317 })
3318 .to_string();
3319 let result = exec_run_command(&args);
3320 let task_id = background_task_id(&result);
3321
3322 let kill_args = serde_json::json!({ "task_id": task_id }).to_string();
3323 let killed_at = std::time::Instant::now();
3324 let kill_result = exec_kill_command(&kill_args);
3325 assert!(
3326 kill_result.contains(&format!("Killed task {task_id}")),
3327 "got: {kill_result}"
3328 );
3329 // The kill must not wait out the 30s sleep.
3330 assert!(
3331 killed_at.elapsed() < std::time::Duration::from_secs(5),
3332 "kill blocked for {:?}",
3333 killed_at.elapsed()
3334 );
3335
3336 // A later poll reports the task as killed, not still running.
3337 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3338 assert!(poll.contains("was killed"), "got: {poll}");
3339 }
3340
3341 #[test]
3342 fn test_command_output_unknown_task() {
3343 let result = exec_command_output(r#"{"task_id": 9999999}"#);
3344 assert!(
3345 result.contains("no background task with id"),
3346 "got: {result}"
3347 );
3348 }
3349
3350 // ── task (subagent) tests ────────────────────────────────────────────
3351
3352 #[test]
3353 fn test_subagent_toolset_is_read_only() {
3354 let specs = subagent_tool_specs(&effective_subagent_tools(None).unwrap());
3355 let mut names: Vec<&str> = specs.iter().map(|spec| spec.name.as_str()).collect();
3356 names.sort_unstable();
3357 assert_eq!(
3358 names,
3359 [
3360 "glob",
3361 "list_directory",
3362 "read_file",
3363 "read_website",
3364 "search_files"
3365 ]
3366 );
3367
3368 // No recursion and no mutating tools, ever.
3369 assert!(!names.contains(&TASK_TOOL_NAME));
3370 for banned in [
3371 "create_file",
3372 "create_directory",
3373 "edit_file",
3374 "multi_edit",
3375 "delete_file",
3376 "run_command",
3377 "write_todos",
3378 "remember",
3379 ] {
3380 assert!(!names.contains(&banned), "{banned} leaked into subagent");
3381 }
3382
3383 // Every spec came from `all_tools()` (schemas intact).
3384 for spec in &specs {
3385 assert!(
3386 serde_json::from_str::<Value>(&spec.parameters_schema)
3387 .unwrap()
3388 .is_object()
3389 );
3390 }
3391 }
3392
3393 #[tokio::test]
3394 async fn test_task_reports_unavailable_without_factory() {
3395 let args = serde_json::json!({
3396 "description": "look around",
3397 "prompt": "What is in the current directory?"
3398 })
3399 .to_string();
3400 // `None` is exactly what `exec_task` passes before any surface has
3401 // registered the process-global factory.
3402 let result = exec_task_with(&args, None).await;
3403 assert!(
3404 result.contains("not available on-device yet"),
3405 "got: {result}"
3406 );
3407 }
3408
3409 #[test]
3410 fn test_kill_command_unknown_task() {
3411 let result = exec_kill_command(r#"{"task_id": 9999999}"#);
3412 assert!(
3413 result.contains("no background task with id"),
3414 "got: {result}"
3415 );
3416 }
3417
3418 #[tokio::test]
3419 async fn test_task_missing_prompt() {
3420 let result = exec_task_with(r#"{"description": "x"}"#, None).await;
3421 assert!(
3422 result.contains("missing required parameter \"prompt\""),
3423 "got: {result}"
3424 );
3425 }
3426
3427 // ── web_search tests ─────────────────────────────────────────────────
3428
3429 #[test]
3430 fn test_web_search_tool_spec_matches_the_cloud_mcp_tools_contract() {
3431 let spec = web_search_tool_spec();
3432 assert_eq!(spec.name, "web_search");
3433 let schema: Value = serde_json::from_str(&spec.parameters_schema).unwrap();
3434 assert_eq!(schema["required"], serde_json::json!(["query"]));
3435 assert!(schema["properties"]["query"].is_object());
3436 assert!(schema["properties"]["count"].is_object());
3437 }
3438
3439 #[test]
3440 fn test_is_web_search_delegate() {
3441 assert!(is_web_search_delegate("mcp__sigit__web_search"));
3442 assert!(!is_web_search_delegate("web_search"));
3443 assert!(!is_web_search_delegate("mcp__other__web_search"));
3444 }
3445
3446 #[test]
3447 fn test_web_search_unavailable_without_mcp_init() {
3448 // `mcp::init()` is never called anywhere in this test binary (it does
3449 // real network I/O and is a one-shot `OnceLock`, same constraint as
3450 // `SUBAGENT_FACTORY`), so the official server's tools — including
3451 // web_search — are never discovered here. This proves the
3452 // conditional-advertisement gate fails closed, matching a
3453 // signed-out/MCP-disabled session, rather than defaulting to "always
3454 // offered".
3455 assert!(!web_search_available());
3456 }
3457
3458 #[tokio::test]
3459 async fn test_web_search_delegates_to_mcp_and_degrades_gracefully_when_uninitialized() {
3460 // With MCP uninitialized (see the test above), `mcp::call_tool`
3461 // returns its own in-band error string rather than panicking — proves
3462 // execute_tool's WEB_SEARCH_TOOL_NAME arm actually reaches
3463 // mcp::call_tool(WEB_SEARCH_MCP_DELEGATE, ...) instead of, say, silently
3464 // no-op'ing or hitting the `Unknown tool` fallback.
3465 let args = serde_json::json!({ "query": "siGit Code" }).to_string();
3466 let result = execute_tool(WEB_SEARCH_TOOL_NAME, &args).await;
3467 assert_eq!(result, "Error: MCP is not initialized.");
3468 }
3469
3470 #[test]
3471 fn test_command_output_missing_task_id() {
3472 let result = exec_command_output("{}");
3473 assert!(
3474 result.contains("missing required parameter"),
3475 "got: {result}"
3476 );
3477 }
3478
3479 #[test]
3480 fn test_background_output_is_capped() {
3481 // Print well over COMMAND_OUTPUT_LIMIT (50 000) bytes without polling,
3482 // so the buffer must drop the oldest output and note the truncation.
3483 #[cfg(unix)]
3484 let command = r#"i=0; while [ $i -lt 200 ]; do printf 'line-%04d %s\n' "$i" \
3485 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3486 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3487 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3488 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3489 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; \
3490 i=$((i+1)); done"#;
3491 // Quote-free on purpose: `cmd /C` does not strip double quotes the
3492 // way `sh -c` does, so a quoted PowerShell one-liner gets mangled.
3493 // A cmd-native for /L loop needs no quoting at all.
3494 #[cfg(windows)]
3495 let command = &format!(
3496 "for /L %i in (1,1,200) do @echo line-%i-end {}",
3497 "a".repeat(330)
3498 );
3499
3500 let args = serde_json::json!({
3501 "command": command,
3502 "cwd": std::env::temp_dir(),
3503 "run_in_background": true
3504 })
3505 .to_string();
3506 let result = exec_run_command(&args);
3507 let task_id = background_task_id(&result);
3508
3509 wait_for_background_exit(task_id);
3510 // Grace period so the reader threads finish draining the pipes.
3511 std::thread::sleep(std::time::Duration::from_millis(300));
3512
3513 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3514 assert!(poll.contains("exited with code 0"), "got: {poll}");
3515 assert!(
3516 poll.contains("earlier output was dropped"),
3517 "expected truncation note, got: {poll}"
3518 );
3519 // The oldest lines were dropped; the newest survived. The two
3520 // platforms number lines differently (printf %04d vs cmd's %i).
3521 #[cfg(unix)]
3522 let (oldest, newest) = ("line-0000", "line-0199");
3523 #[cfg(windows)]
3524 let (oldest, newest) = ("line-1-end", "line-200-end");
3525 assert!(!poll.contains(oldest), "oldest output not dropped");
3526 assert!(poll.contains(newest), "newest output missing");
3527 // Buffer stayed within the cap, plus the framing: the status line
3528 // (which quotes the command) and the truncation note.
3529 assert!(
3530 poll.len() <= COMMAND_OUTPUT_LIMIT + command.len() + 500,
3531 "poll result too large: {} bytes",
3532 poll.len()
3533 );
3534 }
3535
3536 // ── task (subagent) end-to-end against a scripted endpoint ──────────
3537
3538 /// A completion whose assistant message requests one tool call.
3539 fn completion_tool_call(id: &str, name: &str, arguments: &str) -> String {
3540 serde_json::json!({
3541 "choices": [{"message": {
3542 "role": "assistant",
3543 "content": serde_json::Value::Null,
3544 "tool_calls": [{
3545 "id": id,
3546 "type": "function",
3547 "function": {"name": name, "arguments": arguments},
3548 }],
3549 }}]
3550 })
3551 .to_string()
3552 }
3553
3554 /// A completion whose assistant message is a plain text answer.
3555 fn completion_text(text: &str) -> String {
3556 serde_json::json!({
3557 "choices": [{"message": {"role": "assistant", "content": text}}]
3558 })
3559 .to_string()
3560 }
3561
3562 /// Minimal scripted OpenAI-compatible endpoint (same pattern as
3563 /// `tests/acp_permissions.rs`): serves one canned chat-completion JSON body
3564 /// per request and records each request body. The subagent loop passes
3565 /// `sink: None`, so the backend takes the non-streaming path and expects
3566 /// plain JSON rather than SSE.
3567 fn start_scripted_endpoint(responses: Vec<String>) -> (u16, Arc<std::sync::Mutex<Vec<Value>>>) {
3568 use std::io::{BufRead, BufReader, Read, Write};
3569
3570 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind endpoint");
3571 let port = listener.local_addr().unwrap().port();
3572 let requests: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
3573 let recorded = Arc::clone(&requests);
3574 let queue = std::sync::Mutex::new(std::collections::VecDeque::from(responses));
3575
3576 std::thread::spawn(move || {
3577 // `connection: close` means one request per connection, matching
3578 // the backend's serial completion requests.
3579 for stream in listener.incoming() {
3580 let Ok(mut stream) = stream else { continue };
3581 let mut reader = BufReader::new(match stream.try_clone() {
3582 Ok(clone) => clone,
3583 Err(_) => continue,
3584 });
3585 let mut content_length = 0usize;
3586 loop {
3587 let mut line = String::new();
3588 if reader.read_line(&mut line).unwrap_or(0) == 0 {
3589 break;
3590 }
3591 let line = line.trim();
3592 if line.is_empty() {
3593 break;
3594 }
3595 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:")
3596 {
3597 content_length = length.trim().parse().unwrap_or(0);
3598 }
3599 }
3600 let mut body = vec![0u8; content_length];
3601 if reader.read_exact(&mut body).is_err() {
3602 continue;
3603 }
3604 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
3605 recorded.lock().unwrap().push(request);
3606 }
3607 let payload = queue
3608 .lock()
3609 .unwrap()
3610 .pop_front()
3611 .unwrap_or_else(|| completion_text("out of scripted responses"));
3612 let response = format!(
3613 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
3614 content-length: {}\r\nconnection: close\r\n\r\n{}",
3615 payload.len(),
3616 payload
3617 );
3618 let _ = stream.write_all(response.as_bytes());
3619 }
3620 });
3621
3622 (port, requests)
3623 }
3624
3625 #[tokio::test]
3626 #[allow(clippy::await_holding_lock)] // see the ENV_TEST_LOCK comment inside
3627 async fn test_task_runs_subagent_end_to_end() {
3628 // A file only the subagent's read_file call can surface.
3629 let dir = std::env::temp_dir().join("sigit_test_subagent_e2e");
3630 let _ = fs::remove_dir_all(&dir);
3631 fs::create_dir_all(&dir).unwrap();
3632 let file = dir.join("notes.txt");
3633 fs::write(&file, "subagent secret: 4217").unwrap();
3634
3635 // Script: one read_file tool call, a final text answer, then a third
3636 // response for the custom-agent-type phase further down.
3637 let (port, requests) = start_scripted_endpoint(vec![
3638 completion_tool_call(
3639 "call_1",
3640 "read_file",
3641 &serde_json::json!({ "path": file }).to_string(),
3642 ),
3643 completion_text("The file contains the number 4217."),
3644 completion_text("Custom type answer."),
3645 ]);
3646
3647 // Register the real process-global factory, pointing a fresh
3648 // OpenAiBackend at the scripted endpoint — exactly what the surfaces
3649 // do at startup. This is the only test that touches the OnceLock
3650 // (it's a `OnceLock`: later `set_subagent_factory` calls from other
3651 // tests are silently ignored, so every test needing a real factory
3652 // has to share this one registration — see the custom-agent-type
3653 // phase later in this same test).
3654 set_subagent_factory(Box::new(move |system_prompt: &str| {
3655 Some(Arc::new(crate::backend::OpenAiBackend::new(
3656 format!("http://127.0.0.1:{port}"),
3657 "test-key",
3658 "scripted-model",
3659 Some(system_prompt.to_string()),
3660 )) as Arc<dyn InferenceBackend>)
3661 }));
3662 assert!(subagent_available());
3663
3664 let args = serde_json::json!({
3665 "description": "read the notes file",
3666 "prompt": format!("What number is recorded in {}?", file.display()),
3667 })
3668 .to_string();
3669 let result = execute_tool(TASK_TOOL_NAME, &args).await;
3670
3671 // Only the subagent's final text comes back.
3672 assert_eq!(result, "The file contains the number 4217.");
3673
3674 // Scoped so the `MutexGuard` is dropped before phase 2's `.await` —
3675 // holding a `std::sync::MutexGuard` across an await point is a bug
3676 // (it can deadlock or block the executor), and clippy's
3677 // `await_holding_lock` lint doesn't reliably see through a manual
3678 // `drop()` call, so use an explicit block instead.
3679 {
3680 let recorded = requests.lock().unwrap();
3681 assert_eq!(recorded.len(), 2, "expected exactly two completions");
3682
3683 // The subagent conversation is fresh (subagent system prompt) and
3684 // was offered only the read-only toolset.
3685 let first = &recorded[0];
3686 assert_eq!(first["messages"][0]["role"], "system");
3687 assert!(
3688 first["messages"][0]["content"]
3689 .as_str()
3690 .unwrap()
3691 .contains("research subagent")
3692 );
3693 let offered: Vec<&str> = first["tools"]
3694 .as_array()
3695 .unwrap()
3696 .iter()
3697 .map(|tool| tool["function"]["name"].as_str().unwrap())
3698 .collect();
3699 for name in &offered {
3700 assert!(
3701 SUBAGENT_TOOL_NAMES.contains(name),
3702 "non-read-only tool offered: {name}"
3703 );
3704 }
3705 assert!(!offered.contains(&TASK_TOOL_NAME));
3706
3707 // The read-only tool actually executed: its output travelled back
3708 // to the endpoint as a tool result on the second request.
3709 let messages = recorded[1]["messages"].as_array().unwrap();
3710 let tool_message = messages
3711 .iter()
3712 .find(|message| message["role"] == "tool")
3713 .expect("no tool result in second request");
3714 assert_eq!(tool_message["tool_call_id"], "call_1");
3715 assert!(
3716 tool_message["content"]
3717 .as_str()
3718 .unwrap()
3719 .contains("subagent secret: 4217")
3720 );
3721 }
3722
3723 // ── Phase 2: a configured subagent type, on the same registered
3724 // factory and endpoint (the third scripted response above). Proves
3725 // `subagent_type` actually swaps the system prompt and narrows the
3726 // offered toolset end to end, not just at the pure-function level
3727 // (see `effective_subagent_tools_*` below for that).
3728 let agents_root = dir.join(".sigit").join("agents");
3729 fs::create_dir_all(&agents_root).unwrap();
3730 fs::write(
3731 agents_root.join("notes-reader.md"),
3732 "---\nname: notes-reader\ndescription: Reads notes files only\ntools: read_file\n---\n\nYou only ever read notes files and report exactly what they say.\n",
3733 )
3734 .unwrap();
3735
3736 // Serialize the cwd change against the other discovery tests
3737 // (skills/commands/subagents) — the cwd is process-global and cargo
3738 // runs tests in parallel. The guard is held across the `.await`
3739 // below on purpose: the cwd must stay pinned while `execute_tool`
3740 // runs agent-type discovery. That's safe here — each `#[tokio::test]`
3741 // gets its own single-threaded runtime on its own test thread, so no
3742 // other task on this executor can contend for the lock — hence the
3743 // `await_holding_lock` allow on this test.
3744 let _env_guard = crate::ENV_TEST_LOCK
3745 .lock()
3746 .unwrap_or_else(|poisoned| poisoned.into_inner());
3747 let prev_cwd = std::env::current_dir().unwrap();
3748 std::env::set_current_dir(&dir).unwrap();
3749
3750 let args = serde_json::json!({
3751 "description": "read a note",
3752 "prompt": "What does the notes file say?",
3753 "subagent_type": "notes-reader",
3754 })
3755 .to_string();
3756 let result = execute_tool(TASK_TOOL_NAME, &args).await;
3757 assert_eq!(result, "Custom type answer.");
3758
3759 let recorded = requests.lock().unwrap();
3760 assert_eq!(recorded.len(), 3, "expected a third recorded request");
3761 let last = &recorded[2];
3762 assert_eq!(last["messages"][0]["role"], "system");
3763 assert_eq!(
3764 last["messages"][0]["content"].as_str().unwrap(),
3765 "You only ever read notes files and report exactly what they say."
3766 );
3767 let offered: Vec<&str> = last["tools"]
3768 .as_array()
3769 .unwrap()
3770 .iter()
3771 .map(|tool| tool["function"]["name"].as_str().unwrap())
3772 .collect();
3773 assert_eq!(
3774 offered,
3775 vec!["read_file"],
3776 "notes-reader's tools: read_file should narrow the offered toolset to just that"
3777 );
3778 drop(recorded);
3779
3780 std::env::set_current_dir(prev_cwd).unwrap();
3781 let _ = fs::remove_dir_all(&dir);
3782 }
3783
3784 #[test]
3785 fn effective_subagent_tools_defaults_to_full_read_only_set_without_a_type() {
3786 let mut tools = effective_subagent_tools(None).unwrap();
3787 tools.sort();
3788 let mut expected: Vec<String> = SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect();
3789 expected.sort();
3790 assert_eq!(tools, expected);
3791 }
3792
3793 #[test]
3794 fn effective_subagent_tools_inherits_full_set_when_type_omits_tools_field() {
3795 let agent_type = subagents::AgentType {
3796 name: "generalist".to_string(),
3797 description: "does anything".to_string(),
3798 tools: None,
3799 system_prompt: "Be helpful.".to_string(),
3800 path: PathBuf::from("/x/generalist.md"),
3801 };
3802 let mut tools = effective_subagent_tools(Some(&agent_type)).unwrap();
3803 tools.sort();
3804 let mut expected: Vec<String> = SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect();
3805 expected.sort();
3806 assert_eq!(tools, expected);
3807 }
3808
3809 #[test]
3810 fn effective_subagent_tools_narrows_to_the_types_allow_list() {
3811 let agent_type = subagents::AgentType {
3812 name: "notes-reader".to_string(),
3813 description: "reads notes".to_string(),
3814 tools: Some(vec!["read_file".to_string(), "glob".to_string()]),
3815 system_prompt: "Read notes.".to_string(),
3816 path: PathBuf::from("/x/notes-reader.md"),
3817 };
3818 let tools = effective_subagent_tools(Some(&agent_type)).unwrap();
3819 assert_eq!(tools, vec!["read_file".to_string(), "glob".to_string()]);
3820 }
3821
3822 #[test]
3823 fn effective_subagent_tools_drops_anything_outside_the_read_only_ceiling() {
3824 // The security-critical case: a type file cannot grant itself a
3825 // mutating tool by listing it in `tools:` — it's silently dropped,
3826 // never passed through to the offered/gated toolset.
3827 let agent_type = subagents::AgentType {
3828 name: "sneaky".to_string(),
3829 description: "tries to grant itself write access".to_string(),
3830 tools: Some(vec![
3831 "read_file".to_string(),
3832 "edit_file".to_string(),
3833 "run_command".to_string(),
3834 "task".to_string(),
3835 ]),
3836 system_prompt: "...".to_string(),
3837 path: PathBuf::from("/x/sneaky.md"),
3838 };
3839 let tools = effective_subagent_tools(Some(&agent_type)).unwrap();
3840 assert_eq!(tools, vec!["read_file".to_string()]);
3841 assert!(!tools.contains(&"edit_file".to_string()));
3842 assert!(!tools.contains(&"run_command".to_string()));
3843 assert!(!tools.contains(&"task".to_string()));
3844 }
3845
3846 #[test]
3847 fn effective_subagent_tools_errors_when_allow_list_intersects_to_nothing() {
3848 // A type whose `tools:` names only out-of-ceiling tools (e.g. Claude
3849 // Code names carried over) must error, not silently run toolless.
3850 let agent_type = subagents::AgentType {
3851 name: "misconfigured".to_string(),
3852 description: "names only invalid tools".to_string(),
3853 tools: Some(vec!["grep".to_string(), "bash".to_string()]),
3854 system_prompt: "...".to_string(),
3855 path: PathBuf::from("/x/misconfigured.md"),
3856 };
3857 let err = effective_subagent_tools(Some(&agent_type)).unwrap_err();
3858 assert!(
3859 err.contains("misconfigured"),
3860 "message names the type: {err}"
3861 );
3862 assert!(err.contains("read-only set"), "message explains why: {err}");
3863 }
3864 }