001/*
002 * Copyright 2025 - 2025 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package com.agentsflex.tool.commons;
017
018import com.agentsflex.core.model.chat.tool.annotation.ToolDef;
019import com.agentsflex.core.model.chat.tool.annotation.ToolParam;
020
021import java.io.BufferedReader;
022import java.io.IOException;
023import java.io.InputStreamReader;
024import java.util.Map;
025import java.util.concurrent.ConcurrentHashMap;
026import java.util.concurrent.TimeUnit;
027import java.util.regex.Pattern;
028
029/**
030 * @author Christian Tzolov
031 * @author Michael Yang
032 */
033public class ShellTools {
034
035    // Storage for background processes
036    private static final Map<String, BackgroundProcess> backgroundProcesses = new ConcurrentHashMap<>();
037
038    // Inner class to manage background processes
039    private static class BackgroundProcess {
040
041        final Process process;
042
043        final StringBuilder stdout;
044
045        final StringBuilder stderr;
046
047        final Thread stdoutReader;
048
049        final Thread stderrReader;
050
051        int lastStdoutPosition = 0;
052
053        int lastStderrPosition = 0;
054
055        BackgroundProcess(Process process) {
056            this.process = process;
057            this.stdout = new StringBuilder();
058            this.stderr = new StringBuilder();
059
060            // Start thread to read stdout
061            this.stdoutReader = new Thread(() -> {
062                try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
063                    String line;
064                    while ((line = reader.readLine()) != null) {
065                        synchronized (stdout) {
066                            stdout.append(line).append("\n");
067                        }
068                    }
069                } catch (IOException e) {
070                    // Process terminated or stream closed
071                }
072            });
073            this.stdoutReader.setDaemon(true);
074            this.stdoutReader.start();
075
076            // Start thread to read stderr
077            this.stderrReader = new Thread(() -> {
078                try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
079                    String line;
080                    while ((line = reader.readLine()) != null) {
081                        synchronized (stderr) {
082                            stderr.append(line).append("\n");
083                        }
084                    }
085                } catch (IOException e) {
086                    // Process terminated or stream closed
087                }
088            });
089            this.stderrReader.setDaemon(true);
090            this.stderrReader.start();
091        }
092
093        String getNewOutput(String filter) {
094            StringBuilder result = new StringBuilder();
095
096            synchronized (stdout) {
097                String newStdout = stdout.substring(lastStdoutPosition);
098                if (filter != null && !filter.isEmpty()) {
099                    Pattern pattern = Pattern.compile(filter);
100                    newStdout = filterOutput(newStdout, pattern);
101                }
102                if (!newStdout.isEmpty()) {
103                    result.append("STDOUT:\n").append(newStdout);
104                }
105                lastStdoutPosition = stdout.length();
106            }
107
108            synchronized (stderr) {
109                String newStderr = stderr.substring(lastStderrPosition);
110                if (filter != null && !filter.isEmpty()) {
111                    Pattern pattern = Pattern.compile(filter);
112                    newStderr = filterOutput(newStderr, pattern);
113                }
114                if (!newStderr.isEmpty()) {
115                    if (result.length() > 0)
116                        result.append("\n");
117                    result.append("STDERR:\n").append(newStderr);
118                }
119                lastStderrPosition = stderr.length();
120            }
121
122            return result.toString();
123        }
124
125        private String filterOutput(String output, Pattern pattern) {
126            String[] lines = output.split("\n");
127            StringBuilder filtered = new StringBuilder();
128            for (String line : lines) {
129                if (pattern.matcher(line).find()) {
130                    filtered.append(line).append("\n");
131                }
132            }
133            return filtered.toString();
134        }
135
136        boolean isAlive() {
137            return process.isAlive();
138        }
139
140        void destroy() {
141            process.destroy();
142            try {
143                if (!process.waitFor(5, TimeUnit.SECONDS)) {
144                    process.destroyForcibly();
145                }
146            } catch (InterruptedException e) {
147                Thread.currentThread().interrupt();
148                process.destroyForcibly();
149            }
150        }
151
152        int getExitCode() {
153            return process.exitValue();
154        }
155
156    }
157
158    //
159    // Shell comnmands
160    //
161
162    // @formatter:off
163        @ToolDef(name = "Bash", description = "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\n" +
164        "\n" +
165        "IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\n" +
166        "\n" +
167        "Before executing the command, please follow these steps:\n" +
168        "\n" +
169        "1. Directory Verification:\n" +
170        "- If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\n" +
171        "- For example, before running \"mkdir foo/bar\", first use `ls foo` to check that \"foo\" exists and is the intended parent directory\n" +
172        "\n" +
173        "2. Command Execution:\n" +
174        "- Always quote file paths that contain spaces with double quotes (e.g., cd \"path with spaces/file.txt\")\n" +
175        "- Examples of proper quoting:\n" +
176        "- cd \"/Users/[REDACTED]/My Documents\" (correct)\n" +
177        "- cd /Users/[REDACTED]/My Documents (incorrect - will fail)\n" +
178        "- python \"/path/with spaces/script.py\" (correct)\n" +
179        "- python /path/with spaces/script.py (incorrect - will fail)\n" +
180        "- After ensuring proper quoting, execute the command.\n" +
181        "- Capture the output of the command.\n" +
182        "\n" +
183        "Usage notes:\n" +
184        "- The command argument is required.\n" +
185        "- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).\n" +
186        "- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\n" +
187        "- If the output exceeds 30000 characters, output will be truncated before being returned to you.\n" +
188        "- You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. Never use `run_in_background` to run 'sleep' as it will return immediately. You do not need to use '&' at the end of the command when using this parameter.\n" +
189        "\n" +
190        "- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\n" +
191        "- File search: Use Glob (NOT find or ls)\n" +
192        "- Content search: Use Grep (NOT grep or rg)\n" +
193        "- Read files: Use Read (NOT cat/head/tail)\n" +
194        "- Edit files: Use Edit (NOT sed/awk)\n" +
195        "- Write files: Use Write (NOT echo >/cat <<EOF)\n" +
196        "- Communication: Output text directly (NOT echo/printf)\n" +
197        "- When issuing multiple commands:\n" +
198        "- If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run \"git status\" and \"git diff\", send a single message with two Bash tool calls in parallel.\n" +
199        "- If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.\n" +
200        "- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail\n" +
201        "- DO NOT use newlines to separate commands (newlines are ok in quoted strings)\n" +
202        "- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.\n" +
203        "<good-example>\n" +
204        "pytest /foo/bar/tests\n" +
205        "</good-example>\n" +
206        "<bad-example>\n" +
207        "cd /foo/bar && pytest tests\n" +
208        "</bad-example>\n" +
209        "\n" +
210        "# Committing changes with git\n" +
211        "\n" +
212        "Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\n" +
213        "\n" +
214        "Git Safety Protocol:\n" +
215        "- NEVER update the git config\n" +
216        "- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\n" +
217        "- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\n" +
218        "- NEVER run force push to main/master, warn the user if they request it\n" +
219        "- Avoid git commit --amend.  ONLY use --amend when either (1) user explicitly requested amend OR (2) adding edits from pre-commit hook (additional instructions below)\n" +
220        "- Before amending: ALWAYS check authorship (git log -1 --format='%an %ae')\n" +
221        "- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\n" +
222        "\n" +
223        "1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\n" +
224        "- Run a git status command to see all untracked files.\n" +
225        "- Run a git diff command to see both staged and unstaged changes that will be committed.\n" +
226        "- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\n" +
227        "2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\n" +
228        "- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \"add\" means a wholly new feature, \"update\" means an enhancement to an existing feature, \"fix\" means a bug fix, etc.).\n" +
229        "- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files\n" +
230        "- Draft a concise (1-2 sentences) commit message that focuses on the \"why\" rather than the \"what\"\n" +
231        "- Ensure it accurately reflects the changes and their purpose\n" +
232        "3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\n" +
233        "- Add relevant untracked files to the staging area.\n" +
234        "- Create the commit with a message ending with:\n" +
235        "\uD83E\uDD16 Generated with [Claude Code](https://claude.com/claude-code)\n" +
236        "\n" +
237        "Co-Authored-By: Claude <noreply@anthropic.com>\n" +
238        "- Run git status after the commit completes to verify success.\n" +
239        "Note: git status depends on the commit completing, so run it sequentially after the commit.\n" +
240        "4. If the commit fails due to pre-commit hook changes, retry ONCE. If it succeeds but files were modified by the hook, verify it's safe to amend:\n" +
241        "- Check authorship: git log -1 --format='%an %ae'\n" +
242        "- Check not pushed: git status shows \"Your branch is ahead\"\n" +
243        "- If both true: amend your commit. Otherwise: create NEW commit (never amend other developers' commits)\n" +
244        "\n" +
245        "Important notes:\n" +
246        "- NEVER run additional commands to read or explore code, besides git bash commands\n" +
247        "- NEVER use the TodoWrite or Task tools\n" +
248        "- DO NOT push to the remote repository unless the user explicitly asks you to do so\n" +
249        "- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\n" +
250        "- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\n" +
251        "- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:\n" +
252        "<example>\n" +
253        "git commit -m \"$(cat <<'EOF'\n" +
254        "Commit message here.\n" +
255        "\n" +
256        "\uD83E\uDD16 Generated with [Claude Code](https://claude.com/claude-code)\n" +
257        "\n" +
258        "Co-Authored-By: Claude <noreply@anthropic.com>\n" +
259        "EOF\n" +
260        ")\"\n" +
261        "</example>\n" +
262        "\n" +
263        "# Creating pull requests\n" +
264        "Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.\n" +
265        "\n" +
266        "IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\n" +
267        "\n" +
268        "1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\n" +
269        "- Run a git status command to see all untracked files\n" +
270        "- Run a git diff command to see both staged and unstaged changes that will be committed\n" +
271        "- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\n" +
272        "- Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\n" +
273        "2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\n" +
274        "3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\n" +
275        "- Create new branch if needed\n" +
276        "- Push to remote with -u flag if needed\n" +
277        "- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\n" +
278        "<example>\n" +
279        "gh pr create --title \"the pr title\" --body \"$(cat <<'EOF'\n" +
280        "\n" +
281        "## Summary\n" +
282        "<1-3 bullet points>\n" +
283        "\n" +
284        "## Test plan\n" +
285        "[Bulleted markdown checklist of TODOs for testing the pull request...]\n" +
286        "\n" +
287        "\uD83E\uDD16 Generated with [Claude Code](https://claude.com/claude-code)\n" +
288        "EOF\n" +
289        ")\"\n" +
290        "</example>\n" +
291        "\n" +
292        "Important:\n" +
293        "- DO NOT use the TodoWrite or Task tools\n" +
294        "- Return the PR URL when you're done, so the user can see it\n" +
295        "\n" +
296        "# Other common operations\n" +
297        "- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments")
298        public String bash(
299                @ToolParam(name = "command", description = "The command to execute") String command,
300                @ToolParam(name = "timeout", description = "Optional timeout in milliseconds (max 600000)", required = false) Long timeout,
301                @ToolParam(name = "description", description = "Clear, concise description of what this command does in 5-10 words, in active voice. Examples:\nInput: ls\nOutput: List files in current directory\n\nInput: git status\nOutput: Show working tree status\n\nInput: npm install\nOutput: Install package dependencies\n\nInput: mkdir foo\nOutput: Create directory 'foo'", required = false) String description,
302                @ToolParam(name = "runInBackground", description = "Set to true to run this command in the background. Use BashOutput to read the output later.", required = false) Boolean runInBackground) { // @formatter:on
303
304        // Generate unique shell ID for all executions
305        String shellId = "shell_" + System.currentTimeMillis();
306
307        try {
308            // Determine the shell to use based on OS
309            String[] shellCommand;
310            String os = System.getProperty("os.name").toLowerCase();
311            if (os.contains("win")) {
312                shellCommand = new String[]{"cmd.exe", "/c", command};
313            } else {
314                shellCommand = new String[]{"/bin/bash", "-c", command};
315            }
316
317            ProcessBuilder processBuilder = new ProcessBuilder(shellCommand);
318            processBuilder.redirectErrorStream(false);
319
320            // Set working directory if available in tool context
321            // processBuilder.directory(new File(workingDirectory));
322
323            Process process = processBuilder.start();
324
325            if (Boolean.TRUE.equals(runInBackground)) {
326                // Run in background
327                BackgroundProcess bgProcess = new BackgroundProcess(process);
328                backgroundProcesses.put(shellId, bgProcess);
329
330                return String.format(
331                    "bash_id: %s\n\nBackground shell started with ID: %s\nUse BashOutput tool with bash_id='%s' to retrieve output.",
332                    shellId, shellId, shellId);
333            } else {
334                // Run synchronously with timeout
335                long timeoutMs = timeout != null ? Math.min(timeout, 600000) : 120000;
336
337                StringBuilder stdout = new StringBuilder();
338                StringBuilder stderr = new StringBuilder();
339
340                // Read stdout
341                Thread stdoutThread = new Thread(() -> {
342                    try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
343                        String line;
344                        while ((line = reader.readLine()) != null) {
345                            stdout.append(line).append("\n");
346                        }
347                    } catch (IOException e) {
348                        // Ignore
349                    }
350                });
351
352                // Read stderr
353                Thread stderrThread = new Thread(() -> {
354                    try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
355                        String line;
356                        while ((line = reader.readLine()) != null) {
357                            stderr.append(line).append("\n");
358                        }
359                    } catch (IOException e) {
360                        // Ignore
361                    }
362                });
363
364                stdoutThread.start();
365                stderrThread.start();
366
367                boolean completed = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS);
368
369                if (!completed) {
370                    process.destroy();
371                    if (!process.waitFor(5, TimeUnit.SECONDS)) {
372                        process.destroyForcibly();
373                    }
374                    return String.format("bash_id: %s\n\nCommand timed out after %dms", shellId, timeoutMs);
375                }
376
377                stdoutThread.join(1000);
378                stderrThread.join(1000);
379
380                int exitCode = process.exitValue();
381                StringBuilder result = new StringBuilder();
382
383                // Add bash_id at the beginning
384                result.append("bash_id: ").append(shellId).append("\n\n");
385
386                if (stdout.length() > 0) {
387                    result.append(stdout.toString());
388                }
389
390                if (stderr.length() > 0) {
391                    if (result.length() > result.indexOf("\n\n") + 2)
392                        result.append("\n");
393                    result.append("STDERR:\n").append(stderr.toString());
394                }
395
396                if (exitCode != 0) {
397                    if (result.length() > result.indexOf("\n\n") + 2)
398                        result.append("\n");
399                    result.append("Exit code: ").append(exitCode);
400                }
401
402                // Truncate if too long
403                String output = result.toString();
404                if (output.length() > 30000) {
405                    // Keep the bash_id header
406                    String header = output.substring(0, output.indexOf("\n\n") + 2);
407                    String content = output.substring(output.indexOf("\n\n") + 2);
408                    output = header + content.substring(0, Math.min(content.length(), 30000 - header.length()))
409                        + "\n... (output truncated)";
410                }
411
412                return output;
413            }
414
415        } catch (IOException e) {
416            return "Error executing command: " + e.getMessage();
417        } catch (InterruptedException e) {
418            Thread.currentThread().interrupt();
419            return "Command execution interrupted: " + e.getMessage();
420        }
421    }
422
423    // @formatter:off
424        @ToolDef(name = "BashOutput", description = "- Retrieves output from a running or completed background bash shell\n" +
425        "- Takes a shell_id parameter identifying the shell\n" +
426        "- Always returns only new output since the last check\n" +
427        "- Returns stdout and stderr output along with shell status\n" +
428        "- Supports optional regex filtering to show only lines matching a pattern\n" +
429        "- Use this tool when you need to monitor or check the output of a long-running shell\n" +
430        "- Shell IDs can be found using the /bashes command")
431        public String bashOutput(
432                @ToolParam(name = "bash_id", description = "The ID of the background shell to retrieve output from") String bash_id,
433                @ToolParam(name = "filter", description = "Optional regular expression to filter the output lines. Only lines matching this regex will be included in the result. Any lines that do not match will no longer be available to read.", required = false) String filter) { // @formatter:on
434
435        BackgroundProcess bgProcess = backgroundProcesses.get(bash_id);
436
437        if (bgProcess == null) {
438            return "Error: No background shell found with ID: " + bash_id;
439        }
440
441        String newOutput = bgProcess.getNewOutput(filter);
442
443        StringBuilder result = new StringBuilder();
444        result.append("Shell ID: ").append(bash_id).append("\n");
445        result.append("Status: ").append(bgProcess.isAlive() ? "Running" : "Completed").append("\n");
446
447        if (!bgProcess.isAlive()) {
448            try {
449                result.append("Exit code: ").append(bgProcess.getExitCode()).append("\n");
450            } catch (IllegalThreadStateException e) {
451                // Process not yet terminated
452            }
453        }
454
455        if (!newOutput.isEmpty()) {
456            result.append("\nNew output:\n").append(newOutput);
457        } else {
458            result.append("\nNo new output since last check.");
459        }
460
461        return result.toString();
462    }
463
464    // @formatter:off
465        @ToolDef(name = "KillShell", description = "- Kills a running background bash shell by its ID\n" +
466        "- Takes a shell_id parameter identifying the shell to kill\n" +
467        "- Returns a success or failure status\n" +
468        "- Use this tool when you need to terminate a long-running shell\n" +
469        "- Shell IDs can be found using the /bashes command")
470        public String killShell(
471                @ToolParam(name = "bash_id", description = "The ID of the background shell to kill") String bash_id) { // @formatter:on
472
473        BackgroundProcess bgProcess = backgroundProcesses.get(bash_id);
474
475        if (bgProcess == null) {
476            return "Error: No background shell found with ID: " + bash_id;
477        }
478
479        if (!bgProcess.isAlive()) {
480            backgroundProcesses.remove(bash_id);
481            return "Shell " + bash_id + " was already terminated. Removed from active shells.";
482        }
483
484        bgProcess.destroy();
485
486        // Wait a bit to confirm termination
487        try {
488            Thread.sleep(500);
489        } catch (InterruptedException e) {
490            Thread.currentThread().interrupt();
491        }
492
493        backgroundProcesses.remove(bash_id);
494
495        return "Successfully killed shell: " + bash_id;
496    }
497
498    public static Builder builder() {
499        return new Builder();
500    }
501
502    public static class Builder {
503        public ShellTools build() {
504            return new ShellTools();
505        }
506    }
507
508}