@agentscope-ai/agentscope 0.0.2

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.
Files changed (136) hide show
  1. package/dist/agent/index.d.mts +234 -0
  2. package/dist/agent/index.d.ts +234 -0
  3. package/dist/agent/index.js +1412 -0
  4. package/dist/agent/index.js.map +1 -0
  5. package/dist/agent/index.mjs +1375 -0
  6. package/dist/agent/index.mjs.map +1 -0
  7. package/dist/base-BOx3UzOl.d.mts +41 -0
  8. package/dist/base-BoIps2RL.d.ts +41 -0
  9. package/dist/base-C7jwyH4Z.d.mts +52 -0
  10. package/dist/base-Cwi4bjze.d.ts +127 -0
  11. package/dist/base-DYlBMCy_.d.mts +127 -0
  12. package/dist/base-NX-knWOv.d.ts +52 -0
  13. package/dist/block-VsnHrllL.d.mts +48 -0
  14. package/dist/block-VsnHrllL.d.ts +48 -0
  15. package/dist/event/index.d.mts +181 -0
  16. package/dist/event/index.d.ts +181 -0
  17. package/dist/event/index.js +58 -0
  18. package/dist/event/index.js.map +1 -0
  19. package/dist/event/index.mjs +33 -0
  20. package/dist/event/index.mjs.map +1 -0
  21. package/dist/formatter/index.d.mts +187 -0
  22. package/dist/formatter/index.d.ts +187 -0
  23. package/dist/formatter/index.js +647 -0
  24. package/dist/formatter/index.js.map +1 -0
  25. package/dist/formatter/index.mjs +616 -0
  26. package/dist/formatter/index.mjs.map +1 -0
  27. package/dist/index-BTJDlKvQ.d.mts +195 -0
  28. package/dist/index-BcatlwXQ.d.ts +195 -0
  29. package/dist/index-CAxQAkiP.d.mts +21 -0
  30. package/dist/index-CAxQAkiP.d.ts +21 -0
  31. package/dist/mcp/index.d.mts +9 -0
  32. package/dist/mcp/index.d.ts +9 -0
  33. package/dist/mcp/index.js +432 -0
  34. package/dist/mcp/index.js.map +1 -0
  35. package/dist/mcp/index.mjs +408 -0
  36. package/dist/mcp/index.mjs.map +1 -0
  37. package/dist/message/index.d.mts +10 -0
  38. package/dist/message/index.d.ts +10 -0
  39. package/dist/message/index.js +67 -0
  40. package/dist/message/index.js.map +1 -0
  41. package/dist/message/index.mjs +37 -0
  42. package/dist/message/index.mjs.map +1 -0
  43. package/dist/message-CkN21KaY.d.mts +99 -0
  44. package/dist/message-CzLeTlua.d.ts +99 -0
  45. package/dist/model/index.d.mts +377 -0
  46. package/dist/model/index.d.ts +377 -0
  47. package/dist/model/index.js +1880 -0
  48. package/dist/model/index.js.map +1 -0
  49. package/dist/model/index.mjs +1849 -0
  50. package/dist/model/index.mjs.map +1 -0
  51. package/dist/storage/index.d.mts +68 -0
  52. package/dist/storage/index.d.ts +68 -0
  53. package/dist/storage/index.js +250 -0
  54. package/dist/storage/index.js.map +1 -0
  55. package/dist/storage/index.mjs +212 -0
  56. package/dist/storage/index.mjs.map +1 -0
  57. package/dist/tool/index.d.mts +311 -0
  58. package/dist/tool/index.d.ts +311 -0
  59. package/dist/tool/index.js +1494 -0
  60. package/dist/tool/index.js.map +1 -0
  61. package/dist/tool/index.mjs +1447 -0
  62. package/dist/tool/index.mjs.map +1 -0
  63. package/dist/toolkit-CEpulFi0.d.ts +99 -0
  64. package/dist/toolkit-CGEZSZPa.d.mts +99 -0
  65. package/jest.config.js +11 -0
  66. package/package.json +92 -0
  67. package/src/_utils/common.ts +104 -0
  68. package/src/_utils/index.ts +1 -0
  69. package/src/agent/agent-base.ts +0 -0
  70. package/src/agent/agent.test.ts +1028 -0
  71. package/src/agent/agent.ts +1032 -0
  72. package/src/agent/index.ts +2 -0
  73. package/src/agent/interfaces.ts +23 -0
  74. package/src/agent/test-compression.ts +72 -0
  75. package/src/event/index.ts +250 -0
  76. package/src/formatter/base.ts +133 -0
  77. package/src/formatter/dashscope-chat-formatter.test.ts +372 -0
  78. package/src/formatter/dashscope-chat-formatter.ts +163 -0
  79. package/src/formatter/deepseek-chat-formatter.ts +130 -0
  80. package/src/formatter/index.ts +5 -0
  81. package/src/formatter/ollama-chat-formatter.ts +67 -0
  82. package/src/formatter/openai-chat-formatter.test.ts +263 -0
  83. package/src/formatter/openai-chat-formatter.ts +301 -0
  84. package/src/formatter/openai.md +767 -0
  85. package/src/mcp/base.ts +114 -0
  86. package/src/mcp/http.test.ts +303 -0
  87. package/src/mcp/http.ts +224 -0
  88. package/src/mcp/index.ts +2 -0
  89. package/src/mcp/stdio.test.ts +91 -0
  90. package/src/mcp/stdio.ts +119 -0
  91. package/src/message/block.ts +60 -0
  92. package/src/message/enums.ts +4 -0
  93. package/src/message/index.ts +12 -0
  94. package/src/message/message.test.ts +80 -0
  95. package/src/message/message.ts +131 -0
  96. package/src/model/base.ts +226 -0
  97. package/src/model/dashscope-model.test.ts +335 -0
  98. package/src/model/dashscope-model.ts +441 -0
  99. package/src/model/deepseek-model.test.ts +279 -0
  100. package/src/model/deepseek-model.ts +401 -0
  101. package/src/model/index.ts +7 -0
  102. package/src/model/ollama-model.test.ts +307 -0
  103. package/src/model/ollama-model.ts +356 -0
  104. package/src/model/openai-model.ts +327 -0
  105. package/src/model/response.ts +22 -0
  106. package/src/model/usage.ts +12 -0
  107. package/src/storage/base.ts +52 -0
  108. package/src/storage/file-system.test.ts +587 -0
  109. package/src/storage/file-system.ts +269 -0
  110. package/src/storage/index.ts +2 -0
  111. package/src/tool/base.ts +23 -0
  112. package/src/tool/bash.test.ts +174 -0
  113. package/src/tool/bash.ts +152 -0
  114. package/src/tool/edit.test.ts +83 -0
  115. package/src/tool/edit.ts +95 -0
  116. package/src/tool/glob.test.ts +63 -0
  117. package/src/tool/glob.ts +166 -0
  118. package/src/tool/grep.test.ts +74 -0
  119. package/src/tool/grep.ts +256 -0
  120. package/src/tool/index.ts +10 -0
  121. package/src/tool/read.test.ts +77 -0
  122. package/src/tool/read.ts +117 -0
  123. package/src/tool/response.ts +82 -0
  124. package/src/tool/task.test.ts +299 -0
  125. package/src/tool/task.ts +399 -0
  126. package/src/tool/toolkit.test.ts +636 -0
  127. package/src/tool/toolkit.ts +601 -0
  128. package/src/tool/write.test.ts +52 -0
  129. package/src/tool/write.ts +57 -0
  130. package/src/type/index.ts +52 -0
  131. package/tsconfig.build.json +4 -0
  132. package/tsconfig.cjs.json +11 -0
  133. package/tsconfig.esm.json +10 -0
  134. package/tsconfig.json +14 -0
  135. package/tsup.config.ts +20 -0
  136. package/typedoc.json +52 -0
@@ -0,0 +1,269 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import * as mime from 'mime-types';
5
+
6
+ import { Msg } from '../message';
7
+ import { AgentState, StorageBase } from './base';
8
+
9
+ /**
10
+ * Local file system storage implementation.
11
+ * Stores agent state in JSON files with support for incremental context updates.
12
+ */
13
+ export class LocalFileStorage extends StorageBase {
14
+ saveDir: string;
15
+ offloadDir?: string;
16
+
17
+ /**
18
+ * Internal metadata key prefix for storage-layer fields.
19
+ * Fields with this prefix are managed by storage and filtered out when returning to agent layer.
20
+ */
21
+ private readonly INTERNAL_PREFIX = '_storage_';
22
+
23
+ /**
24
+ * Initialize a LocalFileStorage instance.
25
+ * @param root0
26
+ * @param root0.pathSegments - Path segments to determine the directory for saving agent state (e.g. ['rootDir', '{sessionId}'])
27
+ * @param root0.offloadPathSegments - Optional path segments for offloading compressed context for agentic search (e.g. ['rootDir', 'offload'])
28
+ */
29
+ constructor({
30
+ pathSegments = [],
31
+ offloadPathSegments = [],
32
+ }: {
33
+ pathSegments?: string[];
34
+ offloadPathSegments?: string[];
35
+ }) {
36
+ super();
37
+ this.saveDir = path.join(...pathSegments);
38
+ this.offloadDir =
39
+ offloadPathSegments.length > 0 ? path.join(...offloadPathSegments) : undefined;
40
+ }
41
+
42
+ /**
43
+ * Load the complete agent state including context and metadata.
44
+ * @param options
45
+ * @param options.agentId - The agent identifier
46
+ * @returns The agent state with context and metadata (internal fields filtered out)
47
+ */
48
+ async loadAgentState(options?: { agentId?: string }): Promise<AgentState> {
49
+ const agentDir = path.join(this.saveDir, options?.agentId || '');
50
+
51
+ // If the agent directory doesn't exist, return empty state
52
+ if (!fs.existsSync(agentDir)) {
53
+ console.log(`Agent directory ${agentDir} does not exist. Returning empty state.`);
54
+ return {
55
+ context: [],
56
+ metadata: {},
57
+ };
58
+ }
59
+ console.log(`Loading agent state from directory: ${agentDir}`);
60
+
61
+ const contextFile = path.join(agentDir, 'context.jsonl');
62
+ const stateFile = path.join(agentDir, 'state.json');
63
+
64
+ // Load metadata
65
+ let metadata: Record<string, unknown> = {};
66
+ if (fs.existsSync(stateFile)) {
67
+ const content = fs.readFileSync(stateFile, 'utf-8');
68
+ metadata = JSON.parse(content);
69
+ }
70
+
71
+ // Extract internal compression boundary ID
72
+ const compressionBoundaryMsgId = metadata[
73
+ `${this.INTERNAL_PREFIX}compressionBoundaryMsgId`
74
+ ] as string | undefined;
75
+
76
+ // Load context (incrementally if compression boundary exists)
77
+ let context: Msg[] = [];
78
+ if (fs.existsSync(contextFile)) {
79
+ const content = fs.readFileSync(contextFile, 'utf-8');
80
+ const allMsgs = content
81
+ .trim()
82
+ .split('\n')
83
+ .filter(line => line.length > 0)
84
+ .map(line => JSON.parse(line));
85
+
86
+ if (compressionBoundaryMsgId) {
87
+ // Load only messages after the compression boundary
88
+ const boundaryIndex = allMsgs.findIndex(msg => msg.id === compressionBoundaryMsgId);
89
+ if (boundaryIndex !== -1) {
90
+ // Include the boundary message itself
91
+ context = allMsgs.slice(boundaryIndex);
92
+ } else {
93
+ // Boundary not found, load all messages
94
+ context = allMsgs;
95
+ }
96
+ } else {
97
+ // No compression, load all messages
98
+ context = allMsgs;
99
+ }
100
+ }
101
+
102
+ // Filter out internal fields from metadata before returning
103
+ const publicMetadata = this._filterInternalFields(metadata);
104
+
105
+ return {
106
+ context,
107
+ metadata: publicMetadata,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Save the complete agent state including context and metadata.
113
+ * @param options
114
+ * @param options.agentId - The agent identifier
115
+ * @param options.context - The conversation context to save
116
+ * @param options.metadata - The agent metadata to save
117
+ */
118
+ async saveAgentState(options: {
119
+ agentId?: string;
120
+ context: Msg[];
121
+ metadata: Record<string, unknown>;
122
+ }): Promise<void> {
123
+ const agentDir = path.join(this.saveDir, options.agentId || '');
124
+ const contextFile = path.join(agentDir, 'context.jsonl');
125
+ const stateFile = path.join(agentDir, 'state.json');
126
+
127
+ // Ensure directory exists
128
+ if (!fs.existsSync(agentDir)) {
129
+ fs.mkdirSync(agentDir, { recursive: true });
130
+ }
131
+
132
+ // Determine compression boundary (first message in current context)
133
+ const compressionBoundaryMsgId = options.context[0]?.id;
134
+
135
+ // Save context with incremental append optimization
136
+ if (!fs.existsSync(contextFile)) {
137
+ // First time: write all messages
138
+ const content = options.context.map(msg => JSON.stringify(msg)).join('\n');
139
+ if (content) {
140
+ fs.writeFileSync(contextFile, content + '\n', 'utf-8');
141
+ }
142
+ } else {
143
+ // File exists: append only new messages
144
+ const existingContent = fs.readFileSync(contextFile, 'utf-8');
145
+ const existingLines = existingContent
146
+ .trim()
147
+ .split('\n')
148
+ .filter(line => line.length > 0);
149
+
150
+ if (existingLines.length > 0) {
151
+ const lastLine = existingLines[existingLines.length - 1];
152
+ const lastMsg = JSON.parse(lastLine);
153
+
154
+ // Find new messages that need to be saved (including the last saved message to overwrite it)
155
+ const lastMsgIndex = options.context.findIndex(msg => msg.id === lastMsg.id);
156
+ const newMsgs =
157
+ lastMsgIndex >= 0 ? options.context.slice(lastMsgIndex) : options.context;
158
+
159
+ if (newMsgs.length > 0) {
160
+ // Combine existing messages (without last line) with new messages
161
+ const allLines = [
162
+ ...existingLines.slice(0, -1),
163
+ ...newMsgs.map(msg => JSON.stringify(msg)),
164
+ ];
165
+ const content = allLines.join('\n') + '\n';
166
+ fs.writeFileSync(contextFile, content, 'utf-8');
167
+ }
168
+ } else {
169
+ // File is empty, write all messages
170
+ const content = options.context.map(msg => JSON.stringify(msg)).join('\n');
171
+ if (content) {
172
+ fs.writeFileSync(contextFile, content + '\n', 'utf-8');
173
+ }
174
+ }
175
+ }
176
+
177
+ // Save metadata with internal compression boundary
178
+ const internalMetadata = {
179
+ ...options.metadata,
180
+ [`${this.INTERNAL_PREFIX}compressionBoundaryMsgId`]: compressionBoundaryMsgId,
181
+ };
182
+ fs.writeFileSync(stateFile, JSON.stringify(internalMetadata, null, 2), 'utf-8');
183
+ }
184
+
185
+ /**
186
+ * Filter out internal storage fields from metadata.
187
+ * @param metadata - The metadata object
188
+ * @returns Metadata with internal fields removed
189
+ */
190
+ private _filterInternalFields(metadata: Record<string, unknown>): Record<string, unknown> {
191
+ const filtered: Record<string, unknown> = {};
192
+ for (const [key, value] of Object.entries(metadata)) {
193
+ if (!key.startsWith(this.INTERNAL_PREFIX)) {
194
+ filtered[key] = value;
195
+ }
196
+ }
197
+ return filtered;
198
+ }
199
+
200
+ /**
201
+ * Offload the compressed context to external storage for agentic search if needed.
202
+ * @param options
203
+ * @param options.agentId - The agent identifier
204
+ * @param options.msgs - The messages to offload
205
+ * @returns The file path of the offloaded context, or undefined if offloading is not implemented or not needed
206
+ */
207
+ async offloadContext(options: { agentId?: string; msgs: Msg[] }): Promise<string | undefined> {
208
+ if (!this.offloadDir) {
209
+ return;
210
+ }
211
+
212
+ // Offload the compressed context to the text file
213
+ // e.g. 2026-03-01.txt
214
+ const fileName = `${new Date().toISOString().split('T')[0]}.txt`;
215
+ const offloadFile = path.join(this.offloadDir, options.agentId || '', fileName);
216
+ const offloadDataDir = path.join(this.offloadDir, options.agentId || '', 'data');
217
+
218
+ // Create the dir if it doesn't exist
219
+ const offloadAgentDir = path.dirname(offloadFile);
220
+ if (!fs.existsSync(offloadAgentDir)) {
221
+ fs.mkdirSync(offloadAgentDir, { recursive: true });
222
+ }
223
+
224
+ // Append the new context to the offload file
225
+ let appendContent = '';
226
+ for (const msg of options.msgs) {
227
+ const msgContent: string[] = [];
228
+ for (const block of msg.content) {
229
+ switch (block.type) {
230
+ case 'text':
231
+ msgContent.push(`${msg.name}: ${block.text}`);
232
+ break;
233
+ case 'data':
234
+ if (block.source.type === 'url') {
235
+ msgContent.push(
236
+ `${msg.name}: <data src={${block.source.url}} type={${block.source.mediaType}} />`
237
+ );
238
+ } else if (block.source.type === 'base64') {
239
+ // Save the base64 data to a file and add a reference to the file in the offload content
240
+ const mainType = block.source.mediaType.split('/')[0];
241
+ const extension = mime.extension(block.source.mediaType) || 'bin';
242
+ const filePath = path.join(
243
+ offloadDataDir,
244
+ `${mainType}-${Date.now()}.${extension}`
245
+ );
246
+ if (!fs.existsSync(offloadDataDir)) {
247
+ fs.mkdirSync(offloadDataDir, { recursive: true });
248
+ }
249
+ const buffer = Buffer.from(block.source.data, 'base64');
250
+ fs.writeFileSync(filePath, buffer);
251
+ msgContent.push(
252
+ `${msg.name}: <data src={${filePath}} type={${block.source.mediaType}} />`
253
+ );
254
+ }
255
+ break;
256
+ case 'tool_call':
257
+ msgContent.push(`${msg.name}: Calling tool ${block.name} ...`);
258
+ break;
259
+ }
260
+ }
261
+ appendContent += msgContent.join('\n') + '\n';
262
+ }
263
+
264
+ // Append to the offload file
265
+ fs.appendFileSync(offloadFile, appendContent, 'utf-8');
266
+
267
+ return offloadFile;
268
+ }
269
+ }
@@ -0,0 +1,2 @@
1
+ export { AgentState, StorageBase } from './base';
2
+ export { LocalFileStorage } from './file-system';
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+
3
+ import { ToolResponse } from './response';
4
+ import { ToolInputSchema } from '../type';
5
+
6
+ export interface Tool {
7
+ name: string;
8
+ description: string;
9
+ inputSchema: z.ZodObject | ToolInputSchema;
10
+ call?: (
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ input: any
13
+ ) =>
14
+ | string
15
+ | Promise<string>
16
+ | Generator<string>
17
+ | AsyncGenerator<string>
18
+ | ToolResponse
19
+ | Promise<ToolResponse>
20
+ | Generator<ToolResponse>
21
+ | AsyncGenerator<ToolResponse>;
22
+ requireUserConfirm?: boolean;
23
+ }
@@ -0,0 +1,174 @@
1
+ import { Bash } from './bash';
2
+
3
+ describe('Bash', () => {
4
+ test('Normal command execution', async () => {
5
+ const bash = Bash();
6
+ // Use cross-platform compatible command
7
+ const command = process.platform === 'win32' ? 'echo Hello World' : 'echo "Hello World"';
8
+ const result = await bash.call({ command });
9
+
10
+ expect(result.state).toBe('success');
11
+ expect(result.content).toHaveLength(1);
12
+ expect(result.content[0].type).toBe('text');
13
+ expect((result.content[0] as { type: 'text'; text: string }).text).toContain('Hello World');
14
+ });
15
+
16
+ test('Command with description parameter', async () => {
17
+ const bash = Bash();
18
+ const command = process.platform === 'win32' ? 'echo Test' : 'echo "Test"';
19
+ const result = await bash.call({
20
+ command,
21
+ description: 'Test command with description',
22
+ });
23
+
24
+ expect(result.state).toBe('success');
25
+ expect(result.content).toHaveLength(1);
26
+ expect((result.content[0] as { type: 'text'; text: string }).text).toContain('Test');
27
+ });
28
+
29
+ test('Error command - non-existent command', async () => {
30
+ const bash = Bash();
31
+ const result = await bash.call({
32
+ command: 'nonexistentcommand123',
33
+ });
34
+
35
+ expect(result.state).toBe('error');
36
+ expect(result.content).toHaveLength(1);
37
+ expect(result.content[0].type).toBe('text');
38
+ const text = (result.content[0] as { type: 'text'; text: string }).text;
39
+ expect(text).toContain('Command failed');
40
+ expect(text).toContain('nonexistentcommand123');
41
+ });
42
+
43
+ test('Error command - division by zero in bash', async () => {
44
+ const bash = Bash();
45
+ // In bash, division by zero causes an error
46
+ // On Windows cmd, this syntax doesn't work, so use a different failing command
47
+ const command = process.platform === 'win32' ? 'set /a 10/0' : 'echo $((10/0))';
48
+ const result = await bash.call({ command });
49
+
50
+ expect(result.state).toBe('error');
51
+ expect(result.content).toHaveLength(1);
52
+ const text = (result.content[0] as { type: 'text'; text: string }).text;
53
+ expect(text).toContain('Command failed');
54
+ });
55
+
56
+ test('Timeout command', async () => {
57
+ const bash = Bash();
58
+ // Use cross-platform sleep command
59
+ // On Windows, use ping as a delay mechanism (more reliable than timeout in non-interactive mode)
60
+ const command = process.platform === 'win32' ? 'ping 127.0.0.1 -n 6 > nul' : 'sleep 5';
61
+ const result = await bash.call({
62
+ command,
63
+ timeout: 1000, // 1 second timeout
64
+ });
65
+
66
+ expect(result.state).toBe('error');
67
+ expect(result.content).toHaveLength(1);
68
+ const text = (result.content[0] as { type: 'text'; text: string }).text;
69
+ expect(text).toContain('Command failed');
70
+ }, 10000); // Increase Jest timeout for this test
71
+
72
+ test('Command with custom timeout that succeeds', async () => {
73
+ const bash = Bash();
74
+ // On Windows, use ping as a delay (ping waits ~1 second per count)
75
+ const command = process.platform === 'win32' ? 'ping 127.0.0.1 -n 2 > nul' : 'sleep 1';
76
+ const result = await bash.call({
77
+ command,
78
+ timeout: 3000, // 3 second timeout
79
+ });
80
+
81
+ expect(result.state).toBe('success');
82
+ }, 10000);
83
+
84
+ test('Output truncation - exceeds 30000 characters', async () => {
85
+ const bash = Bash();
86
+ // Generate output longer than 30000 characters
87
+ const command =
88
+ process.platform === 'win32'
89
+ ? 'for /L %i in (1,1,10000) do @echo This is line %i with some extra text'
90
+ : 'for i in {1..10000}; do echo "This is line $i with some extra text"; done';
91
+ const result = await bash.call({ command });
92
+
93
+ expect(result.state).toBe('success');
94
+ expect(result.content).toHaveLength(1);
95
+ const text = (result.content[0] as { type: 'text'; text: string }).text;
96
+ expect(text).toContain('[Output truncated - exceeded 30000 characters]');
97
+ expect(text.length).toBeLessThanOrEqual(30100); // Allow some buffer for truncation message
98
+ }, 10000);
99
+
100
+ test('Command with stderr output', async () => {
101
+ const bash = Bash();
102
+ // Use a command that writes to stderr - cross-platform
103
+ const command =
104
+ process.platform === 'win32'
105
+ ? 'dir C:\\nonexistent_directory_12345'
106
+ : 'ls /nonexistent_directory_12345';
107
+ const result = await bash.call({ command });
108
+
109
+ expect(result.state).toBe('error');
110
+ expect(result.content).toHaveLength(1);
111
+ const text = (result.content[0] as { type: 'text'; text: string }).text;
112
+ expect(text).toContain('Command failed');
113
+ });
114
+
115
+ test('Command with both stdout and stderr', async () => {
116
+ const bash = Bash();
117
+ // Command that produces both stdout and stderr
118
+ const command =
119
+ process.platform === 'win32'
120
+ ? 'echo stdout message && dir C:\\nonexistent_directory_12345'
121
+ : 'echo "stdout message" && ls /nonexistent_directory_12345';
122
+ const result = await bash.call({ command });
123
+
124
+ expect(result.state).toBe('error');
125
+ expect(result.content).toHaveLength(1);
126
+ const text = (result.content[0] as { type: 'text'; text: string }).text;
127
+ expect(text).toContain('Command failed');
128
+ expect(text).toContain('stdout message');
129
+ });
130
+
131
+ test('Maximum timeout enforcement', async () => {
132
+ const bash = Bash();
133
+ // Try to set timeout beyond maximum (600000ms)
134
+ const command = process.platform === 'win32' ? 'echo test' : 'echo "test"';
135
+ const result = await bash.call({
136
+ command,
137
+ timeout: 700000, // 700 seconds, should be capped at 600000
138
+ });
139
+
140
+ // Should still succeed because the command is fast
141
+ expect(result.state).toBe('success');
142
+ });
143
+
144
+ test('Command with special characters', async () => {
145
+ const bash = Bash();
146
+ // Windows cmd has different special character handling
147
+ const command =
148
+ process.platform === 'win32'
149
+ ? 'echo Special chars: %USERPROFILE%'
150
+ : 'echo "Special chars: $HOME | & ; < > ( ) { }"';
151
+ const result = await bash.call({ command });
152
+
153
+ expect(result.state).toBe('success');
154
+ expect(result.content).toHaveLength(1);
155
+ const text = (result.content[0] as { type: 'text'; text: string }).text;
156
+ expect(text).toContain('Special chars');
157
+ });
158
+
159
+ test('Multi-line output', async () => {
160
+ const bash = Bash();
161
+ const command =
162
+ process.platform === 'win32'
163
+ ? 'echo Line 1 && echo Line 2 && echo Line 3'
164
+ : 'echo "Line 1" && echo "Line 2" && echo "Line 3"';
165
+ const result = await bash.call({ command });
166
+
167
+ expect(result.state).toBe('success');
168
+ expect(result.content).toHaveLength(1);
169
+ const text = (result.content[0] as { type: 'text'; text: string }).text;
170
+ expect(text).toContain('Line 1');
171
+ expect(text).toContain('Line 2');
172
+ expect(text).toContain('Line 3');
173
+ });
174
+ });
@@ -0,0 +1,152 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ import { z } from 'zod';
5
+
6
+ import { createToolResponse, ToolResponse } from './response';
7
+
8
+ const execAsync = promisify(exec);
9
+
10
+ /**
11
+ * Tool for executing bash commands in a shell environment.
12
+ * Intended for terminal operations such as git, npm, and docker.
13
+ * File operations should use the dedicated Read, Write, Edit, Glob, and Grep tools instead.
14
+ *
15
+ * @returns A Tool object for executing bash commands, with a call method that performs the execution and returns the output or error message.
16
+ */
17
+ export function Bash() {
18
+ return {
19
+ name: 'Bash',
20
+ description: `Executes a given bash command and returns its output.
21
+
22
+ The working directory persists between commands, but shell state does not. The shell environment is initialized from the user's profile (bash or zsh).
23
+
24
+ IMPORTANT: Avoid using this tool to run \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:
25
+
26
+ - File search: Use Glob (NOT find or ls)
27
+ - Content search: Use Grep (NOT grep or rg)
28
+ - Read files: Use Read (NOT cat/head/tail)
29
+ - Edit files: Use Edit (NOT sed/awk)
30
+ - Write files: Use Write (NOT echo >/cat <<EOF)
31
+ - Communication: Output text directly (NOT echo/printf)
32
+
33
+ While the Bash tool can do similar things, it's better to use the built-in tools as they provide a better user experience and make it easier to review tool calls and give permission.
34
+
35
+ # Instructions
36
+ - If your command will create new directories or files, first use this tool to run \`ls\` to verify the parent directory exists and is the correct location.
37
+ - Always quote file paths that contain spaces with double quotes in your command (e.g., cd "path with spaces/file.txt")
38
+ - 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.
39
+ - You may specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). By default, your command will timeout after 120000ms (2 minutes).
40
+ - Write a clear, concise description of what your command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), include enough context so that the user can understand what your command will do.
41
+ - When issuing multiple commands:
42
+ - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. Example: if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
43
+ - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together.
44
+ - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail.
45
+ - DO NOT use newlines to separate commands (newlines are ok in quoted strings).
46
+ - For git commands:
47
+ - Prefer to create a new commit rather than amending an existing commit.
48
+ - Before running destructive operations (e.g., git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative that achieves the same goal. Only use destructive operations when they are truly the best approach.
49
+ - Never skip hooks (--no-verify) or bypass signing (--no-gpg-sign, -c commit.gpgsign=false) unless the user has explicitly asked for it. If a hook fails, investigate and fix the underlying issue.
50
+ - Avoid unnecessary \`sleep\` commands:
51
+ - Do not sleep between commands that can run immediately — just run them.
52
+ - Do not retry failing commands in a sleep loop — diagnose the root cause or consider an alternative approach.
53
+ - If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.`,
54
+ inputSchema: z.object({
55
+ command: z.string().describe('The bash command to execute'),
56
+ description: z
57
+ .string()
58
+ .optional()
59
+ .describe(
60
+ 'Clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands, include enough context.'
61
+ ),
62
+ timeout: z
63
+ .number()
64
+ .int()
65
+ .min(0)
66
+ .max(600000)
67
+ .optional()
68
+ .describe('Optional timeout in milliseconds (default: 120000, max: 600000)'),
69
+ }),
70
+ requireUserConfirm: true,
71
+
72
+ /**
73
+ * Executes a bash command and returns its output.
74
+ *
75
+ * @param root0 - The parameters object
76
+ * @param root0.command - The bash command to execute
77
+ * @param root0.description - Optional description of what the command does
78
+ * @param root0.timeout - Optional timeout in milliseconds (default: 120000, max: 600000)
79
+ * @returns The stdout of the command, or an error message if the command fails
80
+ */
81
+ async call({
82
+ command,
83
+ description: _description,
84
+ timeout = 120000,
85
+ }: {
86
+ command: string;
87
+ description?: string;
88
+ timeout?: number;
89
+ }): Promise<ToolResponse> {
90
+ try {
91
+ const maxTimeout = 600000;
92
+ const effectiveTimeout = Math.min(timeout, maxTimeout);
93
+
94
+ // Determine the appropriate shell based on platform
95
+ let shell: string;
96
+ if (process.platform === 'win32') {
97
+ // On Windows, use cmd.exe or PowerShell
98
+ shell = process.env.COMSPEC || 'cmd.exe';
99
+ } else {
100
+ // On Unix-like systems, use the user's shell or default to bash
101
+ shell = process.env.SHELL || '/bin/bash';
102
+ }
103
+
104
+ const { stdout } = await execAsync(command, {
105
+ encoding: 'utf-8',
106
+ timeout: effectiveTimeout,
107
+ maxBuffer: 30000 * 1024,
108
+ shell,
109
+ });
110
+
111
+ // Normalize line endings to LF for cross-platform consistency
112
+ const normalizedOutput = stdout.replace(/\r\n/g, '\n');
113
+
114
+ const maxOutputLength = 30000;
115
+ if (normalizedOutput.length > maxOutputLength) {
116
+ return createToolResponse({
117
+ content: [
118
+ {
119
+ id: crypto.randomUUID(),
120
+ type: 'text',
121
+ text:
122
+ normalizedOutput.substring(0, maxOutputLength) +
123
+ '\n\n[Output truncated - exceeded 30000 characters]',
124
+ },
125
+ ],
126
+ state: 'success',
127
+ });
128
+ }
129
+
130
+ return createToolResponse({
131
+ content: [{ id: crypto.randomUUID(), type: 'text', text: normalizedOutput }],
132
+ state: 'success',
133
+ });
134
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
135
+ } catch (error: any) {
136
+ const errorMessage = error.message || 'Unknown error';
137
+ const stderr = error.stderr?.toString().replace(/\r\n/g, '\n') || '';
138
+ const stdout = error.stdout?.toString().replace(/\r\n/g, '\n') || '';
139
+
140
+ let result = `Command failed: ${command}\n`;
141
+ if (stdout) result += `\nStdout:\n${stdout}`;
142
+ if (stderr) result += `\nStderr:\n${stderr}`;
143
+ if (errorMessage && !stderr) result += `\nError: ${errorMessage}`;
144
+
145
+ return createToolResponse({
146
+ content: [{ id: crypto.randomUUID(), type: 'text', text: result }],
147
+ state: 'error',
148
+ });
149
+ }
150
+ },
151
+ };
152
+ }