@theia/ai-terminal 1.76.0-next.7 → 1.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/browser/abstract-shell-execution-tool.d.ts +39 -0
- package/lib/browser/abstract-shell-execution-tool.d.ts.map +1 -0
- package/lib/browser/abstract-shell-execution-tool.js +103 -0
- package/lib/browser/abstract-shell-execution-tool.js.map +1 -0
- package/lib/browser/index.d.ts +1 -0
- package/lib/browser/index.d.ts.map +1 -1
- package/lib/browser/index.js +1 -0
- package/lib/browser/index.js.map +1 -1
- package/lib/browser/predefined-shell-tool.d.ts +51 -0
- package/lib/browser/predefined-shell-tool.d.ts.map +1 -0
- package/lib/browser/predefined-shell-tool.js +77 -0
- package/lib/browser/predefined-shell-tool.js.map +1 -0
- package/lib/browser/predefined-shell-tool.spec.d.ts +2 -0
- package/lib/browser/predefined-shell-tool.spec.d.ts.map +1 -0
- package/lib/browser/predefined-shell-tool.spec.js +197 -0
- package/lib/browser/predefined-shell-tool.spec.js.map +1 -0
- package/lib/browser/shell-execution-tool.d.ts +4 -13
- package/lib/browser/shell-execution-tool.d.ts.map +1 -1
- package/lib/browser/shell-execution-tool.js +7 -91
- package/lib/browser/shell-execution-tool.js.map +1 -1
- package/lib/common/shell-execution-input-parser.spec.js +3 -3
- package/lib/common/shell-execution-input-parser.spec.js.map +1 -1
- package/lib/common/shell-execution-server.d.ts +18 -2
- package/lib/common/shell-execution-server.d.ts.map +1 -1
- package/lib/common/shell-execution-server.js +13 -9
- package/lib/common/shell-execution-server.js.map +1 -1
- package/package.json +9 -9
- package/src/browser/abstract-shell-execution-tool.ts +127 -0
- package/src/browser/index.ts +1 -0
- package/src/browser/predefined-shell-tool.spec.ts +222 -0
- package/src/browser/predefined-shell-tool.ts +100 -0
- package/src/browser/shell-execution-tool.ts +10 -103
- package/src/common/shell-execution-input-parser.spec.ts +3 -3
- package/src/common/shell-execution-server.ts +31 -9
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource GmbH.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
|
18
|
+
let disableJSDOM = enableJSDOM();
|
|
19
|
+
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
|
20
|
+
FrontendApplicationConfigProvider.set({});
|
|
21
|
+
|
|
22
|
+
import { expect } from 'chai';
|
|
23
|
+
import * as sinon from 'sinon';
|
|
24
|
+
import { Container } from '@theia/core/shared/inversify';
|
|
25
|
+
import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
|
|
26
|
+
import URI from '@theia/core/lib/common/uri';
|
|
27
|
+
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
|
28
|
+
import { ShellExecutionRequest, ShellExecutionResult, ShellExecutionServer } from '../common/shell-execution-server';
|
|
29
|
+
import { PREDEFINED_SHELL_TOOL_PROVIDER, PredefinedShellTool } from './predefined-shell-tool';
|
|
30
|
+
|
|
31
|
+
disableJSDOM();
|
|
32
|
+
|
|
33
|
+
class TestPredefinedShellTool extends PredefinedShellTool {
|
|
34
|
+
readonly id = 'test-predefined';
|
|
35
|
+
readonly description = 'A test predefined shell tool that returns hello';
|
|
36
|
+
|
|
37
|
+
protected buildCommand(args: Record<string, unknown>): string {
|
|
38
|
+
const upper = args.upper === true;
|
|
39
|
+
return upper ? 'echo HELLO' : 'echo hello';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
protected resolveWorkspaceRoot(args: Record<string, unknown>): string | undefined {
|
|
43
|
+
return typeof args.cwd === 'string' ? args.cwd : this.firstWorkspaceRoot();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createResult(overrides: Partial<ShellExecutionResult> = {}): ShellExecutionResult {
|
|
48
|
+
return {
|
|
49
|
+
success: true,
|
|
50
|
+
exitCode: 0,
|
|
51
|
+
stdout: 'hello\n',
|
|
52
|
+
stderr: '',
|
|
53
|
+
duration: 5,
|
|
54
|
+
...overrides
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('PredefinedShellTool', () => {
|
|
59
|
+
|
|
60
|
+
let container: Container;
|
|
61
|
+
let shellServer: { execute: sinon.SinonStub; cancel: sinon.SinonStub };
|
|
62
|
+
let tool: TestPredefinedShellTool;
|
|
63
|
+
|
|
64
|
+
before(() => {
|
|
65
|
+
disableJSDOM = enableJSDOM();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
after(() => {
|
|
69
|
+
disableJSDOM();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
container = new Container();
|
|
74
|
+
shellServer = {
|
|
75
|
+
execute: sinon.stub().resolves(createResult()),
|
|
76
|
+
cancel: sinon.stub().resolves(true)
|
|
77
|
+
};
|
|
78
|
+
const workspaceService: Partial<WorkspaceService> = {
|
|
79
|
+
getWorkspaceRootUri: () => new URI('file:///workspace')
|
|
80
|
+
};
|
|
81
|
+
container.bind(ShellExecutionServer).toConstantValue(shellServer as unknown as ShellExecutionServer);
|
|
82
|
+
container.bind(WorkspaceService).toConstantValue(workspaceService as WorkspaceService);
|
|
83
|
+
container.bind(TestPredefinedShellTool).toSelf();
|
|
84
|
+
tool = container.get(TestPredefinedShellTool);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('exposes the subclass id and description on the tool', () => {
|
|
88
|
+
const request = tool.getTool();
|
|
89
|
+
expect(request.id).to.equal('test-predefined');
|
|
90
|
+
expect(request.name).to.equal('test-predefined');
|
|
91
|
+
expect(request.description).to.equal('A test predefined shell tool that returns hello');
|
|
92
|
+
expect(request.providerName).to.equal(PREDEFINED_SHELL_TOOL_PROVIDER);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not declare checkAutoAction so confirmation flows through the normal ToolConfirmationManager', () => {
|
|
96
|
+
const request = tool.getTool();
|
|
97
|
+
expect(request.checkAutoAction).to.be.undefined;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('passes the hardcoded command, workspace root and a generated execution id to ShellExecutionServer', async () => {
|
|
101
|
+
const request = tool.getTool();
|
|
102
|
+
await request.handler('{}');
|
|
103
|
+
expect(shellServer.execute.calledOnce).to.be.true;
|
|
104
|
+
const arg = shellServer.execute.firstCall.args[0] as ShellExecutionRequest;
|
|
105
|
+
expect(arg.command).to.equal('echo hello');
|
|
106
|
+
// URI.fsPath() uses the platform-native separator, so the cwd comes back as
|
|
107
|
+
// `\workspace` on Windows. Normalize before asserting to keep the test platform-agnostic.
|
|
108
|
+
expect(arg.cwd?.replace(/\\/g, '/')).to.equal('/workspace');
|
|
109
|
+
expect(arg.timeout).to.equal(30_000);
|
|
110
|
+
expect(typeof arg.executionId).to.equal('string');
|
|
111
|
+
expect(arg.executionId!.length).to.be.greaterThan(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('forwards typed args to buildCommand', async () => {
|
|
115
|
+
const request = tool.getTool();
|
|
116
|
+
await request.handler(JSON.stringify({ upper: true }));
|
|
117
|
+
const arg = shellServer.execute.firstCall.args[0] as ShellExecutionRequest;
|
|
118
|
+
expect(arg.command).to.equal('echo HELLO');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('forwards the same typed args to resolveWorkspaceRoot, so the cwd can depend on them', async () => {
|
|
122
|
+
await tool.getTool().handler(JSON.stringify({ cwd: '/elsewhere' }));
|
|
123
|
+
const arg = shellServer.execute.firstCall.args[0] as ShellExecutionRequest;
|
|
124
|
+
expect(arg.cwd).to.equal('/elsewhere');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('handles an empty argument string as an empty args object', async () => {
|
|
128
|
+
const request = tool.getTool();
|
|
129
|
+
await request.handler('');
|
|
130
|
+
const arg = shellServer.execute.firstCall.args[0] as ShellExecutionRequest;
|
|
131
|
+
expect(arg.command).to.equal('echo hello');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns a formatted result with combined output', async () => {
|
|
135
|
+
shellServer.execute.resolves(createResult({ stdout: 'out\n', stderr: 'err\n' }));
|
|
136
|
+
const request = tool.getTool();
|
|
137
|
+
const raw = await request.handler('{}');
|
|
138
|
+
expect(raw).to.deep.include({
|
|
139
|
+
success: true,
|
|
140
|
+
exitCode: 0
|
|
141
|
+
});
|
|
142
|
+
const result = raw as { output: string };
|
|
143
|
+
expect(result.output).to.contain('out');
|
|
144
|
+
expect(result.output).to.contain('err');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns a canceled result when the underlying execution is canceled', async () => {
|
|
148
|
+
shellServer.execute.resolves(createResult({ canceled: true, stdout: 'partial\n', stderr: '' }));
|
|
149
|
+
const request = tool.getTool();
|
|
150
|
+
const result = await request.handler('{}') as { canceled: true; output?: string };
|
|
151
|
+
expect(result.canceled).to.be.true;
|
|
152
|
+
expect(result.output).to.contain('partial');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('wires the invocation cancellation token to ShellExecutionServer.cancel', async () => {
|
|
156
|
+
const cts = new CancellationTokenSource();
|
|
157
|
+
const request = tool.getTool();
|
|
158
|
+
|
|
159
|
+
let capturedExecutionId: string | undefined;
|
|
160
|
+
shellServer.execute.callsFake(async (req: ShellExecutionRequest) => {
|
|
161
|
+
capturedExecutionId = req.executionId;
|
|
162
|
+
return new Promise<ShellExecutionResult>(resolve => {
|
|
163
|
+
cts.token.onCancellationRequested(() => resolve(createResult({ canceled: true, stdout: '', stderr: '' })));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const handlerPromise = request.handler('{}', { toolCallId: 'tc-1', cancellationToken: cts.token });
|
|
168
|
+
cts.cancel();
|
|
169
|
+
await handlerPromise;
|
|
170
|
+
|
|
171
|
+
expect(shellServer.cancel.calledOnce).to.be.true;
|
|
172
|
+
expect(shellServer.cancel.firstCall.args[0]).to.equal(capturedExecutionId);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('tracks the execution by tool call id so it can be cancelled by id', async () => {
|
|
176
|
+
let capturedExecutionId: string | undefined;
|
|
177
|
+
let release: (() => void) | undefined;
|
|
178
|
+
shellServer.execute.callsFake(async (req: ShellExecutionRequest) => {
|
|
179
|
+
capturedExecutionId = req.executionId;
|
|
180
|
+
return new Promise<ShellExecutionResult>(resolve => {
|
|
181
|
+
release = () => resolve(createResult());
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const handlerPromise = tool.getTool().handler('{}', { toolCallId: 'tc-1' });
|
|
186
|
+
expect(tool.isExecutionRunning('tc-1')).to.be.true;
|
|
187
|
+
|
|
188
|
+
expect(await tool.cancelExecution('tc-1')).to.be.true;
|
|
189
|
+
expect(shellServer.cancel.firstCall.args[0]).to.equal(capturedExecutionId);
|
|
190
|
+
|
|
191
|
+
release!();
|
|
192
|
+
await handlerPromise;
|
|
193
|
+
expect(tool.isExecutionRunning('tc-1')).to.be.false;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('applies the default truncation budget, keeping only the first and last 50 lines', async () => {
|
|
197
|
+
const stdout = Array.from({ length: 500 }, (_unused, index) => `line ${index}`).join('\n');
|
|
198
|
+
shellServer.execute.resolves(createResult({ stdout }));
|
|
199
|
+
|
|
200
|
+
const result = await tool.getTool().handler('{}') as { output: string };
|
|
201
|
+
|
|
202
|
+
expect(result.output).to.contain('line 0');
|
|
203
|
+
expect(result.output).to.contain('line 499');
|
|
204
|
+
expect(result.output).to.not.contain('line 250');
|
|
205
|
+
expect(result.output).to.contain('400 lines omitted');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('honours a raised truncation budget declared by the subclass', async () => {
|
|
209
|
+
class GenerousTool extends TestPredefinedShellTool {
|
|
210
|
+
protected override readonly truncation = { headLines: 400, tailLines: 100 };
|
|
211
|
+
}
|
|
212
|
+
container.bind(GenerousTool).toSelf();
|
|
213
|
+
const generous = container.get(GenerousTool);
|
|
214
|
+
const stdout = Array.from({ length: 500 }, (_unused, index) => `line ${index}`).join('\n');
|
|
215
|
+
shellServer.execute.resolves(createResult({ stdout }));
|
|
216
|
+
|
|
217
|
+
const result = await generous.getTool().handler('{}') as { output: string };
|
|
218
|
+
|
|
219
|
+
expect(result.output).to.contain('line 250');
|
|
220
|
+
expect(result.output).to.not.contain('lines omitted');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource GmbH.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { injectable } from '@theia/core/shared/inversify';
|
|
18
|
+
import { ToolInvocationContext, ToolRequest, ToolRequestParameters } from '@theia/ai-core';
|
|
19
|
+
import { AbstractShellExecutionTool } from './abstract-shell-execution-tool';
|
|
20
|
+
import { OutputTruncationOptions, ShellExecutionCanceledResult, ShellExecutionToolResult } from '../common/shell-execution-server';
|
|
21
|
+
|
|
22
|
+
/** `providerName` reported by tools that derive from {@link PredefinedShellTool}. */
|
|
23
|
+
export const PREDEFINED_SHELL_TOOL_PROVIDER = 'ai-predefined-shell';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Base class for tools that execute a fixed, hardcoded shell command. A subclass declares its
|
|
27
|
+
* tool `id`, its parameters and a `buildCommand(args)` method that assembles the command from
|
|
28
|
+
* typed arguments. The LLM only supplies the typed arguments, so the subclass stays in control
|
|
29
|
+
* of the command.
|
|
30
|
+
*
|
|
31
|
+
* Unlike the general-purpose `shellExecute` tool, a {@link PredefinedShellTool} does not consult
|
|
32
|
+
* `ShellCommandPermissionService` and does not appear on the user's shell allow/deny lists. The
|
|
33
|
+
* safety boundary is the subclass `buildCommand`, which must not be coercible into running
|
|
34
|
+
* arbitrary commands.
|
|
35
|
+
*/
|
|
36
|
+
@injectable()
|
|
37
|
+
export abstract class PredefinedShellTool extends AbstractShellExecutionTool {
|
|
38
|
+
|
|
39
|
+
/** The unique identifier of this tool. Must not be `shellExecute`. */
|
|
40
|
+
abstract readonly id: string;
|
|
41
|
+
|
|
42
|
+
/** Human-readable description of this tool, shown to the LLM. */
|
|
43
|
+
abstract readonly description: string;
|
|
44
|
+
|
|
45
|
+
protected readonly parameters: ToolRequestParameters = { type: 'object', properties: {} };
|
|
46
|
+
|
|
47
|
+
/** Timeout for the underlying shell execution, in milliseconds. */
|
|
48
|
+
protected readonly timeout: number = 30_000;
|
|
49
|
+
|
|
50
|
+
/** Output truncation budget. `undefined` keeps the `shellExecute` defaults (first/last 50 lines). */
|
|
51
|
+
protected readonly truncation: OutputTruncationOptions | undefined = undefined;
|
|
52
|
+
|
|
53
|
+
/** Build the exact shell command to execute from the parsed arguments. */
|
|
54
|
+
protected abstract buildCommand(args: Record<string, unknown>): string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolves the working directory for the shell command, given the same parsed arguments that
|
|
58
|
+
* {@link buildCommand} receives, so a tool can let the caller pick the folder it runs in.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately abstract: the cwd materially affects most predefined commands (anything
|
|
61
|
+
* talking to a specific repository, build script, or per-folder tool), and silently
|
|
62
|
+
* defaulting to an arbitrary workspace root produces results that look plausible but describe
|
|
63
|
+
* the wrong folder.
|
|
64
|
+
*
|
|
65
|
+
* Subclasses that genuinely do not care about the cwd can return
|
|
66
|
+
* {@link firstWorkspaceRoot} or `undefined`.
|
|
67
|
+
*/
|
|
68
|
+
protected abstract resolveWorkspaceRoot(args: Record<string, unknown>): string | undefined;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The **first** workspace root, or `undefined` when no workspace is open. Only a sensible
|
|
72
|
+
* `resolveWorkspaceRoot` implementation for commands that produce the same output regardless
|
|
73
|
+
* of cwd. In a multi-root workspace the first root is arbitrary.
|
|
74
|
+
*/
|
|
75
|
+
protected firstWorkspaceRoot(): string | undefined {
|
|
76
|
+
return this.workspaceService.getWorkspaceRootUri(undefined)?.path.fsPath();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getTool(): ToolRequest {
|
|
80
|
+
return {
|
|
81
|
+
id: this.id,
|
|
82
|
+
name: this.id,
|
|
83
|
+
providerName: PREDEFINED_SHELL_TOOL_PROVIDER,
|
|
84
|
+
description: this.description,
|
|
85
|
+
parameters: this.parameters,
|
|
86
|
+
handler: (argString: string, ctx?: ToolInvocationContext) => this.execute(argString, ctx)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
protected async execute(argString: string, ctx?: ToolInvocationContext): Promise<ShellExecutionToolResult | ShellExecutionCanceledResult> {
|
|
91
|
+
const args: Record<string, unknown> = argString ? JSON.parse(argString) : {};
|
|
92
|
+
return this.runShellCommand({
|
|
93
|
+
command: this.buildCommand(args),
|
|
94
|
+
cwd: this.resolveWorkspaceRoot(args),
|
|
95
|
+
timeout: this.timeout,
|
|
96
|
+
ctx,
|
|
97
|
+
truncation: this.truncation
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -15,48 +15,22 @@
|
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
17
|
import { injectable, inject } from '@theia/core/shared/inversify';
|
|
18
|
-
import {
|
|
18
|
+
import { ToolRequest, AutoActionResult } from '@theia/ai-core';
|
|
19
19
|
import { ShellCommandPermissionService } from './shell-command-permission-service';
|
|
20
|
-
import {
|
|
20
|
+
import { AbstractShellExecutionTool } from './abstract-shell-execution-tool';
|
|
21
21
|
import {
|
|
22
22
|
SHELL_EXECUTION_FUNCTION_ID,
|
|
23
|
-
ShellExecutionServer,
|
|
24
23
|
ShellExecutionToolResult,
|
|
25
|
-
ShellExecutionCanceledResult
|
|
26
|
-
combineAndTruncate
|
|
24
|
+
ShellExecutionCanceledResult
|
|
27
25
|
} from '../common/shell-execution-server';
|
|
28
|
-
import {
|
|
26
|
+
import { Path } from '@theia/core';
|
|
29
27
|
|
|
30
28
|
@injectable()
|
|
31
|
-
export class ShellExecutionTool
|
|
32
|
-
|
|
33
|
-
@inject(ShellExecutionServer)
|
|
34
|
-
protected readonly shellServer: ShellExecutionServer;
|
|
35
|
-
|
|
36
|
-
@inject(WorkspaceService)
|
|
37
|
-
protected readonly workspaceService: WorkspaceService;
|
|
29
|
+
export class ShellExecutionTool extends AbstractShellExecutionTool {
|
|
38
30
|
|
|
39
31
|
@inject(ShellCommandPermissionService)
|
|
40
32
|
protected readonly shellCommandPermissionService: ShellCommandPermissionService;
|
|
41
33
|
|
|
42
|
-
protected readonly runningExecutions = new Map<string, string>();
|
|
43
|
-
|
|
44
|
-
async cancelExecution(toolCallId: string): Promise<boolean> {
|
|
45
|
-
const executionId = this.runningExecutions.get(toolCallId);
|
|
46
|
-
if (executionId) {
|
|
47
|
-
const canceled = await this.shellServer.cancel(executionId);
|
|
48
|
-
if (canceled) {
|
|
49
|
-
this.runningExecutions.delete(toolCallId);
|
|
50
|
-
}
|
|
51
|
-
return canceled;
|
|
52
|
-
}
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
isExecutionRunning(toolCallId: string): boolean {
|
|
57
|
-
return this.runningExecutions.has(toolCallId);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
34
|
getTool(): ToolRequest {
|
|
61
35
|
return {
|
|
62
36
|
id: SHELL_EXECUTION_FUNCTION_ID,
|
|
@@ -171,56 +145,12 @@ TIMEOUT: Default 2 minutes, max 10 minutes. Specify higher timeout for longer co
|
|
|
171
145
|
timeout?: number;
|
|
172
146
|
} = JSON.parse(argString);
|
|
173
147
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const cancellationToken = this.extractCancellationToken(ctx);
|
|
180
|
-
|
|
181
|
-
// Track this execution
|
|
182
|
-
if (toolCallId) {
|
|
183
|
-
this.runningExecutions.set(toolCallId, executionId);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const cancellationListener = cancellationToken?.onCancellationRequested(() => {
|
|
187
|
-
this.shellServer.cancel(executionId);
|
|
148
|
+
return this.runShellCommand({
|
|
149
|
+
command: args.command,
|
|
150
|
+
cwd: this.resolveCwd(args.cwd),
|
|
151
|
+
timeout: args.timeout,
|
|
152
|
+
ctx
|
|
188
153
|
});
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const result = await this.shellServer.execute({
|
|
192
|
-
command: args.command,
|
|
193
|
-
cwd: resolvedCwd,
|
|
194
|
-
timeout: args.timeout,
|
|
195
|
-
executionId,
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
if (result.canceled) {
|
|
199
|
-
return {
|
|
200
|
-
canceled: true,
|
|
201
|
-
output: this.combineAndTruncate(result.stdout, result.stderr) || undefined,
|
|
202
|
-
duration: result.duration,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Combine stdout and stderr, apply truncation
|
|
207
|
-
const combinedOutput = this.combineAndTruncate(result.stdout, result.stderr);
|
|
208
|
-
|
|
209
|
-
return {
|
|
210
|
-
success: result.success,
|
|
211
|
-
exitCode: result.exitCode,
|
|
212
|
-
output: combinedOutput,
|
|
213
|
-
error: result.error,
|
|
214
|
-
duration: result.duration,
|
|
215
|
-
cwd: result.resolvedCwd,
|
|
216
|
-
};
|
|
217
|
-
} finally {
|
|
218
|
-
// Clean up
|
|
219
|
-
cancellationListener?.dispose();
|
|
220
|
-
if (toolCallId) {
|
|
221
|
-
this.runningExecutions.delete(toolCallId);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
154
|
}
|
|
225
155
|
|
|
226
156
|
/**
|
|
@@ -269,27 +199,4 @@ TIMEOUT: Default 2 minutes, max 10 minutes. Specify higher timeout for longer co
|
|
|
269
199
|
);
|
|
270
200
|
}
|
|
271
201
|
|
|
272
|
-
protected extractToolCallId(ctx?: unknown): string | undefined {
|
|
273
|
-
if (ctx && typeof ctx === 'object' && 'toolCallId' in ctx) {
|
|
274
|
-
return (ctx as { toolCallId?: string }).toolCallId;
|
|
275
|
-
}
|
|
276
|
-
return undefined;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
protected extractCancellationToken(ctx: unknown): CancellationToken | undefined {
|
|
280
|
-
if (ctx && typeof ctx === 'object') {
|
|
281
|
-
// Check for MutableChatRequestModel structure (response.cancellationToken)
|
|
282
|
-
if ('response' in ctx) {
|
|
283
|
-
const response = (ctx as { response?: { cancellationToken?: CancellationToken } }).response;
|
|
284
|
-
if (response?.cancellationToken) {
|
|
285
|
-
return response.cancellationToken;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return undefined;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
protected combineAndTruncate(stdout: string, stderr: string): string {
|
|
293
|
-
return combineAndTruncate(stdout, stderr);
|
|
294
|
-
}
|
|
295
202
|
}
|
|
@@ -105,10 +105,10 @@ describe('parseShellExecutionInput', () => {
|
|
|
105
105
|
expect(result.command).to.equal('echo "hello"');
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
it('should
|
|
109
|
-
// During streaming, we get partial content - the
|
|
108
|
+
it('should unescape a quote in an incomplete command', () => {
|
|
109
|
+
// During streaming, we get partial content - the escaped quote is part of the command, not its end
|
|
110
110
|
const result = parseShellExecutionInput('{"command": "echo \\"hello');
|
|
111
|
-
expect(result.command).to.equal('echo
|
|
111
|
+
expect(result.command).to.equal('echo "hello');
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
it('should handle command with pipes and redirects', () => {
|
|
@@ -82,16 +82,38 @@ export const TAIL_LINES = 50;
|
|
|
82
82
|
export const GRACE_LINES = 10;
|
|
83
83
|
export const MAX_LINE_LENGTH = 1000;
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Line and line-length budget applied to combined command output. The defaults suit the
|
|
87
|
+
* general-purpose `shellExecute` tool, where output is often a long build or test log and only
|
|
88
|
+
* the beginning and the end carry information. Tools whose output is meaningful as a whole
|
|
89
|
+
* (e.g. a diff) should raise these limits.
|
|
90
|
+
*/
|
|
91
|
+
export interface OutputTruncationOptions {
|
|
92
|
+
/** Number of leading lines to keep. Default {@link HEAD_LINES}. */
|
|
93
|
+
headLines?: number;
|
|
94
|
+
/** Number of trailing lines to keep. Default {@link TAIL_LINES}. */
|
|
95
|
+
tailLines?: number;
|
|
96
|
+
/** Extra lines tolerated before truncation kicks in at all. Default {@link GRACE_LINES}. */
|
|
97
|
+
graceLines?: number;
|
|
98
|
+
/** Maximum length of a single line before its middle is elided. Default {@link MAX_LINE_LENGTH}. */
|
|
99
|
+
maxLineLength?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function truncateLine(line: string, maxLineLength: number = MAX_LINE_LENGTH): string {
|
|
103
|
+
if (line.length <= maxLineLength) {
|
|
87
104
|
return line;
|
|
88
105
|
}
|
|
89
|
-
const halfLength = Math.floor((
|
|
106
|
+
const halfLength = Math.floor((maxLineLength - 30) / 2);
|
|
90
107
|
const omittedCount = line.length - halfLength * 2;
|
|
91
108
|
return `${line.slice(0, halfLength)} ... [${omittedCount} chars omitted] ... ${line.slice(-halfLength)}`;
|
|
92
109
|
}
|
|
93
110
|
|
|
94
|
-
export function combineAndTruncate(stdout: string, stderr: string): string {
|
|
111
|
+
export function combineAndTruncate(stdout: string, stderr: string, options?: OutputTruncationOptions): string {
|
|
112
|
+
const head = options?.headLines ?? HEAD_LINES;
|
|
113
|
+
const tail = options?.tailLines ?? TAIL_LINES;
|
|
114
|
+
const grace = options?.graceLines ?? GRACE_LINES;
|
|
115
|
+
const maxLineLength = options?.maxLineLength ?? MAX_LINE_LENGTH;
|
|
116
|
+
|
|
95
117
|
const trimmedStdout = stdout.trim();
|
|
96
118
|
const trimmedStderr = stderr.trim();
|
|
97
119
|
|
|
@@ -108,13 +130,13 @@ export function combineAndTruncate(stdout: string, stderr: string): string {
|
|
|
108
130
|
|
|
109
131
|
const lines = output.split('\n');
|
|
110
132
|
|
|
111
|
-
if (lines.length <=
|
|
112
|
-
return lines.map(truncateLine).join('\n');
|
|
133
|
+
if (lines.length <= head + tail + grace) {
|
|
134
|
+
return lines.map(line => truncateLine(line, maxLineLength)).join('\n');
|
|
113
135
|
}
|
|
114
136
|
|
|
115
|
-
const headLines = lines.slice(0,
|
|
116
|
-
const tailLines = lines.slice(-
|
|
117
|
-
const omittedCount = lines.length -
|
|
137
|
+
const headLines = lines.slice(0, head).map(line => truncateLine(line, maxLineLength));
|
|
138
|
+
const tailLines = lines.slice(-tail).map(line => truncateLine(line, maxLineLength));
|
|
139
|
+
const omittedCount = lines.length - head - tail;
|
|
118
140
|
|
|
119
141
|
return [...headLines, `\n... [${omittedCount} lines omitted] ...\n`, ...tailLines].join('\n');
|
|
120
142
|
}
|