mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
fix: add explicit is_background param for shell tool (#445)
* fix: add explicit background param for shell tool * fix: explicit param schema * docs(shelltool): update `is_background` description
This commit is contained in:
@@ -13,10 +13,39 @@ Use `run_shell_command` to interact with the underlying system, run scripts, or
|
|||||||
- `command` (string, required): The exact shell command to execute.
|
- `command` (string, required): The exact shell command to execute.
|
||||||
- `description` (string, optional): A brief description of the command's purpose, which will be shown to the user.
|
- `description` (string, optional): A brief description of the command's purpose, which will be shown to the user.
|
||||||
- `directory` (string, optional): The directory (relative to the project root) in which to execute the command. If not provided, the command runs in the project root.
|
- `directory` (string, optional): The directory (relative to the project root) in which to execute the command. If not provided, the command runs in the project root.
|
||||||
|
- `is_background` (boolean, required): Whether to run the command in background. This parameter is required to ensure explicit decision-making about command execution mode. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands. Set to false for one-time commands that should complete before proceeding.
|
||||||
|
|
||||||
## How to use `run_shell_command` with Qwen Code
|
## How to use `run_shell_command` with Qwen Code
|
||||||
|
|
||||||
When using `run_shell_command`, the command is executed as a subprocess. `run_shell_command` can start background processes using `&`. The tool returns detailed information about the execution, including:
|
When using `run_shell_command`, the command is executed as a subprocess. You can control whether commands run in background or foreground using the `is_background` parameter, or by explicitly adding `&` to commands. The tool returns detailed information about the execution, including:
|
||||||
|
|
||||||
|
### Required Background Parameter
|
||||||
|
|
||||||
|
The `is_background` parameter is **required** for all command executions. This design ensures that the LLM (and users) must explicitly decide whether each command should run in the background or foreground, promoting intentional and predictable command execution behavior. By making this parameter mandatory, we avoid unintended fallback to foreground execution, which could block subsequent operations when dealing with long-running processes.
|
||||||
|
|
||||||
|
### Background vs Foreground Execution
|
||||||
|
|
||||||
|
The tool intelligently handles background and foreground execution based on your explicit choice:
|
||||||
|
|
||||||
|
**Use background execution (`is_background: true`) for:**
|
||||||
|
|
||||||
|
- Long-running development servers: `npm run start`, `npm run dev`, `yarn dev`
|
||||||
|
- Build watchers: `npm run watch`, `webpack --watch`
|
||||||
|
- Database servers: `mongod`, `mysql`, `redis-server`
|
||||||
|
- Web servers: `python -m http.server`, `php -S localhost:8000`
|
||||||
|
- Any command expected to run indefinitely until manually stopped
|
||||||
|
|
||||||
|
**Use foreground execution (`is_background: false`) for:**
|
||||||
|
|
||||||
|
- One-time commands: `ls`, `cat`, `grep`
|
||||||
|
- Build commands: `npm run build`, `make`
|
||||||
|
- Installation commands: `npm install`, `pip install`
|
||||||
|
- Git operations: `git commit`, `git push`
|
||||||
|
- Test runs: `npm test`, `pytest`
|
||||||
|
|
||||||
|
### Execution Information
|
||||||
|
|
||||||
|
The tool returns detailed information about the execution, including:
|
||||||
|
|
||||||
- `Command`: The command that was executed.
|
- `Command`: The command that was executed.
|
||||||
- `Directory`: The directory where the command was run.
|
- `Directory`: The directory where the command was run.
|
||||||
@@ -29,28 +58,48 @@ When using `run_shell_command`, the command is executed as a subprocess. `run_sh
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
run_shell_command(command="Your commands.", description="Your description of the command.", directory="Your execution directory.", is_background=false)
|
||||||
```
|
```
|
||||||
run_shell_command(command="Your commands.", description="Your description of the command.", directory="Your execution directory.")
|
|
||||||
```
|
**Note:** The `is_background` parameter is required and must be explicitly specified for every command execution.
|
||||||
|
|
||||||
## `run_shell_command` examples
|
## `run_shell_command` examples
|
||||||
|
|
||||||
List files in the current directory:
|
List files in the current directory:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
run_shell_command(command="ls -la")
|
run_shell_command(command="ls -la", is_background=false)
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a script in a specific directory:
|
Run a script in a specific directory:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
run_shell_command(command="./my_script.sh", directory="scripts", description="Run my custom script")
|
run_shell_command(command="./my_script.sh", directory="scripts", description="Run my custom script", is_background=false)
|
||||||
```
|
```
|
||||||
|
|
||||||
Start a background server:
|
Start a background development server (recommended approach):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
run_shell_command(command="npm run dev", description="Start development server in background", is_background=true)
|
||||||
```
|
```
|
||||||
run_shell_command(command="npm run dev &", description="Start development server in background")
|
|
||||||
|
Start a background server (alternative with explicit &):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
run_shell_command(command="npm run dev &", description="Start development server in background", is_background=false)
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a build command in foreground:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
run_shell_command(command="npm run build", description="Build the project", is_background=false)
|
||||||
|
```
|
||||||
|
|
||||||
|
Start multiple background services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
run_shell_command(command="docker-compose up", description="Start all services", is_background=true)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Important notes
|
## Important notes
|
||||||
@@ -58,7 +107,9 @@ run_shell_command(command="npm run dev &", description="Start development server
|
|||||||
- **Security:** Be cautious when executing commands, especially those constructed from user input, to prevent security vulnerabilities.
|
- **Security:** Be cautious when executing commands, especially those constructed from user input, to prevent security vulnerabilities.
|
||||||
- **Interactive commands:** Avoid commands that require interactive user input, as this can cause the tool to hang. Use non-interactive flags if available (e.g., `npm init -y`).
|
- **Interactive commands:** Avoid commands that require interactive user input, as this can cause the tool to hang. Use non-interactive flags if available (e.g., `npm init -y`).
|
||||||
- **Error handling:** Check the `Stderr`, `Error`, and `Exit Code` fields to determine if a command executed successfully.
|
- **Error handling:** Check the `Stderr`, `Error`, and `Exit Code` fields to determine if a command executed successfully.
|
||||||
- **Background processes:** When a command is run in the background with `&`, the tool will return immediately and the process will continue to run in the background. The `Background PIDs` field will contain the process ID of the background process.
|
- **Background processes:** When `is_background=true` or when a command contains `&`, the tool will return immediately and the process will continue to run in the background. The `Background PIDs` field will contain the process ID of the background process.
|
||||||
|
- **Background execution choices:** The `is_background` parameter is required and provides explicit control over execution mode. You can also add `&` to the command for manual background execution, but the `is_background` parameter must still be specified. The parameter provides clearer intent and automatically handles the background execution setup.
|
||||||
|
- **Command descriptions:** When using `is_background=true`, the command description will include a `[background]` indicator to clearly show the execution mode.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
|||||||
@@ -99,24 +99,47 @@ describe('ShellTool', () => {
|
|||||||
|
|
||||||
describe('build', () => {
|
describe('build', () => {
|
||||||
it('should return an invocation for a valid command', () => {
|
it('should return an invocation for a valid command', () => {
|
||||||
const invocation = shellTool.build({ command: 'ls -l' });
|
const invocation = shellTool.build({
|
||||||
|
command: 'ls -l',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
expect(invocation).toBeDefined();
|
expect(invocation).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for an empty command', () => {
|
it('should throw an error for an empty command', () => {
|
||||||
expect(() => shellTool.build({ command: ' ' })).toThrow(
|
expect(() =>
|
||||||
'Command cannot be empty.',
|
shellTool.build({ command: ' ', is_background: false }),
|
||||||
);
|
).toThrow('Command cannot be empty.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for a non-existent directory', () => {
|
it('should throw an error for a non-existent directory', () => {
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
shellTool.build({ command: 'ls', directory: 'rel/path' }),
|
shellTool.build({
|
||||||
|
command: 'ls',
|
||||||
|
directory: 'rel/path',
|
||||||
|
is_background: false,
|
||||||
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
"Directory 'rel/path' is not a registered workspace directory.",
|
"Directory 'rel/path' is not a registered workspace directory.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should include background indicator in description when is_background is true', () => {
|
||||||
|
const invocation = shellTool.build({
|
||||||
|
command: 'npm start',
|
||||||
|
is_background: true,
|
||||||
|
});
|
||||||
|
expect(invocation.getDescription()).toContain('[background]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include background indicator in description when is_background is false', () => {
|
||||||
|
const invocation = shellTool.build({
|
||||||
|
command: 'npm test',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
|
expect(invocation.getDescription()).not.toContain('[background]');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('execute', () => {
|
describe('execute', () => {
|
||||||
@@ -141,7 +164,10 @@ describe('ShellTool', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('should wrap command on linux and parse pgrep output', async () => {
|
it('should wrap command on linux and parse pgrep output', async () => {
|
||||||
const invocation = shellTool.build({ command: 'my-command &' });
|
const invocation = shellTool.build({
|
||||||
|
command: 'my-command &',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
resolveShellExecution({ pid: 54321 });
|
resolveShellExecution({ pid: 54321 });
|
||||||
|
|
||||||
@@ -162,9 +188,81 @@ describe('ShellTool', () => {
|
|||||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add ampersand to command when is_background is true and command does not end with &', async () => {
|
||||||
|
const invocation = shellTool.build({
|
||||||
|
command: 'npm start',
|
||||||
|
is_background: true,
|
||||||
|
});
|
||||||
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
resolveShellExecution({ pid: 54321 });
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||||
|
const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||||
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||||
|
wrappedCommand,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Function),
|
||||||
|
mockAbortSignal,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add extra ampersand when is_background is true and command already ends with &', async () => {
|
||||||
|
const invocation = shellTool.build({
|
||||||
|
command: 'npm start &',
|
||||||
|
is_background: true,
|
||||||
|
});
|
||||||
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
resolveShellExecution({ pid: 54321 });
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||||
|
const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||||
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||||
|
wrappedCommand,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Function),
|
||||||
|
mockAbortSignal,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add ampersand when is_background is false', async () => {
|
||||||
|
const invocation = shellTool.build({
|
||||||
|
command: 'npm test',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
resolveShellExecution({ pid: 54321 });
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||||
|
const wrappedCommand = `{ npm test; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||||
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||||
|
wrappedCommand,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Function),
|
||||||
|
mockAbortSignal,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not wrap command on windows', async () => {
|
it('should not wrap command on windows', async () => {
|
||||||
vi.mocked(os.platform).mockReturnValue('win32');
|
vi.mocked(os.platform).mockReturnValue('win32');
|
||||||
const invocation = shellTool.build({ command: 'dir' });
|
const invocation = shellTool.build({
|
||||||
|
command: 'dir',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
resolveShellExecution({
|
resolveShellExecution({
|
||||||
rawOutput: Buffer.from(''),
|
rawOutput: Buffer.from(''),
|
||||||
@@ -188,7 +286,10 @@ describe('ShellTool', () => {
|
|||||||
|
|
||||||
it('should format error messages correctly', async () => {
|
it('should format error messages correctly', async () => {
|
||||||
const error = new Error('wrapped command failed');
|
const error = new Error('wrapped command failed');
|
||||||
const invocation = shellTool.build({ command: 'user-command' });
|
const invocation = shellTool.build({
|
||||||
|
command: 'user-command',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
resolveShellExecution({
|
resolveShellExecution({
|
||||||
error,
|
error,
|
||||||
@@ -209,15 +310,19 @@ describe('ShellTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for invalid parameters', () => {
|
it('should throw an error for invalid parameters', () => {
|
||||||
expect(() => shellTool.build({ command: '' })).toThrow(
|
expect(() =>
|
||||||
'Command cannot be empty.',
|
shellTool.build({ command: '', is_background: false }),
|
||||||
);
|
).toThrow('Command cannot be empty.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for invalid directory', () => {
|
it('should throw an error for invalid directory', () => {
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
shellTool.build({ command: 'ls', directory: 'nonexistent' }),
|
shellTool.build({
|
||||||
|
command: 'ls',
|
||||||
|
directory: 'nonexistent',
|
||||||
|
is_background: false,
|
||||||
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
`Directory 'nonexistent' is not a registered workspace directory.`,
|
`Directory 'nonexistent' is not a registered workspace directory.`,
|
||||||
);
|
);
|
||||||
@@ -231,7 +336,10 @@ describe('ShellTool', () => {
|
|||||||
'summarized output',
|
'summarized output',
|
||||||
);
|
);
|
||||||
|
|
||||||
const invocation = shellTool.build({ command: 'ls' });
|
const invocation = shellTool.build({
|
||||||
|
command: 'ls',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
resolveExecutionPromise({
|
resolveExecutionPromise({
|
||||||
output: 'long output',
|
output: 'long output',
|
||||||
@@ -264,7 +372,10 @@ describe('ShellTool', () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(true); // Pretend the file exists
|
vi.mocked(fs.existsSync).mockReturnValue(true); // Pretend the file exists
|
||||||
|
|
||||||
const invocation = shellTool.build({ command: 'a-command' });
|
const invocation = shellTool.build({
|
||||||
|
command: 'a-command',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
await expect(invocation.execute(mockAbortSignal)).rejects.toThrow(error);
|
await expect(invocation.execute(mockAbortSignal)).rejects.toThrow(error);
|
||||||
|
|
||||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||||
@@ -282,7 +393,10 @@ describe('ShellTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throttle text output updates', async () => {
|
it('should throttle text output updates', async () => {
|
||||||
const invocation = shellTool.build({ command: 'stream' });
|
const invocation = shellTool.build({
|
||||||
|
command: 'stream',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
|
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
|
||||||
|
|
||||||
// First chunk, should be throttled.
|
// First chunk, should be throttled.
|
||||||
@@ -322,7 +436,10 @@ describe('ShellTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should immediately show binary detection message and throttle progress', async () => {
|
it('should immediately show binary detection message and throttle progress', async () => {
|
||||||
const invocation = shellTool.build({ command: 'cat img' });
|
const invocation = shellTool.build({
|
||||||
|
command: 'cat img',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
|
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
|
||||||
|
|
||||||
mockShellOutputCallback({ type: 'binary_detected' });
|
mockShellOutputCallback({ type: 'binary_detected' });
|
||||||
@@ -370,7 +487,7 @@ describe('ShellTool', () => {
|
|||||||
describe('addCoAuthorToGitCommit', () => {
|
describe('addCoAuthorToGitCommit', () => {
|
||||||
it('should add co-author to git commit with double quotes', async () => {
|
it('should add co-author to git commit with double quotes', async () => {
|
||||||
const command = 'git commit -m "Initial commit"';
|
const command = 'git commit -m "Initial commit"';
|
||||||
const invocation = shellTool.build({ command });
|
const invocation = shellTool.build({ command, is_background: false });
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
|
||||||
// Mock the shell execution to return success
|
// Mock the shell execution to return success
|
||||||
@@ -401,7 +518,7 @@ describe('ShellTool', () => {
|
|||||||
|
|
||||||
it('should add co-author to git commit with single quotes', async () => {
|
it('should add co-author to git commit with single quotes', async () => {
|
||||||
const command = "git commit -m 'Fix bug'";
|
const command = "git commit -m 'Fix bug'";
|
||||||
const invocation = shellTool.build({ command });
|
const invocation = shellTool.build({ command, is_background: false });
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
|
||||||
resolveExecutionPromise({
|
resolveExecutionPromise({
|
||||||
@@ -430,7 +547,7 @@ describe('ShellTool', () => {
|
|||||||
|
|
||||||
it('should handle git commit with additional flags', async () => {
|
it('should handle git commit with additional flags', async () => {
|
||||||
const command = 'git commit -a -m "Add feature"';
|
const command = 'git commit -a -m "Add feature"';
|
||||||
const invocation = shellTool.build({ command });
|
const invocation = shellTool.build({ command, is_background: false });
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
|
||||||
resolveExecutionPromise({
|
resolveExecutionPromise({
|
||||||
@@ -459,7 +576,7 @@ describe('ShellTool', () => {
|
|||||||
|
|
||||||
it('should not modify non-git commands', async () => {
|
it('should not modify non-git commands', async () => {
|
||||||
const command = 'npm install';
|
const command = 'npm install';
|
||||||
const invocation = shellTool.build({ command });
|
const invocation = shellTool.build({ command, is_background: false });
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
|
||||||
resolveExecutionPromise({
|
resolveExecutionPromise({
|
||||||
@@ -487,7 +604,7 @@ describe('ShellTool', () => {
|
|||||||
|
|
||||||
it('should not modify git commands without -m flag', async () => {
|
it('should not modify git commands without -m flag', async () => {
|
||||||
const command = 'git commit';
|
const command = 'git commit';
|
||||||
const invocation = shellTool.build({ command });
|
const invocation = shellTool.build({ command, is_background: false });
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
|
||||||
resolveExecutionPromise({
|
resolveExecutionPromise({
|
||||||
@@ -515,7 +632,7 @@ describe('ShellTool', () => {
|
|||||||
|
|
||||||
it('should handle git commit with escaped quotes in message', async () => {
|
it('should handle git commit with escaped quotes in message', async () => {
|
||||||
const command = 'git commit -m "Fix \\"quoted\\" text"';
|
const command = 'git commit -m "Fix \\"quoted\\" text"';
|
||||||
const invocation = shellTool.build({ command });
|
const invocation = shellTool.build({ command, is_background: false });
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
|
||||||
resolveExecutionPromise({
|
resolveExecutionPromise({
|
||||||
@@ -551,7 +668,7 @@ describe('ShellTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const command = 'git commit -m "Initial commit"';
|
const command = 'git commit -m "Initial commit"';
|
||||||
const invocation = shellTool.build({ command });
|
const invocation = shellTool.build({ command, is_background: false });
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
|
||||||
resolveExecutionPromise({
|
resolveExecutionPromise({
|
||||||
@@ -586,7 +703,7 @@ describe('ShellTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const command = 'git commit -m "Test commit"';
|
const command = 'git commit -m "Test commit"';
|
||||||
const invocation = shellTool.build({ command });
|
const invocation = shellTool.build({ command, is_background: false });
|
||||||
const promise = invocation.execute(mockAbortSignal);
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
|
||||||
resolveExecutionPromise({
|
resolveExecutionPromise({
|
||||||
@@ -617,7 +734,7 @@ describe('ShellTool', () => {
|
|||||||
|
|
||||||
describe('shouldConfirmExecute', () => {
|
describe('shouldConfirmExecute', () => {
|
||||||
it('should request confirmation for a new command and whitelist it on "Always"', async () => {
|
it('should request confirmation for a new command and whitelist it on "Always"', async () => {
|
||||||
const params = { command: 'npm install' };
|
const params = { command: 'npm install', is_background: false };
|
||||||
const invocation = shellTool.build(params);
|
const invocation = shellTool.build(params);
|
||||||
const confirmation = await invocation.shouldConfirmExecute(
|
const confirmation = await invocation.shouldConfirmExecute(
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
@@ -632,7 +749,10 @@ describe('ShellTool', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Should now be whitelisted
|
// Should now be whitelisted
|
||||||
const secondInvocation = shellTool.build({ command: 'npm test' });
|
const secondInvocation = shellTool.build({
|
||||||
|
command: 'npm test',
|
||||||
|
is_background: false,
|
||||||
|
});
|
||||||
const secondConfirmation = await secondInvocation.shouldConfirmExecute(
|
const secondConfirmation = await secondInvocation.shouldConfirmExecute(
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
);
|
);
|
||||||
@@ -640,7 +760,9 @@ describe('ShellTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if validation fails', () => {
|
it('should throw an error if validation fails', () => {
|
||||||
expect(() => shellTool.build({ command: '' })).toThrow();
|
expect(() =>
|
||||||
|
shellTool.build({ command: '', is_background: false }),
|
||||||
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -658,6 +780,7 @@ describe('validateToolParams', () => {
|
|||||||
const result = shellTool.validateToolParams({
|
const result = shellTool.validateToolParams({
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
directory: 'test',
|
directory: 'test',
|
||||||
|
is_background: false,
|
||||||
});
|
});
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -674,6 +797,7 @@ describe('validateToolParams', () => {
|
|||||||
const result = shellTool.validateToolParams({
|
const result = shellTool.validateToolParams({
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
directory: 'test2',
|
directory: 'test2',
|
||||||
|
is_background: false,
|
||||||
});
|
});
|
||||||
expect(result).toContain('is not a registered workspace directory');
|
expect(result).toContain('is not a registered workspace directory');
|
||||||
});
|
});
|
||||||
@@ -692,6 +816,7 @@ describe('build', () => {
|
|||||||
const invocation = shellTool.build({
|
const invocation = shellTool.build({
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
directory: 'test',
|
directory: 'test',
|
||||||
|
is_background: false,
|
||||||
});
|
});
|
||||||
expect(invocation).toBeDefined();
|
expect(invocation).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -709,6 +834,7 @@ describe('build', () => {
|
|||||||
shellTool.build({
|
shellTool.build({
|
||||||
command: 'ls',
|
command: 'ls',
|
||||||
directory: 'test2',
|
directory: 'test2',
|
||||||
|
is_background: false,
|
||||||
}),
|
}),
|
||||||
).toThrow('is not a registered workspace directory');
|
).toThrow('is not a registered workspace directory');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
|||||||
|
|
||||||
export interface ShellToolParams {
|
export interface ShellToolParams {
|
||||||
command: string;
|
command: string;
|
||||||
|
is_background: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
directory?: string;
|
directory?: string;
|
||||||
}
|
}
|
||||||
@@ -60,6 +61,10 @@ class ShellToolInvocation extends BaseToolInvocation<
|
|||||||
if (this.params.directory) {
|
if (this.params.directory) {
|
||||||
description += ` [in ${this.params.directory}]`;
|
description += ` [in ${this.params.directory}]`;
|
||||||
}
|
}
|
||||||
|
// append background indicator
|
||||||
|
if (this.params.is_background) {
|
||||||
|
description += ` [background]`;
|
||||||
|
}
|
||||||
// append optional (description), replacing any line breaks with spaces
|
// append optional (description), replacing any line breaks with spaces
|
||||||
if (this.params.description) {
|
if (this.params.description) {
|
||||||
description += ` (${this.params.description.replace(/\n/g, ' ')})`;
|
description += ` (${this.params.description.replace(/\n/g, ' ')})`;
|
||||||
@@ -117,12 +122,20 @@ class ShellToolInvocation extends BaseToolInvocation<
|
|||||||
// Add co-author to git commit commands
|
// Add co-author to git commit commands
|
||||||
const processedCommand = this.addCoAuthorToGitCommit(strippedCommand);
|
const processedCommand = this.addCoAuthorToGitCommit(strippedCommand);
|
||||||
|
|
||||||
|
const shouldRunInBackground = this.params.is_background;
|
||||||
|
let finalCommand = processedCommand;
|
||||||
|
|
||||||
|
// If explicitly marked as background and doesn't already end with &, add it
|
||||||
|
if (shouldRunInBackground && !finalCommand.trim().endsWith('&')) {
|
||||||
|
finalCommand = finalCommand.trim() + ' &';
|
||||||
|
}
|
||||||
|
|
||||||
// pgrep is not available on Windows, so we can't get background PIDs
|
// pgrep is not available on Windows, so we can't get background PIDs
|
||||||
const commandToExecute = isWindows
|
const commandToExecute = isWindows
|
||||||
? processedCommand
|
? finalCommand
|
||||||
: (() => {
|
: (() => {
|
||||||
// wrap command to append subprocess pids (via pgrep) to temporary file
|
// wrap command to append subprocess pids (via pgrep) to temporary file
|
||||||
let command = processedCommand.trim();
|
let command = finalCommand.trim();
|
||||||
if (!command.endsWith('&')) command += ';';
|
if (!command.endsWith('&')) command += ';';
|
||||||
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
||||||
})();
|
})();
|
||||||
@@ -343,7 +356,26 @@ export class ShellTool extends BaseDeclarativeTool<
|
|||||||
super(
|
super(
|
||||||
ShellTool.Name,
|
ShellTool.Name,
|
||||||
'Shell',
|
'Shell',
|
||||||
`This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
|
`This tool executes a given shell command as \`bash -c <command>\`.
|
||||||
|
|
||||||
|
**Background vs Foreground Execution:**
|
||||||
|
You should decide whether commands should run in background or foreground based on their nature:
|
||||||
|
|
||||||
|
**Use background execution (is_background: true) for:**
|
||||||
|
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
|
||||||
|
- Build watchers: \`npm run watch\`, \`webpack --watch\`
|
||||||
|
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
|
||||||
|
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
|
||||||
|
- Any command expected to run indefinitely until manually stopped
|
||||||
|
|
||||||
|
**Use foreground execution (is_background: false) for:**
|
||||||
|
- One-time commands: \`ls\`, \`cat\`, \`grep\`
|
||||||
|
- Build commands: \`npm run build\`, \`make\`
|
||||||
|
- Installation commands: \`npm install\`, \`pip install\`
|
||||||
|
- Git operations: \`git commit\`, \`git push\`
|
||||||
|
- Test runs: \`npm test\`, \`pytest\`
|
||||||
|
|
||||||
|
Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
|
||||||
|
|
||||||
The following information is returned:
|
The following information is returned:
|
||||||
|
|
||||||
@@ -364,6 +396,11 @@ export class ShellTool extends BaseDeclarativeTool<
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Exact bash command to execute as `bash -c <command>`',
|
description: 'Exact bash command to execute as `bash -c <command>`',
|
||||||
},
|
},
|
||||||
|
is_background: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
|
||||||
|
},
|
||||||
description: {
|
description: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
@@ -375,7 +412,7 @@ export class ShellTool extends BaseDeclarativeTool<
|
|||||||
'(OPTIONAL) Directory to run the command in, if not the project root directory. Must be relative to the project root directory and must already exist.',
|
'(OPTIONAL) Directory to run the command in, if not the project root directory. Must be relative to the project root directory and must already exist.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['command'],
|
required: ['command', 'is_background'],
|
||||||
},
|
},
|
||||||
false, // output is not markdown
|
false, // output is not markdown
|
||||||
true, // output can be updated
|
true, // output can be updated
|
||||||
|
|||||||
Reference in New Issue
Block a user