@jackchen_me/open-multi-agent 0.1.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/LICENSE +21 -0
- package/README.md +280 -0
- package/dist/agent/agent.d.ts +121 -0
- package/dist/agent/agent.d.ts.map +1 -0
- package/dist/agent/agent.js +294 -0
- package/dist/agent/agent.js.map +1 -0
- package/dist/agent/pool.d.ts +128 -0
- package/dist/agent/pool.d.ts.map +1 -0
- package/dist/agent/pool.js +236 -0
- package/dist/agent/pool.js.map +1 -0
- package/dist/agent/runner.d.ts +120 -0
- package/dist/agent/runner.d.ts.map +1 -0
- package/dist/agent/runner.js +274 -0
- package/dist/agent/runner.js.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/adapter.d.ts +38 -0
- package/dist/llm/adapter.d.ts.map +1 -0
- package/dist/llm/adapter.js +46 -0
- package/dist/llm/adapter.js.map +1 -0
- package/dist/llm/anthropic.d.ts +56 -0
- package/dist/llm/anthropic.d.ts.map +1 -0
- package/dist/llm/anthropic.js +307 -0
- package/dist/llm/anthropic.js.map +1 -0
- package/dist/llm/openai.d.ts +62 -0
- package/dist/llm/openai.d.ts.map +1 -0
- package/dist/llm/openai.js +424 -0
- package/dist/llm/openai.js.map +1 -0
- package/dist/memory/shared.d.ts +86 -0
- package/dist/memory/shared.d.ts.map +1 -0
- package/dist/memory/shared.js +155 -0
- package/dist/memory/shared.js.map +1 -0
- package/dist/memory/store.d.ts +64 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +103 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/orchestrator/orchestrator.d.ts +173 -0
- package/dist/orchestrator/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator/orchestrator.js +698 -0
- package/dist/orchestrator/orchestrator.js.map +1 -0
- package/dist/orchestrator/scheduler.d.ts +112 -0
- package/dist/orchestrator/scheduler.d.ts.map +1 -0
- package/dist/orchestrator/scheduler.js +282 -0
- package/dist/orchestrator/scheduler.js.map +1 -0
- package/dist/task/queue.d.ts +160 -0
- package/dist/task/queue.d.ts.map +1 -0
- package/dist/task/queue.js +337 -0
- package/dist/task/queue.js.map +1 -0
- package/dist/task/task.d.ts +86 -0
- package/dist/task/task.d.ts.map +1 -0
- package/dist/task/task.js +201 -0
- package/dist/task/task.js.map +1 -0
- package/dist/team/messaging.d.ts +106 -0
- package/dist/team/messaging.d.ts.map +1 -0
- package/dist/team/messaging.js +182 -0
- package/dist/team/messaging.js.map +1 -0
- package/dist/team/team.d.ts +141 -0
- package/dist/team/team.d.ts.map +1 -0
- package/dist/team/team.js +282 -0
- package/dist/team/team.js.map +1 -0
- package/dist/tool/built-in/bash.d.ts +12 -0
- package/dist/tool/built-in/bash.d.ts.map +1 -0
- package/dist/tool/built-in/bash.js +133 -0
- package/dist/tool/built-in/bash.js.map +1 -0
- package/dist/tool/built-in/file-edit.d.ts +14 -0
- package/dist/tool/built-in/file-edit.d.ts.map +1 -0
- package/dist/tool/built-in/file-edit.js +130 -0
- package/dist/tool/built-in/file-edit.js.map +1 -0
- package/dist/tool/built-in/file-read.d.ts +12 -0
- package/dist/tool/built-in/file-read.d.ts.map +1 -0
- package/dist/tool/built-in/file-read.js +82 -0
- package/dist/tool/built-in/file-read.js.map +1 -0
- package/dist/tool/built-in/file-write.d.ts +11 -0
- package/dist/tool/built-in/file-write.d.ts.map +1 -0
- package/dist/tool/built-in/file-write.js +70 -0
- package/dist/tool/built-in/file-write.js.map +1 -0
- package/dist/tool/built-in/grep.d.ts +15 -0
- package/dist/tool/built-in/grep.d.ts.map +1 -0
- package/dist/tool/built-in/grep.js +287 -0
- package/dist/tool/built-in/grep.js.map +1 -0
- package/dist/tool/built-in/index.d.ts +36 -0
- package/dist/tool/built-in/index.d.ts.map +1 -0
- package/dist/tool/built-in/index.js +45 -0
- package/dist/tool/built-in/index.js.map +1 -0
- package/dist/tool/executor.d.ts +71 -0
- package/dist/tool/executor.d.ts.map +1 -0
- package/dist/tool/executor.js +116 -0
- package/dist/tool/executor.js.map +1 -0
- package/dist/tool/framework.d.ts +143 -0
- package/dist/tool/framework.d.ts.map +1 -0
- package/dist/tool/framework.js +371 -0
- package/dist/tool/framework.js.map +1 -0
- package/dist/types.d.ts +285 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/semaphore.d.ts +47 -0
- package/dist/utils/semaphore.d.ts.map +1 -0
- package/dist/utils/semaphore.js +85 -0
- package/dist/utils/semaphore.js.map +1 -0
- package/examples/01-single-agent.ts +131 -0
- package/examples/02-team-collaboration.ts +167 -0
- package/examples/03-task-pipeline.ts +201 -0
- package/examples/04-multi-model-team.ts +261 -0
- package/package.json +49 -0
- package/src/agent/agent.ts +364 -0
- package/src/agent/pool.ts +278 -0
- package/src/agent/runner.ts +413 -0
- package/src/index.ts +166 -0
- package/src/llm/adapter.ts +74 -0
- package/src/llm/anthropic.ts +388 -0
- package/src/llm/openai.ts +522 -0
- package/src/memory/shared.ts +181 -0
- package/src/memory/store.ts +124 -0
- package/src/orchestrator/orchestrator.ts +851 -0
- package/src/orchestrator/scheduler.ts +352 -0
- package/src/task/queue.ts +394 -0
- package/src/task/task.ts +232 -0
- package/src/team/messaging.ts +230 -0
- package/src/team/team.ts +334 -0
- package/src/tool/built-in/bash.ts +187 -0
- package/src/tool/built-in/file-edit.ts +154 -0
- package/src/tool/built-in/file-read.ts +105 -0
- package/src/tool/built-in/file-write.ts +81 -0
- package/src/tool/built-in/grep.ts +362 -0
- package/src/tool/built-in/index.ts +50 -0
- package/src/tool/executor.ts +178 -0
- package/src/tool/framework.ts +557 -0
- package/src/types.ts +362 -0
- package/src/utils/semaphore.ts +89 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in grep tool.
|
|
3
|
+
*
|
|
4
|
+
* Searches for a regex pattern in files. Prefers the `rg` (ripgrep) binary
|
|
5
|
+
* when available for performance; falls back to a pure Node.js recursive
|
|
6
|
+
* implementation using the standard `fs` module so the tool works in
|
|
7
|
+
* environments without ripgrep installed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'child_process'
|
|
11
|
+
import { readdir, readFile, stat } from 'fs/promises'
|
|
12
|
+
// Note: readdir is used with { encoding: 'utf8' } to return string[] directly.
|
|
13
|
+
import { join, relative } from 'path'
|
|
14
|
+
import { z } from 'zod'
|
|
15
|
+
import type { ToolResult } from '../../types.js'
|
|
16
|
+
import { defineTool } from '../framework.js'
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAX_RESULTS = 100
|
|
23
|
+
// Directories that are almost never useful to search inside
|
|
24
|
+
const SKIP_DIRS = new Set([
|
|
25
|
+
'.git',
|
|
26
|
+
'.svn',
|
|
27
|
+
'.hg',
|
|
28
|
+
'node_modules',
|
|
29
|
+
'.next',
|
|
30
|
+
'dist',
|
|
31
|
+
'build',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Tool definition
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export const grepTool = defineTool({
|
|
39
|
+
name: 'grep',
|
|
40
|
+
description:
|
|
41
|
+
'Search for a regular-expression pattern in one or more files. ' +
|
|
42
|
+
'Returns matching lines with their file paths and 1-based line numbers. ' +
|
|
43
|
+
'Use the `glob` parameter to restrict the search to specific file types ' +
|
|
44
|
+
'(e.g. "*.ts"). ' +
|
|
45
|
+
'Results are capped by `maxResults` to keep the response manageable.',
|
|
46
|
+
|
|
47
|
+
inputSchema: z.object({
|
|
48
|
+
pattern: z
|
|
49
|
+
.string()
|
|
50
|
+
.describe('Regular expression pattern to search for in file contents.'),
|
|
51
|
+
path: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe(
|
|
55
|
+
'Directory or file path to search in. ' +
|
|
56
|
+
'Defaults to the current working directory.',
|
|
57
|
+
),
|
|
58
|
+
glob: z
|
|
59
|
+
.string()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe(
|
|
62
|
+
'Glob pattern to filter which files are searched ' +
|
|
63
|
+
'(e.g. "*.ts", "**/*.json"). ' +
|
|
64
|
+
'Only used when `path` is a directory.',
|
|
65
|
+
),
|
|
66
|
+
maxResults: z
|
|
67
|
+
.number()
|
|
68
|
+
.int()
|
|
69
|
+
.positive()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
`Maximum number of matching lines to return. ` +
|
|
73
|
+
`Defaults to ${DEFAULT_MAX_RESULTS}.`,
|
|
74
|
+
),
|
|
75
|
+
}),
|
|
76
|
+
|
|
77
|
+
execute: async (input, context) => {
|
|
78
|
+
const searchPath = input.path ?? process.cwd()
|
|
79
|
+
const maxResults = input.maxResults ?? DEFAULT_MAX_RESULTS
|
|
80
|
+
|
|
81
|
+
// Compile the regex once and surface bad patterns immediately.
|
|
82
|
+
let regex: RegExp
|
|
83
|
+
try {
|
|
84
|
+
regex = new RegExp(input.pattern)
|
|
85
|
+
} catch {
|
|
86
|
+
return {
|
|
87
|
+
data: `Invalid regular expression: "${input.pattern}"`,
|
|
88
|
+
isError: true,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Attempt ripgrep first.
|
|
93
|
+
const rgAvailable = await isRipgrepAvailable()
|
|
94
|
+
if (rgAvailable) {
|
|
95
|
+
return runRipgrep(input.pattern, searchPath, {
|
|
96
|
+
glob: input.glob,
|
|
97
|
+
maxResults,
|
|
98
|
+
signal: context.abortSignal,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Fallback: pure Node.js recursive search.
|
|
103
|
+
return runNodeSearch(regex, searchPath, {
|
|
104
|
+
glob: input.glob,
|
|
105
|
+
maxResults,
|
|
106
|
+
signal: context.abortSignal,
|
|
107
|
+
})
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// ripgrep path
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
interface SearchOptions {
|
|
116
|
+
glob?: string
|
|
117
|
+
maxResults: number
|
|
118
|
+
signal: AbortSignal | undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runRipgrep(
|
|
122
|
+
pattern: string,
|
|
123
|
+
searchPath: string,
|
|
124
|
+
options: SearchOptions,
|
|
125
|
+
): Promise<ToolResult> {
|
|
126
|
+
const args = [
|
|
127
|
+
'--line-number',
|
|
128
|
+
'--no-heading',
|
|
129
|
+
'--color=never',
|
|
130
|
+
`--max-count=${options.maxResults}`,
|
|
131
|
+
]
|
|
132
|
+
if (options.glob !== undefined) {
|
|
133
|
+
args.push('--glob', options.glob)
|
|
134
|
+
}
|
|
135
|
+
args.push('--', pattern, searchPath)
|
|
136
|
+
|
|
137
|
+
return new Promise<ToolResult>((resolve) => {
|
|
138
|
+
const chunks: Buffer[] = []
|
|
139
|
+
const errChunks: Buffer[] = []
|
|
140
|
+
|
|
141
|
+
const child = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
142
|
+
|
|
143
|
+
child.stdout.on('data', (d: Buffer) => chunks.push(d))
|
|
144
|
+
child.stderr.on('data', (d: Buffer) => errChunks.push(d))
|
|
145
|
+
|
|
146
|
+
const onAbort = (): void => { child.kill('SIGKILL') }
|
|
147
|
+
if (options.signal !== undefined) {
|
|
148
|
+
options.signal.addEventListener('abort', onAbort, { once: true })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
child.on('close', (code: number | null) => {
|
|
152
|
+
if (options.signal !== undefined) {
|
|
153
|
+
options.signal.removeEventListener('abort', onAbort)
|
|
154
|
+
}
|
|
155
|
+
const output = Buffer.concat(chunks).toString('utf8').trimEnd()
|
|
156
|
+
|
|
157
|
+
// rg exit code 1 = no matches (not an error)
|
|
158
|
+
if (code !== 0 && code !== 1) {
|
|
159
|
+
const errMsg = Buffer.concat(errChunks).toString('utf8').trim()
|
|
160
|
+
resolve({
|
|
161
|
+
data: `ripgrep failed (exit ${code}): ${errMsg}`,
|
|
162
|
+
isError: true,
|
|
163
|
+
})
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (output.length === 0) {
|
|
168
|
+
resolve({ data: 'No matches found.', isError: false })
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const lines = output.split('\n')
|
|
173
|
+
resolve({
|
|
174
|
+
data: lines.join('\n'),
|
|
175
|
+
isError: false,
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
child.on('error', () => {
|
|
180
|
+
if (options.signal !== undefined) {
|
|
181
|
+
options.signal.removeEventListener('abort', onAbort)
|
|
182
|
+
}
|
|
183
|
+
// Caller will see an error result — the tool won't retry with Node search
|
|
184
|
+
// since this branch is only reachable after we confirmed rg is available.
|
|
185
|
+
resolve({
|
|
186
|
+
data: 'ripgrep process error — run may be retried with the Node.js fallback.',
|
|
187
|
+
isError: true,
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Node.js fallback search
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
interface MatchLine {
|
|
198
|
+
file: string
|
|
199
|
+
lineNumber: number
|
|
200
|
+
text: string
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function runNodeSearch(
|
|
204
|
+
regex: RegExp,
|
|
205
|
+
searchPath: string,
|
|
206
|
+
options: SearchOptions,
|
|
207
|
+
): Promise<ToolResult> {
|
|
208
|
+
// Collect files
|
|
209
|
+
let files: string[]
|
|
210
|
+
try {
|
|
211
|
+
const info = await stat(searchPath)
|
|
212
|
+
if (info.isFile()) {
|
|
213
|
+
files = [searchPath]
|
|
214
|
+
} else {
|
|
215
|
+
files = await collectFiles(searchPath, options.glob, options.signal)
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
219
|
+
return {
|
|
220
|
+
data: `Cannot access path "${searchPath}": ${message}`,
|
|
221
|
+
isError: true,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const matches: MatchLine[] = []
|
|
226
|
+
|
|
227
|
+
for (const file of files) {
|
|
228
|
+
if (options.signal?.aborted === true) break
|
|
229
|
+
if (matches.length >= options.maxResults) break
|
|
230
|
+
|
|
231
|
+
let fileContent: string
|
|
232
|
+
try {
|
|
233
|
+
fileContent = (await readFile(file)).toString('utf8')
|
|
234
|
+
} catch {
|
|
235
|
+
// Skip unreadable files (binary, permission denied, etc.)
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const lines = fileContent.split('\n')
|
|
240
|
+
for (let i = 0; i < lines.length; i++) {
|
|
241
|
+
if (matches.length >= options.maxResults) break
|
|
242
|
+
// Reset lastIndex for global regexes
|
|
243
|
+
regex.lastIndex = 0
|
|
244
|
+
if (regex.test(lines[i])) {
|
|
245
|
+
matches.push({
|
|
246
|
+
file: relative(process.cwd(), file) || file,
|
|
247
|
+
lineNumber: i + 1,
|
|
248
|
+
text: lines[i],
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (matches.length === 0) {
|
|
255
|
+
return { data: 'No matches found.', isError: false }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const formatted = matches
|
|
259
|
+
.map((m) => `${m.file}:${m.lineNumber}:${m.text}`)
|
|
260
|
+
.join('\n')
|
|
261
|
+
|
|
262
|
+
const truncationNote =
|
|
263
|
+
matches.length >= options.maxResults
|
|
264
|
+
? `\n\n(results capped at ${options.maxResults}; use maxResults to raise the limit)`
|
|
265
|
+
: ''
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
data: formatted + truncationNote,
|
|
269
|
+
isError: false,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// File collection with glob filtering
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Recursively walk `dir` and return file paths, honouring `SKIP_DIRS` and an
|
|
279
|
+
* optional glob pattern.
|
|
280
|
+
*/
|
|
281
|
+
async function collectFiles(
|
|
282
|
+
dir: string,
|
|
283
|
+
glob: string | undefined,
|
|
284
|
+
signal: AbortSignal | undefined,
|
|
285
|
+
): Promise<string[]> {
|
|
286
|
+
const results: string[] = []
|
|
287
|
+
await walk(dir, glob, results, signal)
|
|
288
|
+
return results
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function walk(
|
|
292
|
+
dir: string,
|
|
293
|
+
glob: string | undefined,
|
|
294
|
+
results: string[],
|
|
295
|
+
signal: AbortSignal | undefined,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
if (signal?.aborted === true) return
|
|
298
|
+
|
|
299
|
+
let entryNames: string[]
|
|
300
|
+
try {
|
|
301
|
+
// Read as plain strings so we don't have to deal with Buffer Dirent variants.
|
|
302
|
+
entryNames = await readdir(dir, { encoding: 'utf8' })
|
|
303
|
+
} catch {
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const entryName of entryNames) {
|
|
308
|
+
if (signal !== undefined && signal.aborted) return
|
|
309
|
+
|
|
310
|
+
const fullPath = join(dir, entryName)
|
|
311
|
+
|
|
312
|
+
let entryInfo: Awaited<ReturnType<typeof stat>>
|
|
313
|
+
try {
|
|
314
|
+
entryInfo = await stat(fullPath)
|
|
315
|
+
} catch {
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (entryInfo.isDirectory()) {
|
|
320
|
+
if (!SKIP_DIRS.has(entryName)) {
|
|
321
|
+
await walk(fullPath, glob, results, signal)
|
|
322
|
+
}
|
|
323
|
+
} else if (entryInfo.isFile()) {
|
|
324
|
+
if (glob === undefined || matchesGlob(entryName, glob)) {
|
|
325
|
+
results.push(fullPath)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Minimal glob match supporting `*.ext` and `**\/<pattern>` forms.
|
|
333
|
+
*/
|
|
334
|
+
function matchesGlob(filename: string, glob: string): boolean {
|
|
335
|
+
// Strip leading **/ prefix — we already recurse into all directories
|
|
336
|
+
const pattern = glob.startsWith('**/') ? glob.slice(3) : glob
|
|
337
|
+
// Convert shell glob characters to regex equivalents
|
|
338
|
+
const regexSource = pattern
|
|
339
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape special regex chars first
|
|
340
|
+
.replace(/\*/g, '.*') // * -> .*
|
|
341
|
+
.replace(/\?/g, '.') // ? -> .
|
|
342
|
+
const re = new RegExp(`^${regexSource}$`, 'i')
|
|
343
|
+
return re.test(filename)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// ripgrep availability check (cached per process)
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
let rgAvailableCache: boolean | undefined
|
|
351
|
+
|
|
352
|
+
async function isRipgrepAvailable(): Promise<boolean> {
|
|
353
|
+
if (rgAvailableCache !== undefined) return rgAvailableCache
|
|
354
|
+
|
|
355
|
+
rgAvailableCache = await new Promise<boolean>((resolve) => {
|
|
356
|
+
const child = spawn('rg', ['--version'], { stdio: 'ignore' })
|
|
357
|
+
child.on('close', (code) => resolve(code === 0))
|
|
358
|
+
child.on('error', () => resolve(false))
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
return rgAvailableCache
|
|
362
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in tool collection.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports every built-in tool and provides a convenience function to
|
|
5
|
+
* register them all with a {@link ToolRegistry} in one call.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ToolDefinition } from '../../types.js'
|
|
9
|
+
import { ToolRegistry } from '../framework.js'
|
|
10
|
+
import { bashTool } from './bash.js'
|
|
11
|
+
import { fileEditTool } from './file-edit.js'
|
|
12
|
+
import { fileReadTool } from './file-read.js'
|
|
13
|
+
import { fileWriteTool } from './file-write.js'
|
|
14
|
+
import { grepTool } from './grep.js'
|
|
15
|
+
|
|
16
|
+
export { bashTool, fileEditTool, fileReadTool, fileWriteTool, grepTool }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The ordered list of all built-in tools. Import this when you need to
|
|
20
|
+
* iterate over them without calling `registerBuiltInTools`.
|
|
21
|
+
*
|
|
22
|
+
* The array is typed as `ToolDefinition<unknown>[]` so it can be passed to
|
|
23
|
+
* APIs that accept any ToolDefinition without requiring a union type.
|
|
24
|
+
*/
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
export const BUILT_IN_TOOLS: ToolDefinition<any>[] = [
|
|
27
|
+
bashTool,
|
|
28
|
+
fileReadTool,
|
|
29
|
+
fileWriteTool,
|
|
30
|
+
fileEditTool,
|
|
31
|
+
grepTool,
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Register all built-in tools with the given registry.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { ToolRegistry } from '../framework.js'
|
|
40
|
+
* import { registerBuiltInTools } from './built-in/index.js'
|
|
41
|
+
*
|
|
42
|
+
* const registry = new ToolRegistry()
|
|
43
|
+
* registerBuiltInTools(registry)
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function registerBuiltInTools(registry: ToolRegistry): void {
|
|
47
|
+
for (const tool of BUILT_IN_TOOLS) {
|
|
48
|
+
registry.register(tool)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel tool executor with concurrency control and error isolation.
|
|
3
|
+
*
|
|
4
|
+
* Validates input via Zod schemas, enforces a maximum concurrency limit using
|
|
5
|
+
* a lightweight semaphore, tracks execution duration, and surfaces any
|
|
6
|
+
* execution errors as ToolResult objects rather than thrown exceptions.
|
|
7
|
+
*
|
|
8
|
+
* Types are imported from `../types` to ensure consistency with the rest of
|
|
9
|
+
* the framework.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ToolResult, ToolUseContext } from '../types.js'
|
|
13
|
+
import type { ToolDefinition } from '../types.js'
|
|
14
|
+
import { ToolRegistry } from './framework.js'
|
|
15
|
+
import { Semaphore } from '../utils/semaphore.js'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// ToolExecutor
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface ToolExecutorOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Maximum number of tool calls that may run in parallel.
|
|
24
|
+
* Defaults to 4.
|
|
25
|
+
*/
|
|
26
|
+
maxConcurrency?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Describes one call in a batch. */
|
|
30
|
+
export interface BatchToolCall {
|
|
31
|
+
/** Caller-assigned ID used as the key in the result map. */
|
|
32
|
+
id: string
|
|
33
|
+
/** Registered tool name. */
|
|
34
|
+
name: string
|
|
35
|
+
/** Raw (unparsed) input object from the LLM. */
|
|
36
|
+
input: Record<string, unknown>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Executes tools from a {@link ToolRegistry}, validating input against each
|
|
41
|
+
* tool's Zod schema and enforcing a concurrency limit for batch execution.
|
|
42
|
+
*
|
|
43
|
+
* All errors — including unknown tool names, Zod validation failures, and
|
|
44
|
+
* execution exceptions — are caught and returned as `ToolResult` objects with
|
|
45
|
+
* `isError: true` so the agent runner can forward them to the LLM.
|
|
46
|
+
*/
|
|
47
|
+
export class ToolExecutor {
|
|
48
|
+
private readonly registry: ToolRegistry
|
|
49
|
+
private readonly semaphore: Semaphore
|
|
50
|
+
|
|
51
|
+
constructor(registry: ToolRegistry, options: ToolExecutorOptions = {}) {
|
|
52
|
+
this.registry = registry
|
|
53
|
+
this.semaphore = new Semaphore(options.maxConcurrency ?? 4)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// -------------------------------------------------------------------------
|
|
57
|
+
// Single execution
|
|
58
|
+
// -------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Execute a single tool by name.
|
|
62
|
+
*
|
|
63
|
+
* Errors are caught and returned as a {@link ToolResult} with
|
|
64
|
+
* `isError: true` — this method itself never rejects.
|
|
65
|
+
*
|
|
66
|
+
* @param toolName The registered tool name.
|
|
67
|
+
* @param input Raw input object (before Zod validation).
|
|
68
|
+
* @param context Execution context forwarded to the tool.
|
|
69
|
+
*/
|
|
70
|
+
async execute(
|
|
71
|
+
toolName: string,
|
|
72
|
+
input: Record<string, unknown>,
|
|
73
|
+
context: ToolUseContext,
|
|
74
|
+
): Promise<ToolResult> {
|
|
75
|
+
const tool = this.registry.get(toolName)
|
|
76
|
+
if (tool === undefined) {
|
|
77
|
+
return this.errorResult(
|
|
78
|
+
`Tool "${toolName}" is not registered in the ToolRegistry.`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check abort before even starting
|
|
83
|
+
if (context.abortSignal?.aborted === true) {
|
|
84
|
+
return this.errorResult(
|
|
85
|
+
`Tool "${toolName}" was aborted before execution began.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return this.runTool(tool, input, context)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// -------------------------------------------------------------------------
|
|
93
|
+
// Batch execution
|
|
94
|
+
// -------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Execute multiple tool calls in parallel, honouring the concurrency limit.
|
|
98
|
+
*
|
|
99
|
+
* Returns a `Map` from call ID to result. Every call in `calls` is
|
|
100
|
+
* guaranteed to produce an entry — errors are captured as results.
|
|
101
|
+
*
|
|
102
|
+
* @param calls Array of tool calls to execute.
|
|
103
|
+
* @param context Shared execution context for all calls in this batch.
|
|
104
|
+
*/
|
|
105
|
+
async executeBatch(
|
|
106
|
+
calls: BatchToolCall[],
|
|
107
|
+
context: ToolUseContext,
|
|
108
|
+
): Promise<Map<string, ToolResult>> {
|
|
109
|
+
const results = new Map<string, ToolResult>()
|
|
110
|
+
|
|
111
|
+
await Promise.all(
|
|
112
|
+
calls.map(async (call) => {
|
|
113
|
+
const result = await this.semaphore.run(() =>
|
|
114
|
+
this.execute(call.name, call.input, context),
|
|
115
|
+
)
|
|
116
|
+
results.set(call.id, result)
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return results
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// -------------------------------------------------------------------------
|
|
124
|
+
// Private helpers
|
|
125
|
+
// -------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Validate input with the tool's Zod schema, then call `execute`.
|
|
129
|
+
* Any synchronous or asynchronous error is caught and turned into an error
|
|
130
|
+
* ToolResult.
|
|
131
|
+
*/
|
|
132
|
+
private async runTool(
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
134
|
+
tool: ToolDefinition<any>,
|
|
135
|
+
rawInput: Record<string, unknown>,
|
|
136
|
+
context: ToolUseContext,
|
|
137
|
+
): Promise<ToolResult> {
|
|
138
|
+
// --- Zod validation ---
|
|
139
|
+
const parseResult = tool.inputSchema.safeParse(rawInput)
|
|
140
|
+
if (!parseResult.success) {
|
|
141
|
+
const issues = parseResult.error.issues
|
|
142
|
+
.map((issue) => ` • ${issue.path.join('.')}: ${issue.message}`)
|
|
143
|
+
.join('\n')
|
|
144
|
+
return this.errorResult(
|
|
145
|
+
`Invalid input for tool "${tool.name}":\n${issues}`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Abort check after parse (parse can be expensive for large inputs) ---
|
|
150
|
+
if (context.abortSignal?.aborted === true) {
|
|
151
|
+
return this.errorResult(
|
|
152
|
+
`Tool "${tool.name}" was aborted before execution began.`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Execute ---
|
|
157
|
+
try {
|
|
158
|
+
const result = await tool.execute(parseResult.data, context)
|
|
159
|
+
return result
|
|
160
|
+
} catch (err) {
|
|
161
|
+
const message =
|
|
162
|
+
err instanceof Error
|
|
163
|
+
? err.message
|
|
164
|
+
: typeof err === 'string'
|
|
165
|
+
? err
|
|
166
|
+
: JSON.stringify(err)
|
|
167
|
+
return this.errorResult(`Tool "${tool.name}" threw an error: ${message}`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Construct an error ToolResult. */
|
|
172
|
+
private errorResult(message: string): ToolResult {
|
|
173
|
+
return {
|
|
174
|
+
data: message,
|
|
175
|
+
isError: true,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|