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}