@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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +280 -0
  3. package/dist/agent/agent.d.ts +121 -0
  4. package/dist/agent/agent.d.ts.map +1 -0
  5. package/dist/agent/agent.js +294 -0
  6. package/dist/agent/agent.js.map +1 -0
  7. package/dist/agent/pool.d.ts +128 -0
  8. package/dist/agent/pool.d.ts.map +1 -0
  9. package/dist/agent/pool.js +236 -0
  10. package/dist/agent/pool.js.map +1 -0
  11. package/dist/agent/runner.d.ts +120 -0
  12. package/dist/agent/runner.d.ts.map +1 -0
  13. package/dist/agent/runner.js +274 -0
  14. package/dist/agent/runner.js.map +1 -0
  15. package/dist/index.d.ts +73 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +87 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/llm/adapter.d.ts +38 -0
  20. package/dist/llm/adapter.d.ts.map +1 -0
  21. package/dist/llm/adapter.js +46 -0
  22. package/dist/llm/adapter.js.map +1 -0
  23. package/dist/llm/anthropic.d.ts +56 -0
  24. package/dist/llm/anthropic.d.ts.map +1 -0
  25. package/dist/llm/anthropic.js +307 -0
  26. package/dist/llm/anthropic.js.map +1 -0
  27. package/dist/llm/openai.d.ts +62 -0
  28. package/dist/llm/openai.d.ts.map +1 -0
  29. package/dist/llm/openai.js +424 -0
  30. package/dist/llm/openai.js.map +1 -0
  31. package/dist/memory/shared.d.ts +86 -0
  32. package/dist/memory/shared.d.ts.map +1 -0
  33. package/dist/memory/shared.js +155 -0
  34. package/dist/memory/shared.js.map +1 -0
  35. package/dist/memory/store.d.ts +64 -0
  36. package/dist/memory/store.d.ts.map +1 -0
  37. package/dist/memory/store.js +103 -0
  38. package/dist/memory/store.js.map +1 -0
  39. package/dist/orchestrator/orchestrator.d.ts +173 -0
  40. package/dist/orchestrator/orchestrator.d.ts.map +1 -0
  41. package/dist/orchestrator/orchestrator.js +698 -0
  42. package/dist/orchestrator/orchestrator.js.map +1 -0
  43. package/dist/orchestrator/scheduler.d.ts +112 -0
  44. package/dist/orchestrator/scheduler.d.ts.map +1 -0
  45. package/dist/orchestrator/scheduler.js +282 -0
  46. package/dist/orchestrator/scheduler.js.map +1 -0
  47. package/dist/task/queue.d.ts +160 -0
  48. package/dist/task/queue.d.ts.map +1 -0
  49. package/dist/task/queue.js +337 -0
  50. package/dist/task/queue.js.map +1 -0
  51. package/dist/task/task.d.ts +86 -0
  52. package/dist/task/task.d.ts.map +1 -0
  53. package/dist/task/task.js +201 -0
  54. package/dist/task/task.js.map +1 -0
  55. package/dist/team/messaging.d.ts +106 -0
  56. package/dist/team/messaging.d.ts.map +1 -0
  57. package/dist/team/messaging.js +182 -0
  58. package/dist/team/messaging.js.map +1 -0
  59. package/dist/team/team.d.ts +141 -0
  60. package/dist/team/team.d.ts.map +1 -0
  61. package/dist/team/team.js +282 -0
  62. package/dist/team/team.js.map +1 -0
  63. package/dist/tool/built-in/bash.d.ts +12 -0
  64. package/dist/tool/built-in/bash.d.ts.map +1 -0
  65. package/dist/tool/built-in/bash.js +133 -0
  66. package/dist/tool/built-in/bash.js.map +1 -0
  67. package/dist/tool/built-in/file-edit.d.ts +14 -0
  68. package/dist/tool/built-in/file-edit.d.ts.map +1 -0
  69. package/dist/tool/built-in/file-edit.js +130 -0
  70. package/dist/tool/built-in/file-edit.js.map +1 -0
  71. package/dist/tool/built-in/file-read.d.ts +12 -0
  72. package/dist/tool/built-in/file-read.d.ts.map +1 -0
  73. package/dist/tool/built-in/file-read.js +82 -0
  74. package/dist/tool/built-in/file-read.js.map +1 -0
  75. package/dist/tool/built-in/file-write.d.ts +11 -0
  76. package/dist/tool/built-in/file-write.d.ts.map +1 -0
  77. package/dist/tool/built-in/file-write.js +70 -0
  78. package/dist/tool/built-in/file-write.js.map +1 -0
  79. package/dist/tool/built-in/grep.d.ts +15 -0
  80. package/dist/tool/built-in/grep.d.ts.map +1 -0
  81. package/dist/tool/built-in/grep.js +287 -0
  82. package/dist/tool/built-in/grep.js.map +1 -0
  83. package/dist/tool/built-in/index.d.ts +36 -0
  84. package/dist/tool/built-in/index.d.ts.map +1 -0
  85. package/dist/tool/built-in/index.js +45 -0
  86. package/dist/tool/built-in/index.js.map +1 -0
  87. package/dist/tool/executor.d.ts +71 -0
  88. package/dist/tool/executor.d.ts.map +1 -0
  89. package/dist/tool/executor.js +116 -0
  90. package/dist/tool/executor.js.map +1 -0
  91. package/dist/tool/framework.d.ts +143 -0
  92. package/dist/tool/framework.d.ts.map +1 -0
  93. package/dist/tool/framework.js +371 -0
  94. package/dist/tool/framework.js.map +1 -0
  95. package/dist/types.d.ts +285 -0
  96. package/dist/types.d.ts.map +1 -0
  97. package/dist/types.js +8 -0
  98. package/dist/types.js.map +1 -0
  99. package/dist/utils/semaphore.d.ts +47 -0
  100. package/dist/utils/semaphore.d.ts.map +1 -0
  101. package/dist/utils/semaphore.js +85 -0
  102. package/dist/utils/semaphore.js.map +1 -0
  103. package/examples/01-single-agent.ts +131 -0
  104. package/examples/02-team-collaboration.ts +167 -0
  105. package/examples/03-task-pipeline.ts +201 -0
  106. package/examples/04-multi-model-team.ts +261 -0
  107. package/package.json +49 -0
  108. package/src/agent/agent.ts +364 -0
  109. package/src/agent/pool.ts +278 -0
  110. package/src/agent/runner.ts +413 -0
  111. package/src/index.ts +166 -0
  112. package/src/llm/adapter.ts +74 -0
  113. package/src/llm/anthropic.ts +388 -0
  114. package/src/llm/openai.ts +522 -0
  115. package/src/memory/shared.ts +181 -0
  116. package/src/memory/store.ts +124 -0
  117. package/src/orchestrator/orchestrator.ts +851 -0
  118. package/src/orchestrator/scheduler.ts +352 -0
  119. package/src/task/queue.ts +394 -0
  120. package/src/task/task.ts +232 -0
  121. package/src/team/messaging.ts +230 -0
  122. package/src/team/team.ts +334 -0
  123. package/src/tool/built-in/bash.ts +187 -0
  124. package/src/tool/built-in/file-edit.ts +154 -0
  125. package/src/tool/built-in/file-read.ts +105 -0
  126. package/src/tool/built-in/file-write.ts +81 -0
  127. package/src/tool/built-in/grep.ts +362 -0
  128. package/src/tool/built-in/index.ts +50 -0
  129. package/src/tool/executor.ts +178 -0
  130. package/src/tool/framework.ts +557 -0
  131. package/src/types.ts +362 -0
  132. package/src/utils/semaphore.ts +89 -0
  133. 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
+ }