@mono-agent/agent-runtime 0.15.3 → 0.15.4

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 (36) hide show
  1. package/README.md +43 -6
  2. package/package.json +5 -1
  3. package/src/agent/tools/agent-tool.js +859 -0
  4. package/src/agent/tools/bash.js +241 -123
  5. package/src/agent/tools/exec.js +238 -0
  6. package/src/agent/tools/index.js +10 -3
  7. package/src/agent/tools/node-repl.js +231 -95
  8. package/src/agent/tools/pi-bridge.js +115 -24
  9. package/src/agent/tools/shared/process-runner.js +162 -0
  10. package/src/agent/tools/shared/semaphore.js +73 -0
  11. package/src/agent/tools/web-browser-render.js +221 -0
  12. package/src/agent/tools/web-controller.js +160 -0
  13. package/src/agent/tools/web-fetch.js +653 -68
  14. package/src/agent/tools/web-search.js +568 -16
  15. package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
  16. package/src/ai/providers/pi-native/turn-runner.js +60 -5
  17. package/src/ai/providers/pi-native.js +49 -5
  18. package/src/ai/runtime/router.js +302 -166
  19. package/src/ai/types.js +52 -1
  20. package/src/runtime.js +51 -1
  21. package/types/agent/tools/agent-tool.d.ts +60 -0
  22. package/types/agent/tools/bash.d.ts +55 -7
  23. package/types/agent/tools/exec.d.ts +53 -0
  24. package/types/agent/tools/index.d.ts +5 -3
  25. package/types/agent/tools/node-repl.d.ts +28 -3
  26. package/types/agent/tools/pi-bridge.d.ts +6 -2
  27. package/types/agent/tools/shared/process-runner.d.ts +33 -0
  28. package/types/agent/tools/shared/semaphore.d.ts +29 -0
  29. package/types/agent/tools/web-browser-render.d.ts +16 -0
  30. package/types/agent/tools/web-controller.d.ts +20 -0
  31. package/types/agent/tools/web-fetch.d.ts +74 -5
  32. package/types/agent/tools/web-search.d.ts +81 -5
  33. package/types/ai/providers/pi-native/turn-runner.d.ts +34 -2
  34. package/types/ai/providers/pi-native.d.ts +12 -0
  35. package/types/ai/runtime/router.d.ts +23 -3
  36. package/types/ai/types.d.ts +163 -1
@@ -7,14 +7,14 @@ import { passthroughSandbox } from "../sandbox-seam.js";
7
7
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
9
9
  import {
10
- bashToolImpl,
10
+ bashToolRun,
11
11
  editToolImpl,
12
+ execToolRun,
12
13
  globToolImpl,
13
14
  grepToolImpl,
14
15
  normalizeBashTimeoutMs,
16
+ normalizeProcessTimeoutMs,
15
17
  readToolImpl,
16
- webFetchToolImpl,
17
- webSearchToolImpl,
18
18
  writeToolImpl,
19
19
  } from "./index.js";
20
20
  import {
@@ -28,6 +28,7 @@ import { wrapToolsWithApprovalGate } from "../approval.js";
28
28
  import { isInsidePath } from "./shared/path-resolver.js";
29
29
  import { readToolRuntime } from "./shared/runtime-context.js";
30
30
  import { resolveSandboxPolicy } from "./shared/tool-context.js";
31
+ import { createAgentTool } from "./agent-tool.js";
31
32
 
32
33
  function textResult(text, details = {}) {
33
34
  return {
@@ -114,7 +115,7 @@ function withAbsolutePaths(name, params, cwd, ctx) {
114
115
  const next = { ...(params || {}) };
115
116
  if (["Read", "Write", "Edit"].includes(name)) next.file_path = absolutizePath(next.file_path, cwd);
116
117
  if (["Glob", "Grep"].includes(name)) next.path = absolutizePath(next.path, cwd);
117
- if (["Read", "Write", "Edit", "Glob", "Grep", "Bash"].includes(name)) {
118
+ if (["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Exec"].includes(name)) {
118
119
  next.workdir = normalizeWorkdir(next.workdir, cwd, ctx);
119
120
  }
120
121
  return next;
@@ -198,9 +199,14 @@ function withToolLimits(name, params, limits = {}) {
198
199
  next.output_mode = next.output_mode || "files_with_matches";
199
200
  delete next.max_matches;
200
201
  }
201
- if (name === "Bash") {
202
+ if (name === "Bash" || name === "Exec") {
203
+ const timeoutLimit = limits.bashTimeoutMs || DEFAULT_BASH_TIMEOUT_MS;
202
204
  next.max_output_chars = limitedNumber(next.max_output_chars, limits.bashOutputLimitChars || limits.toolTextLimitChars || 20000);
203
- next.timeout = normalizeBashTimeoutMs(next.timeout, limits.bashTimeoutMs || DEFAULT_BASH_TIMEOUT_MS);
205
+ if (name === "Bash" && next.timeout_ms === undefined && next.timeout !== undefined) {
206
+ next.timeout = normalizeBashTimeoutMs(next.timeout, timeoutLimit);
207
+ } else {
208
+ next.timeout_ms = normalizeProcessTimeoutMs(next.timeout_ms, timeoutLimit);
209
+ }
204
210
  }
205
211
  return next;
206
212
  }
@@ -238,21 +244,41 @@ function isReadOnlyShellCommand(command) {
238
244
  ].some((pattern) => pattern.test(text));
239
245
  }
240
246
 
247
+ const ALWAYS_SEQUENTIAL_BUILTINS = new Set(["Write", "Edit", "Bash", "Exec", "NodeRepl"]);
248
+ const SENSITIVE_RESULT_PARAMS = new Set(["Bash", "Exec", "WebFetch", "WebSearch"]);
249
+
250
+ function isStructuredToolRun(value) {
251
+ return Boolean(value)
252
+ && typeof value === "object"
253
+ && typeof value.text === "string"
254
+ && value.outcome
255
+ && typeof value.outcome === "object";
256
+ }
257
+
241
258
  /**
242
259
  * @param {any} name
243
260
  * @param {any} label
244
261
  * @param {any} description
245
262
  * @param {any} parameters
246
263
  * @param {any} execute
247
- * @param {{cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
264
+ * @param {{cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, forceSequential?: boolean}} [options]
248
265
  */
249
- function createBuiltinTool(name, label, description, parameters, execute, { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine, ctx } = {}) {
266
+ function createBuiltinTool(name, label, description, parameters, execute, {
267
+ cwd,
268
+ onEvent,
269
+ toolLimits,
270
+ toolPolicy,
271
+ sandboxPolicy,
272
+ sandboxEngine,
273
+ ctx,
274
+ forceSequential = false,
275
+ } = {}) {
250
276
  return {
251
277
  name,
252
278
  label,
253
279
  description,
254
280
  parameters,
255
- executionMode: name === "Write" || name === "Edit" || name === "Bash" || name === "NodeRepl" ? "sequential" : undefined,
281
+ executionMode: forceSequential || ALWAYS_SEQUENTIAL_BUILTINS.has(name) ? "sequential" : undefined,
256
282
  async execute(toolCallId, params, signal) {
257
283
  if (signal?.aborted) throw new Error("tool execution aborted");
258
284
  const normalized = normalizePiBuiltinToolParams(name, params, { cwd, toolLimits, ctx });
@@ -268,9 +294,21 @@ function createBuiltinTool(name, label, description, parameters, execute, { cwd,
268
294
  if (isImageToolResult(raw)) {
269
295
  return imageResult(raw.data, raw.mimeType, { tool: name, params: normalized });
270
296
  }
271
- const text = toolText(raw);
272
- if (isErrorText(text)) throw new Error(text);
273
- const details = { tool: name, params: normalized };
297
+ const structured = isStructuredToolRun(raw) ? raw : null;
298
+ const text = structured ? structured.text : toolText(raw);
299
+ if (!structured && isErrorText(text)) throw new Error(text);
300
+ if (structured?.outcome?.legacyTimeoutUsed) {
301
+ onEvent?.({
302
+ type: "runtime_warning",
303
+ warning_kind: "deprecated_bash_timeout",
304
+ message: "Bash.timeout is deprecated; use timeout_ms for exact millisecond semantics.",
305
+ });
306
+ }
307
+ const details = /** @type {any} */ ({
308
+ tool: name,
309
+ ...(SENSITIVE_RESULT_PARAMS.has(name) ? {} : { params: normalized }),
310
+ ...(structured ? { outcome: structured.outcome } : {}),
311
+ });
274
312
  if (shouldTrackWrite) {
275
313
  details.file_change = writeFileChangeDetails(normalized.file_path, beforeWrite, readFileChangeSnapshot(normalized.file_path));
276
314
  }
@@ -379,7 +417,7 @@ export function createStructuredOutputTool(outputSchema, onStructuredOutput) {
379
417
 
380
418
  /**
381
419
  * @param {any} allowedTools
382
- * @param {{disallowedTools?: any[], skillNames?: any[], skills?: any[], skillsRoot?: any, dataDir?: any, cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, persistArtifact?: any, onTruncate?: any, toolPayloadMaxBytes?: number, imageInlineMaxBytes?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, approvalManager?: any, approvalModel?: any, nodeReplController?: any, ctx?: any}} [options]
420
+ * @param {{disallowedTools?: any[], skillNames?: any[], skills?: any[], skillsRoot?: any, dataDir?: any, cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, persistArtifact?: any, onTruncate?: any, toolPayloadMaxBytes?: number, imageInlineMaxBytes?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, approvalManager?: any, approvalModel?: any, nodeReplController?: any, webController?: any, toolExecutionMode?: "sequential"|"safe-parallel", subagents?: any, subagentContext?: any, ctx?: any}} [options]
383
421
  */
384
422
  export function getPiBuiltinTools(allowedTools, {
385
423
  disallowedTools = [],
@@ -400,17 +438,35 @@ export function getPiBuiltinTools(allowedTools, {
400
438
  approvalManager = null,
401
439
  approvalModel = null,
402
440
  nodeReplController = null,
441
+ webController = null,
442
+ subagents = null,
443
+ subagentContext = null,
444
+ toolExecutionMode = "safe-parallel",
403
445
  ctx = null,
404
446
  } = {}) {
405
447
  const textLimitSchema = integerSchema();
406
448
  const bashLimitSchema = integerSchema();
407
- const bashTimeoutSchema = {
449
+ const legacyBashTimeoutSchema = {
450
+ type: "integer",
451
+ description: "Deprecated compatibility timeout. Values up to 600 mean seconds and larger values mean milliseconds; use timeout_ms instead.",
452
+ };
453
+ const processTimeoutSchema = {
408
454
  type: "integer",
409
- description: "Timeout in milliseconds. Use 30000 for 30 seconds; small values like 30 are treated as seconds for compatibility.",
455
+ minimum: 1,
456
+ description: "Exact timeout in milliseconds.",
410
457
  };
411
458
  // Per-tool closure config (cwd/event sink/limits/policy) plus the per-instance
412
459
  // ToolContext `ctx` that the tool impls and shared helpers read from.
413
- const toolContext = { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine, ctx };
460
+ const toolContext = {
461
+ cwd,
462
+ onEvent,
463
+ toolLimits,
464
+ toolPolicy,
465
+ sandboxPolicy,
466
+ sandboxEngine,
467
+ forceSequential: toolExecutionMode === "sequential",
468
+ ctx,
469
+ };
414
470
  const all = {
415
471
  Read: createBuiltinTool("Read", "Read", "Read a local file. Text files return line-numbered content; image files (PNG, JPEG, GIF, WebP, BMP) are returned as a viewable image you can see directly — use this to look at image attachments.", objectSchema({
416
472
  file_path: { type: "string" },
@@ -451,32 +507,64 @@ export function getPiBuiltinTools(allowedTools, {
451
507
  max_matches: { type: "integer" },
452
508
  max_output_chars: textLimitSchema,
453
509
  }, ["pattern"]), grepToolImpl, toolContext),
454
- Bash: createBuiltinTool("Bash", "Bash", "Execute a shell command in the workspace.", objectSchema({
510
+ Bash: createBuiltinTool("Bash", "Bash", "Execute a shell command for pipelines, redirection, conditionals, or other shell syntax. Prefer Exec for one executable with an argv array. This is macOS: do not assume GNU-only commands or flags.", objectSchema({
455
511
  command: { type: "string" },
456
512
  workdir: { type: "string" },
457
513
  description: { type: "string" },
458
- timeout: bashTimeoutSchema,
514
+ timeout_ms: processTimeoutSchema,
515
+ timeout: legacyBashTimeoutSchema,
516
+ max_output_chars: bashLimitSchema,
517
+ }, ["command"]), bashToolRun, toolContext),
518
+ Exec: createBuiltinTool("Exec", "Exec", "Execute one program directly from an argv array without shell parsing. Prefer this for ordinary commands; use Bash only when shell syntax is required.", objectSchema({
519
+ executable: { type: "string", minLength: 1 },
520
+ args: { type: "array", items: { type: "string" }, maxItems: 256 },
521
+ workdir: { type: "string" },
522
+ timeout_ms: processTimeoutSchema,
459
523
  max_output_chars: bashLimitSchema,
460
- }, ["command"]), bashToolImpl, toolContext),
524
+ }, ["executable"]), execToolRun, toolContext),
461
525
  NodeRepl: nodeReplController
462
526
  ? createBuiltinTool(
463
527
  "NodeRepl",
464
528
  "Node REPL",
465
529
  "Evaluate JavaScript in a run-scoped Node.js REPL. Variables persist across NodeRepl calls in this run.",
466
530
  objectSchema({ code: { type: "string", minLength: 1 } }, ["code"]),
467
- (params, { signal }) => nodeReplController.execute(params, { signal }),
531
+ (params, { signal }) => typeof nodeReplController.executeDetailed === "function"
532
+ ? nodeReplController.executeDetailed(params, { signal })
533
+ : nodeReplController.execute(params, { signal }),
468
534
  toolContext,
469
535
  )
470
536
  : null,
471
- WebFetch: createBuiltinTool("WebFetch", "Web Fetch", "Fetch a URL and return text.", objectSchema({
537
+ // Built directly (not via createBuiltinTool) so a subagent answer starting
538
+ // with "Error:" is not reclassified as a tool failure, discarding its log.
539
+ Agent: createAgentTool(subagents, { onEvent, ...(subagentContext || {}) }),
540
+ WebFetch: createBuiltinTool("WebFetch", "Web Fetch", "Fetch and extract one HTTP(S) URL locally. Static extraction is preferred; browser rendering is available only through the configured render policy.", objectSchema({
472
541
  url: { type: "string" },
473
542
  headers: { type: "object", additionalProperties: { type: "string" } },
474
543
  max_output_chars: textLimitSchema,
475
- }, ["url"]), webFetchToolImpl, toolContext),
476
- WebSearch: createBuiltinTool("WebSearch", "Web Search", "Search the web and return result summaries.", objectSchema({
544
+ format: { type: "string", enum: ["markdown", "text", "raw"] },
545
+ render: { type: "string", enum: ["never", "auto", "always"] },
546
+ }, ["url"]), webController
547
+ ? (params, execution) => webController.fetch(params, execution)
548
+ : async () => ({
549
+ text: "Error: WebFetch controller is unavailable.",
550
+ outcome: { status: "error", code: "controller_unavailable", retryable: false, attempts: 0 },
551
+ error: true,
552
+ }), toolContext),
553
+ WebSearch: createBuiltinTool("WebSearch", "Web Search", "Search the public web with an operator-owned SearXNG endpoint when available, otherwise a keyless fallback, and return deduplicated ranked results.", objectSchema({
477
554
  query: { type: "string" },
478
555
  limit: { type: "integer" },
479
- }, ["query"]), webSearchToolImpl, toolContext),
556
+ alternate_queries: { type: "array", items: { type: "string" }, maxItems: 3 },
557
+ domains: { type: "array", items: { type: "string" } },
558
+ exclude_domains: { type: "array", items: { type: "string" } },
559
+ language: { type: "string" },
560
+ time_range: { type: "string", enum: ["day", "month", "year"] },
561
+ }, ["query"]), webController
562
+ ? (params, execution) => webController.search(params, execution)
563
+ : async () => ({
564
+ text: "Error: WebSearch controller is unavailable.",
565
+ outcome: { status: "error", code: "controller_unavailable", retryable: false, attempts: 0 },
566
+ error: true,
567
+ }), toolContext),
480
568
  };
481
569
  // allowedTools honors the `"*"` allow-all sentinel (and undefined) as "every
482
570
  // built-in"; disallowedTools is the deny-wins filter applied to the final set.
@@ -489,6 +577,9 @@ export function getPiBuiltinTools(allowedTools, {
489
577
  // Deny-check the canonical PascalCase name AND the legacy snake_case alias so
490
578
  // an old denylist keeps disabling the tool after the rename.
491
579
  if (skillTool && !denied.has("ReadSkill") && !denied.has("read_skill" /* legacy alias */)) tools.push(skillTool);
580
+ if (toolExecutionMode === "sequential") {
581
+ for (const tool of tools) tool.executionMode = "sequential";
582
+ }
492
583
  const gated = approvalManager
493
584
  ? wrapToolsWithApprovalGate(tools, approvalManager, { model: approvalModel })
494
585
  : tools;
@@ -0,0 +1,162 @@
1
+ // @ts-check
2
+
3
+ import { spawn } from "node:child_process";
4
+
5
+ export const DEFAULT_PROCESS_BUFFER_BYTES = 8 * 1024 * 1024;
6
+ const KILL_GRACE_MS = 1_000;
7
+
8
+ /**
9
+ * Run one already-prepared executable without adding a shell.
10
+ *
11
+ * The result is deliberately loss-aware: stdout/stderr are retained up to the
12
+ * shared byte cap even when the child times out, is aborted, exits by signal,
13
+ * or exceeds that cap.
14
+ *
15
+ * @param {{command: string, args?: string[], cwd?: string, env?: Record<string, string|undefined>}} commandSpec
16
+ * @param {{timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number}} [options]
17
+ */
18
+ export function runPreparedProcess(
19
+ commandSpec,
20
+ {
21
+ timeoutMs,
22
+ signal,
23
+ maxBufferBytes = DEFAULT_PROCESS_BUFFER_BYTES,
24
+ } = {},
25
+ ) {
26
+ const startedAt = Date.now();
27
+ return new Promise((resolve) => {
28
+ let child;
29
+ try {
30
+ const env = commandSpec.env ? mergedProcessEnv(commandSpec.env) : process.env;
31
+ child = spawn(commandSpec.command, commandSpec.args || [], {
32
+ cwd: commandSpec.cwd,
33
+ detached: process.platform !== "win32",
34
+ env,
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ });
37
+ } catch (error) {
38
+ resolve({
39
+ code: null,
40
+ signal: null,
41
+ stdout: "",
42
+ stderr: "",
43
+ aborted: false,
44
+ timedOut: false,
45
+ bufferExceeded: false,
46
+ truncated: false,
47
+ bytes: 0,
48
+ storedBytes: 0,
49
+ spawnError: error,
50
+ durationMs: Date.now() - startedAt,
51
+ });
52
+ return;
53
+ }
54
+
55
+ const stdout = [];
56
+ const stderr = [];
57
+ const state = {
58
+ aborted: false,
59
+ bufferExceeded: false,
60
+ bytes: 0,
61
+ storedBytes: 0,
62
+ spawnError: null,
63
+ timedOut: false,
64
+ truncated: false,
65
+ };
66
+ let killTimer = null;
67
+ let timeoutTimer = null;
68
+ let settled = false;
69
+
70
+ function terminate() {
71
+ killProcessGroup(child, "SIGTERM");
72
+ if (killTimer === null) {
73
+ killTimer = setTimeout(() => killProcessGroup(child, "SIGKILL"), KILL_GRACE_MS);
74
+ killTimer.unref?.();
75
+ }
76
+ }
77
+
78
+ function append(target, chunk) {
79
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
80
+ state.bytes += buffer.length;
81
+ const remaining = Math.max(0, maxBufferBytes - state.storedBytes);
82
+ if (remaining > 0) {
83
+ const stored = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer;
84
+ target.push(stored);
85
+ state.storedBytes += stored.length;
86
+ }
87
+ if (buffer.length > remaining) {
88
+ state.bufferExceeded = true;
89
+ state.truncated = true;
90
+ terminate();
91
+ }
92
+ }
93
+
94
+ if (Number.isFinite(timeoutMs) && Number(timeoutMs) > 0) {
95
+ timeoutTimer = setTimeout(() => {
96
+ state.timedOut = true;
97
+ terminate();
98
+ }, Number(timeoutMs));
99
+ timeoutTimer.unref?.();
100
+ }
101
+
102
+ const onAbort = () => {
103
+ state.aborted = true;
104
+ terminate();
105
+ };
106
+ if (signal?.aborted) onAbort();
107
+ else signal?.addEventListener?.("abort", onAbort, { once: true });
108
+
109
+ child.stdout?.on("data", (chunk) => append(stdout, chunk));
110
+ child.stderr?.on("data", (chunk) => append(stderr, chunk));
111
+ child.once("error", (error) => {
112
+ state.spawnError = error;
113
+ });
114
+ child.once("close", (code, closeSignal) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ if (timeoutTimer !== null) clearTimeout(timeoutTimer);
118
+ if (killTimer !== null) clearTimeout(killTimer);
119
+ signal?.removeEventListener?.("abort", onAbort);
120
+ resolve({
121
+ code,
122
+ signal: closeSignal,
123
+ stdout: Buffer.concat(stdout).toString("utf8"),
124
+ stderr: Buffer.concat(stderr).toString("utf8"),
125
+ ...state,
126
+ durationMs: Date.now() - startedAt,
127
+ });
128
+ });
129
+ });
130
+ }
131
+
132
+ function mergedProcessEnv(overrides) {
133
+ const env = { ...process.env };
134
+ for (const [key, value] of Object.entries(overrides)) {
135
+ if (value === undefined) delete env[key];
136
+ else env[key] = value;
137
+ }
138
+ return env;
139
+ }
140
+
141
+ /**
142
+ * @param {import("node:child_process").ChildProcess} child
143
+ * @param {NodeJS.Signals} signal
144
+ */
145
+ export function killProcessGroup(child, signal) {
146
+ if (!child?.pid) return;
147
+ try {
148
+ process.kill(process.platform === "win32" ? child.pid : -child.pid, signal);
149
+ } catch {
150
+ try { process.kill(child.pid, signal); } catch { /* already gone */ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * @param {{stdout?: string, stderr?: string}} result
156
+ */
157
+ export function combinedProcessOutput(result) {
158
+ const stdout = String(result.stdout || "");
159
+ const stderr = String(result.stderr || "");
160
+ if (stdout && stderr) return `STDOUT:\n${stdout}\nSTDERR:\n${stderr}`;
161
+ return stdout || stderr || "(no output)";
162
+ }
@@ -0,0 +1,73 @@
1
+ // FIFO counting semaphore for run-scoped tool concurrency.
2
+ //
3
+ // agent-harness owns the equivalent helper for provider-execution width, but
4
+ // the kernel cannot depend on the harness, so the shape is duplicated here for
5
+ // the in-process tools that need to bound their own fan-out.
6
+
7
+ // @ts-check
8
+
9
+ /**
10
+ * @typedef {Object} CountingSemaphore
11
+ * @property {(signal?: AbortSignal) => Promise<() => void>} acquire Resolves with
12
+ * a single-use release function once a slot is free. Rejects if `signal`
13
+ * aborts while queued; an already-acquired slot is never leaked.
14
+ * @property {() => number} inFlight Slots currently held.
15
+ * @property {() => number} queued Waiters not yet admitted.
16
+ */
17
+
18
+ /**
19
+ * @param {number} limit Maximum simultaneous holders. Values below 1 are clamped.
20
+ * @returns {CountingSemaphore}
21
+ */
22
+ export function createCountingSemaphore(limit) {
23
+ const max = Number.isInteger(limit) && limit > 0 ? limit : 1;
24
+ let active = 0;
25
+ /** @type {Array<{resolve: (release: () => void) => void, reject: (error: Error) => void, settled: boolean}>} */
26
+ const waiters = [];
27
+
28
+ const release = () => {
29
+ // A release function is single-use: a double call would hand out a slot the
30
+ // holder no longer owns and let the limit drift upward for the whole run.
31
+ let released = false;
32
+ return () => {
33
+ if (released) return;
34
+ released = true;
35
+ active -= 1;
36
+ pump();
37
+ };
38
+ };
39
+
40
+ const pump = () => {
41
+ while (active < max && waiters.length > 0) {
42
+ const waiter = /** @type {*} */ (waiters.shift());
43
+ if (waiter.settled) continue;
44
+ waiter.settled = true;
45
+ active += 1;
46
+ waiter.resolve(release());
47
+ }
48
+ };
49
+
50
+ return {
51
+ acquire(signal) {
52
+ if (signal?.aborted) {
53
+ return Promise.reject(new Error("tool execution aborted"));
54
+ }
55
+ if (active < max) {
56
+ active += 1;
57
+ return Promise.resolve(release());
58
+ }
59
+ return new Promise((resolve, reject) => {
60
+ /** @type {*} */
61
+ const waiter = { resolve, reject, settled: false };
62
+ waiters.push(waiter);
63
+ signal?.addEventListener("abort", () => {
64
+ if (waiter.settled) return;
65
+ waiter.settled = true;
66
+ reject(new Error("tool execution aborted"));
67
+ }, { once: true });
68
+ });
69
+ },
70
+ inFlight: () => active,
71
+ queued: () => waiters.filter((waiter) => !waiter.settled).length,
72
+ };
73
+ }