@dogpile/sdk 0.4.0 → 0.6.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 (108) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/browser/index.js +4156 -4611
  3. package/dist/browser/index.js.map +1 -1
  4. package/dist/index.d.ts +3 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/providers/openai-compatible.d.ts.map +1 -1
  9. package/dist/providers/openai-compatible.js +6 -1
  10. package/dist/providers/openai-compatible.js.map +1 -1
  11. package/dist/runtime/audit.d.ts +42 -0
  12. package/dist/runtime/audit.d.ts.map +1 -0
  13. package/dist/runtime/audit.js +73 -0
  14. package/dist/runtime/audit.js.map +1 -0
  15. package/dist/runtime/broadcast.d.ts +1 -0
  16. package/dist/runtime/broadcast.d.ts.map +1 -1
  17. package/dist/runtime/broadcast.js +171 -105
  18. package/dist/runtime/broadcast.js.map +1 -1
  19. package/dist/runtime/coordinator.d.ts +9 -2
  20. package/dist/runtime/coordinator.d.ts.map +1 -1
  21. package/dist/runtime/coordinator.js +164 -78
  22. package/dist/runtime/coordinator.js.map +1 -1
  23. package/dist/runtime/defaults.d.ts.map +1 -1
  24. package/dist/runtime/defaults.js +14 -5
  25. package/dist/runtime/defaults.js.map +1 -1
  26. package/dist/runtime/engine.d.ts +17 -4
  27. package/dist/runtime/engine.d.ts.map +1 -1
  28. package/dist/runtime/engine.js +577 -52
  29. package/dist/runtime/engine.js.map +1 -1
  30. package/dist/runtime/health.d.ts +51 -0
  31. package/dist/runtime/health.d.ts.map +1 -0
  32. package/dist/runtime/health.js +85 -0
  33. package/dist/runtime/health.js.map +1 -0
  34. package/dist/runtime/introspection.d.ts +96 -0
  35. package/dist/runtime/introspection.d.ts.map +1 -0
  36. package/dist/runtime/introspection.js +31 -0
  37. package/dist/runtime/introspection.js.map +1 -0
  38. package/dist/runtime/metrics.d.ts +44 -0
  39. package/dist/runtime/metrics.d.ts.map +1 -0
  40. package/dist/runtime/metrics.js +12 -0
  41. package/dist/runtime/metrics.js.map +1 -0
  42. package/dist/runtime/model.d.ts.map +1 -1
  43. package/dist/runtime/model.js +40 -10
  44. package/dist/runtime/model.js.map +1 -1
  45. package/dist/runtime/provenance.d.ts +25 -0
  46. package/dist/runtime/provenance.d.ts.map +1 -0
  47. package/dist/runtime/provenance.js +13 -0
  48. package/dist/runtime/provenance.js.map +1 -0
  49. package/dist/runtime/redaction.d.ts +13 -0
  50. package/dist/runtime/redaction.d.ts.map +1 -0
  51. package/dist/runtime/redaction.js +278 -0
  52. package/dist/runtime/redaction.js.map +1 -0
  53. package/dist/runtime/sanitization.d.ts +4 -0
  54. package/dist/runtime/sanitization.d.ts.map +1 -0
  55. package/dist/runtime/sanitization.js +63 -0
  56. package/dist/runtime/sanitization.js.map +1 -0
  57. package/dist/runtime/sequential.d.ts.map +1 -1
  58. package/dist/runtime/sequential.js +39 -36
  59. package/dist/runtime/sequential.js.map +1 -1
  60. package/dist/runtime/shared.d.ts +1 -0
  61. package/dist/runtime/shared.d.ts.map +1 -1
  62. package/dist/runtime/shared.js +167 -101
  63. package/dist/runtime/shared.js.map +1 -1
  64. package/dist/runtime/tools/built-in.d.ts +2 -0
  65. package/dist/runtime/tools/built-in.d.ts.map +1 -1
  66. package/dist/runtime/tools/built-in.js +153 -15
  67. package/dist/runtime/tools/built-in.js.map +1 -1
  68. package/dist/runtime/tools.d.ts.map +1 -1
  69. package/dist/runtime/tools.js +29 -7
  70. package/dist/runtime/tools.js.map +1 -1
  71. package/dist/runtime/tracing.d.ts +31 -0
  72. package/dist/runtime/tracing.d.ts.map +1 -0
  73. package/dist/runtime/tracing.js +18 -0
  74. package/dist/runtime/tracing.js.map +1 -0
  75. package/dist/runtime/validation.d.ts.map +1 -1
  76. package/dist/runtime/validation.js +3 -0
  77. package/dist/runtime/validation.js.map +1 -1
  78. package/dist/types/events.d.ts +13 -7
  79. package/dist/types/events.d.ts.map +1 -1
  80. package/dist/types/replay.d.ts +5 -1
  81. package/dist/types/replay.d.ts.map +1 -1
  82. package/dist/types.d.ts +144 -1
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/package.json +46 -1
  86. package/src/index.ts +5 -0
  87. package/src/providers/openai-compatible.ts +6 -1
  88. package/src/runtime/audit.ts +121 -0
  89. package/src/runtime/broadcast.ts +195 -108
  90. package/src/runtime/coordinator.ts +197 -86
  91. package/src/runtime/defaults.ts +15 -5
  92. package/src/runtime/engine.ts +725 -58
  93. package/src/runtime/health.ts +136 -0
  94. package/src/runtime/introspection.ts +122 -0
  95. package/src/runtime/metrics.ts +45 -0
  96. package/src/runtime/model.ts +44 -9
  97. package/src/runtime/provenance.ts +43 -0
  98. package/src/runtime/redaction.ts +355 -0
  99. package/src/runtime/sanitization.ts +81 -0
  100. package/src/runtime/sequential.ts +40 -37
  101. package/src/runtime/shared.ts +191 -104
  102. package/src/runtime/tools/built-in.ts +168 -15
  103. package/src/runtime/tools.ts +39 -8
  104. package/src/runtime/tracing.ts +35 -0
  105. package/src/runtime/validation.ts +3 -0
  106. package/src/types/events.ts +13 -7
  107. package/src/types/replay.ts +5 -1
  108. package/src/types.ts +152 -1
@@ -16,6 +16,7 @@ import type {
16
16
  TerminationCondition,
17
17
  TerminationStopRecord,
18
18
  Tier,
19
+ Trace,
19
20
  TranscriptEntry
20
21
  } from "../types.js";
21
22
  import { createRunId, elapsedMs, nowMs, providerCallIdFor } from "./ids.js";
@@ -34,6 +35,7 @@ import {
34
35
  createTranscriptLink,
35
36
  emptyCost
36
37
  } from "./defaults.js";
38
+ import { computeHealth, DEFAULT_HEALTH_THRESHOLDS } from "./health.js";
37
39
  import { throwIfAborted } from "./cancellation.js";
38
40
  import { parseAgentDecision } from "./decisions.js";
39
41
  import { generateModelTurn } from "./model.js";
@@ -54,9 +56,12 @@ interface SharedRunOptions {
54
56
  readonly signal?: AbortSignal;
55
57
  readonly terminate?: TerminationCondition;
56
58
  readonly wrapUpHint?: DogpileOptions["wrapUpHint"];
59
+ readonly maxConcurrentAgentTurns?: number;
57
60
  readonly emit?: (event: RunEvent) => void;
58
61
  }
59
62
 
63
+ const DEFAULT_MAX_CONCURRENT_AGENT_TURNS = 4;
64
+
60
65
  export async function runShared(options: SharedRunOptions): Promise<RunResult> {
61
66
  const runId = createRunId();
62
67
  const events: RunEvent[] = [];
@@ -122,77 +127,88 @@ export async function runShared(options: SharedRunOptions): Promise<RunResult> {
122
127
 
123
128
  if (!stopIfNeeded()) {
124
129
  const providerCallSlots: ReplayTraceProviderCall[] = [];
125
- const turnResults = await Promise.all(
126
- activeAgents.map(async (agent, index) => {
127
- const turn = index + 1;
128
- const input = buildSharedInput(options.intent, sharedState, turn);
129
- const request: ModelRequest = {
130
- temperature: options.temperature,
131
- ...(options.signal !== undefined ? { signal: options.signal } : {}),
132
- metadata: {
133
- runId,
134
- protocol: "shared",
135
- agentId: agent.id,
136
- role: agent.role,
137
- tier: options.tier,
138
- turn,
139
- ...toolAvailability
140
- },
141
- messages: wrapUpHint.inject(
142
- [
143
- {
144
- role: "system",
145
- content: buildSystemPrompt(agent)
130
+ const fanout = createFanoutAbortController(options.signal);
131
+ const turnResults = await (async () => {
132
+ try {
133
+ return await mapWithConcurrency(
134
+ activeAgents,
135
+ options.maxConcurrentAgentTurns ?? DEFAULT_MAX_CONCURRENT_AGENT_TURNS,
136
+ fanout,
137
+ async (agent, index) => {
138
+ throwIfAborted(fanout.signal, options.model.id);
139
+ const turn = index + 1;
140
+ const input = buildSharedInput(options.intent, sharedState, turn);
141
+ const request: ModelRequest = {
142
+ temperature: options.temperature,
143
+ signal: fanout.signal,
144
+ metadata: {
145
+ runId,
146
+ protocol: "shared",
147
+ agentId: agent.id,
148
+ role: agent.role,
149
+ tier: options.tier,
150
+ turn,
151
+ ...toolAvailability
146
152
  },
147
- {
148
- role: "user",
149
- content: input
150
- }
151
- ],
152
- {
153
+ messages: wrapUpHint.inject(
154
+ [
155
+ {
156
+ role: "system",
157
+ content: buildSystemPrompt(agent)
158
+ },
159
+ {
160
+ role: "user",
161
+ content: input
162
+ }
163
+ ],
164
+ {
165
+ runId,
166
+ protocol: "shared",
167
+ cost: totalCost,
168
+ events,
169
+ transcript,
170
+ iteration: transcript.length,
171
+ elapsedMs: elapsedMs(startedAtMs)
172
+ }
173
+ )
174
+ };
175
+ const response = await generateModelTurn({
176
+ model: options.model,
177
+ request,
153
178
  runId,
154
- protocol: "shared",
155
- cost: totalCost,
156
- events,
157
- transcript,
158
- iteration: transcript.length,
159
- elapsedMs: elapsedMs(startedAtMs)
160
- }
161
- )
162
- };
163
- const response = await generateModelTurn({
164
- model: options.model,
165
- request,
166
- runId,
167
- agent,
168
- input,
169
- emit,
170
- callId: providerCallIdFor(runId, providerCalls.length + index + 1),
171
- onProviderCall(call): void {
172
- providerCallSlots[index] = call;
179
+ agent,
180
+ input,
181
+ emit,
182
+ callId: providerCallIdFor(runId, providerCalls.length + index + 1),
183
+ onProviderCall(call): void {
184
+ providerCallSlots[index] = call;
185
+ }
186
+ });
187
+ const decision = parseAgentDecision(response.text);
188
+ const toolCalls = await executeModelResponseToolRequests({
189
+ response,
190
+ executor: toolExecutor,
191
+ agentId: agent.id,
192
+ role: agent.role,
193
+ turn
194
+ });
195
+ throwIfAborted(fanout.signal, options.model.id);
196
+
197
+ return {
198
+ agent,
199
+ turn,
200
+ input,
201
+ response,
202
+ decision,
203
+ toolCalls,
204
+ turnCost: responseCost(response)
205
+ };
173
206
  }
174
- });
175
- const decision = parseAgentDecision(response.text);
176
- const toolCalls = await executeModelResponseToolRequests({
177
- response,
178
- executor: toolExecutor,
179
- agentId: agent.id,
180
- role: agent.role,
181
- turn
182
- });
183
- throwIfAborted(options.signal, options.model.id);
184
-
185
- return {
186
- agent,
187
- turn,
188
- input,
189
- response,
190
- decision,
191
- toolCalls,
192
- turnCost: responseCost(response)
193
- };
194
- })
195
- );
207
+ );
208
+ } finally {
209
+ fanout.cleanup();
210
+ }
211
+ })();
196
212
  providerCalls.push(...providerCallSlots.filter((call): call is ReplayTraceProviderCall => call !== undefined));
197
213
 
198
214
  for (const result of turnResults) {
@@ -242,45 +258,46 @@ export async function runShared(options: SharedRunOptions): Promise<RunResult> {
242
258
  transcriptEntryCount: transcript.length
243
259
  });
244
260
  const finalEvent = events.at(-1);
261
+ const trace: Trace = {
262
+ schemaVersion: "1.0",
263
+ runId,
264
+ protocol: "shared",
265
+ tier: options.tier,
266
+ modelProviderId: options.model.id,
267
+ agentsUsed: activeAgents,
268
+ inputs: createReplayTraceRunInputs({
269
+ intent: options.intent,
270
+ protocol: options.protocol,
271
+ tier: options.tier,
272
+ modelProviderId: options.model.id,
273
+ agents: activeAgents,
274
+ temperature: options.temperature
275
+ }),
276
+ budget: createReplayTraceBudget({
277
+ tier: options.tier,
278
+ ...(options.budget ? { caps: options.budget } : {}),
279
+ ...(options.terminate ? { termination: options.terminate } : {})
280
+ }),
281
+ budgetStateChanges: createReplayTraceBudgetStateChanges(events),
282
+ seed: createReplayTraceSeed(options.seed),
283
+ protocolDecisions,
284
+ providerCalls,
285
+ finalOutput: createReplayTraceFinalOutput(output, finalEvent ?? {
286
+ type: "final",
287
+ runId,
288
+ at: "",
289
+ output,
290
+ cost: totalCost,
291
+ transcript: createTranscriptLink(transcript)
292
+ }),
293
+ events,
294
+ transcript
295
+ };
245
296
 
246
297
  return {
247
298
  output,
248
299
  eventLog: createRunEventLog(runId, "shared", events),
249
- trace: {
250
- schemaVersion: "1.0",
251
- runId,
252
- protocol: "shared",
253
- tier: options.tier,
254
- modelProviderId: options.model.id,
255
- agentsUsed: activeAgents,
256
- inputs: createReplayTraceRunInputs({
257
- intent: options.intent,
258
- protocol: options.protocol,
259
- tier: options.tier,
260
- modelProviderId: options.model.id,
261
- agents: activeAgents,
262
- temperature: options.temperature
263
- }),
264
- budget: createReplayTraceBudget({
265
- tier: options.tier,
266
- ...(options.budget ? { caps: options.budget } : {}),
267
- ...(options.terminate ? { termination: options.terminate } : {})
268
- }),
269
- budgetStateChanges: createReplayTraceBudgetStateChanges(events),
270
- seed: createReplayTraceSeed(options.seed),
271
- protocolDecisions,
272
- providerCalls,
273
- finalOutput: createReplayTraceFinalOutput(output, finalEvent ?? {
274
- type: "final",
275
- runId,
276
- at: "",
277
- output,
278
- cost: totalCost,
279
- transcript: createTranscriptLink(transcript)
280
- }),
281
- events,
282
- transcript
283
- },
300
+ trace,
284
301
  transcript,
285
302
  usage: createRunUsage(totalCost),
286
303
  metadata: createRunMetadata({
@@ -298,7 +315,8 @@ export async function runShared(options: SharedRunOptions): Promise<RunResult> {
298
315
  cost: totalCost,
299
316
  events
300
317
  }),
301
- cost: totalCost
318
+ cost: totalCost,
319
+ health: computeHealth(trace, DEFAULT_HEALTH_THRESHOLDS)
302
320
  };
303
321
 
304
322
  function stopIfNeeded(): boolean {
@@ -376,3 +394,72 @@ function responseCost(response: ModelResponse): CostSummary {
376
394
  };
377
395
  }
378
396
 
397
+ interface FanoutAbortController {
398
+ readonly signal: AbortSignal;
399
+ abort(reason: unknown): void;
400
+ cleanup(): void;
401
+ }
402
+
403
+ function createFanoutAbortController(parentSignal: AbortSignal | undefined): FanoutAbortController {
404
+ const controller = new AbortController();
405
+ let removeParentListener = (): void => {};
406
+
407
+ if (parentSignal?.aborted) {
408
+ controller.abort(parentSignal.reason);
409
+ } else if (parentSignal !== undefined) {
410
+ const abortFromParent = (): void => {
411
+ controller.abort(parentSignal.reason);
412
+ };
413
+ parentSignal.addEventListener("abort", abortFromParent, { once: true });
414
+ removeParentListener = (): void => {
415
+ parentSignal.removeEventListener("abort", abortFromParent);
416
+ };
417
+ }
418
+
419
+ return {
420
+ signal: controller.signal,
421
+ abort(reason: unknown): void {
422
+ if (!controller.signal.aborted) {
423
+ controller.abort(reason);
424
+ }
425
+ },
426
+ cleanup(): void {
427
+ removeParentListener();
428
+ }
429
+ };
430
+ }
431
+
432
+ async function mapWithConcurrency<T, R>(
433
+ items: readonly T[],
434
+ maxConcurrent: number,
435
+ fanout: FanoutAbortController,
436
+ mapper: (item: T, index: number) => Promise<R>
437
+ ): Promise<R[]> {
438
+ if (items.length === 0) {
439
+ return [];
440
+ }
441
+
442
+ const results: R[] = new Array(items.length);
443
+ let nextIndex = 0;
444
+ let firstError: unknown;
445
+ const workerCount = Math.min(maxConcurrent, items.length);
446
+
447
+ await Promise.all(Array.from({ length: workerCount }, async () => {
448
+ while (nextIndex < items.length && firstError === undefined) {
449
+ const index = nextIndex;
450
+ nextIndex += 1;
451
+ try {
452
+ results[index] = await mapper(items[index]!, index);
453
+ } catch (error) {
454
+ firstError ??= error;
455
+ fanout.abort(error);
456
+ }
457
+ }
458
+ }));
459
+
460
+ if (firstError !== undefined) {
461
+ throw firstError;
462
+ }
463
+
464
+ return results;
465
+ }
@@ -60,6 +60,8 @@ export interface WebSearchToolAdapterOptions {
60
60
  readonly fetch?: WebSearchFetch;
61
61
  readonly headers?: HeadersInit;
62
62
  readonly defaultMaxResults?: number;
63
+ readonly allowHosts?: readonly string[];
64
+ readonly allowPrivateNetwork?: boolean;
63
65
  readonly identity?: BuiltInDogpileToolIdentityOptions;
64
66
  readonly permissions?: readonly RuntimeToolPermission[];
65
67
  readonly buildRequest?: WebSearchFetchRequestBuilder;
@@ -271,11 +273,15 @@ export function createWebSearchToolAdapter(
271
273
  options: WebSearchToolAdapterOptions
272
274
  ): RuntimeToolAdapterContract<WebSearchToolInput, WebSearchToolOutput> {
273
275
  const identity = mergeIdentity(webSearchIdentity, options.identity);
276
+ const endpointUrl = new URL(String(options.endpoint));
277
+ const allowedHosts = normalizeAllowedHosts(options.allowHosts ?? [endpointUrl.hostname]);
278
+ const allowPrivateNetwork = options.allowPrivateNetwork ?? false;
279
+ const permissions = options.permissions ?? webSearchPermissionsFor(allowedHosts, allowPrivateNetwork);
274
280
 
275
281
  return normalizeBuiltInDogpileTool({
276
282
  name: "webSearch",
277
283
  ...(options.identity ? { identity: options.identity } : {}),
278
- ...(options.permissions ? { permissions: options.permissions } : {}),
284
+ permissions,
279
285
  async execute(input, context): Promise<RuntimeToolResult<WebSearchToolOutput>> {
280
286
  const fetchImplementation = options.fetch ?? globalThis.fetch;
281
287
 
@@ -295,6 +301,15 @@ export function createWebSearchToolAdapter(
295
301
  const request = options.buildRequest
296
302
  ? options.buildRequest(input, context)
297
303
  : defaultWebSearchRequest(options, input, context);
304
+ const policyError = validateWebSearchRequestPolicy(request.url, allowedHosts, allowPrivateNetwork);
305
+ if (policyError !== undefined) {
306
+ return {
307
+ type: "error",
308
+ toolCallId: context.toolCallId,
309
+ tool: identity,
310
+ error: policyError
311
+ };
312
+ }
298
313
  const response = await fetchImplementation(request.url, {
299
314
  ...request.init,
300
315
  ...(context.abortSignal ? { signal: context.abortSignal } : {})
@@ -616,10 +631,19 @@ async function executeSandboxWithPolicy(
616
631
  } satisfies RuntimeToolAdapterError;
617
632
  }
618
633
 
619
- const execution = Promise.resolve().then(() => execute(input, context));
634
+ const policy = createToolPolicyAbortController(context.abortSignal);
635
+ const executionContext: RuntimeToolExecutionContext = {
636
+ ...context,
637
+ abortSignal: policy.signal
638
+ };
639
+ const execution = Promise.resolve().then(() => execute(input, executionContext));
620
640
 
621
641
  if (timeoutMs === undefined && context.abortSignal === undefined) {
622
- return await execution;
642
+ try {
643
+ return await execution;
644
+ } finally {
645
+ policy.cleanup();
646
+ }
623
647
  }
624
648
 
625
649
  return await new Promise<CodeExecToolOutput>((resolve, reject) => {
@@ -627,31 +651,33 @@ async function executeSandboxWithPolicy(
627
651
 
628
652
  const cleanup = (): void => {
629
653
  if (timeoutId !== undefined) clearTimeout(timeoutId);
630
- context.abortSignal?.removeEventListener("abort", abortHandler);
654
+ policy.signal.removeEventListener("abort", abortHandler);
655
+ policy.cleanup();
631
656
  };
632
657
 
633
658
  const abortHandler = (): void => {
634
659
  cleanup();
635
- reject({
636
- code: "aborted",
637
- message: "Code execution was aborted.",
638
- retryable: true
639
- } satisfies RuntimeToolAdapterError);
660
+ const reason = policy.signal.reason;
661
+ reject(isRuntimeToolAdapterError(reason)
662
+ ? reason
663
+ : {
664
+ code: "aborted",
665
+ message: "Code execution was aborted.",
666
+ retryable: true
667
+ } satisfies RuntimeToolAdapterError);
640
668
  };
641
669
 
642
- if (context.abortSignal) {
643
- context.abortSignal.addEventListener("abort", abortHandler, { once: true });
644
- }
670
+ policy.signal.addEventListener("abort", abortHandler, { once: true });
645
671
 
646
672
  if (timeoutMs !== undefined) {
647
673
  timeoutId = setTimeout(() => {
648
- cleanup();
649
- reject({
674
+ const error = {
650
675
  code: "timeout",
651
676
  message: `Code execution exceeded timeout of ${timeoutMs}ms.`,
652
677
  retryable: true,
653
678
  detail: { timeoutMs }
654
- } satisfies RuntimeToolAdapterError);
679
+ } satisfies RuntimeToolAdapterError;
680
+ policy.abort(error);
655
681
  }, timeoutMs);
656
682
  }
657
683
 
@@ -662,6 +688,133 @@ async function executeSandboxWithPolicy(
662
688
  });
663
689
  }
664
690
 
691
+ function webSearchPermissionsFor(
692
+ allowedHosts: ReadonlySet<string>,
693
+ allowPrivateNetwork: boolean
694
+ ): readonly RuntimeToolPermission[] {
695
+ return [
696
+ {
697
+ kind: "network",
698
+ allowHosts: Array.from(allowedHosts).sort(),
699
+ allowPrivateNetwork
700
+ }
701
+ ];
702
+ }
703
+
704
+ function validateWebSearchRequestPolicy(
705
+ requestUrl: string | URL,
706
+ allowedHosts: ReadonlySet<string>,
707
+ allowPrivateNetwork: boolean
708
+ ): RuntimeToolAdapterError | undefined {
709
+ const url = new URL(String(requestUrl));
710
+ const host = normalizeHost(url.hostname);
711
+
712
+ if (!allowedHosts.has(host)) {
713
+ return {
714
+ code: "permission-denied",
715
+ message: `webSearch request host ${url.hostname} is not allowed.`,
716
+ retryable: false,
717
+ detail: {
718
+ reason: "host-not-allowed",
719
+ host: url.hostname,
720
+ allowedHosts: Array.from(allowedHosts).sort()
721
+ }
722
+ };
723
+ }
724
+
725
+ if (!allowPrivateNetwork && classifyNetworkHost(host) === "private") {
726
+ return {
727
+ code: "permission-denied",
728
+ message: `webSearch request host ${url.hostname} is private-network and is not allowed.`,
729
+ retryable: false,
730
+ detail: {
731
+ reason: "private-network-not-allowed",
732
+ host: url.hostname
733
+ }
734
+ };
735
+ }
736
+
737
+ return undefined;
738
+ }
739
+
740
+ function normalizeAllowedHosts(hosts: readonly string[]): ReadonlySet<string> {
741
+ return new Set(hosts.map(normalizeHost));
742
+ }
743
+
744
+ function normalizeHost(host: string): string {
745
+ return host.toLowerCase().replace(/^\[|\]$/g, "");
746
+ }
747
+
748
+ function classifyNetworkHost(host: string): "private" | "public" {
749
+ const mappedIpv4 = ipv4MappedToDottedQuad(host);
750
+ if (mappedIpv4 !== undefined) {
751
+ return classifyNetworkHost(mappedIpv4);
752
+ }
753
+ if (host === "localhost") return "private";
754
+ if (host.endsWith(".local")) return "private";
755
+ if (/^127(?:\.\d{1,3}){3}$/.test(host)) return "private";
756
+ if (/^10(?:\.\d{1,3}){3}$/.test(host)) return "private";
757
+ if (/^172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}$/.test(host)) return "private";
758
+ if (/^192\.168(?:\.\d{1,3}){2}$/.test(host)) return "private";
759
+ if (/^169\.254(?:\.\d{1,3}){2}$/.test(host)) return "private";
760
+ if (host === "::1") return "private";
761
+ if (/^f[cd][0-9a-f]{2}:/.test(host)) return "private";
762
+ if (/^fe[89ab][0-9a-f]?:/.test(host)) return "private";
763
+ return "public";
764
+ }
765
+
766
+ function ipv4MappedToDottedQuad(host: string): string | undefined {
767
+ const match = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
768
+ if (match === null) {
769
+ return undefined;
770
+ }
771
+ const high = Number.parseInt(match[1] ?? "", 16);
772
+ const low = Number.parseInt(match[2] ?? "", 16);
773
+ if (!Number.isFinite(high) || !Number.isFinite(low)) {
774
+ return undefined;
775
+ }
776
+ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
777
+ }
778
+
779
+ interface ToolPolicyAbortController {
780
+ readonly signal: AbortSignal;
781
+ abort(reason: unknown): void;
782
+ cleanup(): void;
783
+ }
784
+
785
+ function createToolPolicyAbortController(parentSignal: AbortSignal | undefined): ToolPolicyAbortController {
786
+ const controller = new AbortController();
787
+ let removeParentListener = (): void => {};
788
+
789
+ if (parentSignal?.aborted) {
790
+ controller.abort(parentSignal.reason);
791
+ } else if (parentSignal !== undefined) {
792
+ const abortFromParent = (): void => {
793
+ controller.abort(parentSignal.reason ?? {
794
+ code: "aborted",
795
+ message: "Code execution was aborted.",
796
+ retryable: true
797
+ } satisfies RuntimeToolAdapterError);
798
+ };
799
+ parentSignal.addEventListener("abort", abortFromParent, { once: true });
800
+ removeParentListener = (): void => {
801
+ parentSignal.removeEventListener("abort", abortFromParent);
802
+ };
803
+ }
804
+
805
+ return {
806
+ signal: controller.signal,
807
+ abort(reason: unknown): void {
808
+ if (!controller.signal.aborted) {
809
+ controller.abort(reason);
810
+ }
811
+ },
812
+ cleanup(): void {
813
+ removeParentListener();
814
+ }
815
+ };
816
+ }
817
+
665
818
  function normalizeWebSearchResult(value: unknown): WebSearchToolResult {
666
819
  if (!isJsonObject(value)) {
667
820
  throw {
@@ -16,6 +16,8 @@ import type {
16
16
  import { validateRuntimeToolRegistrations } from "./validation.js";
17
17
  import { normalizeRuntimeToolAdapterError } from "./tools/built-in.js";
18
18
 
19
+ const DEFAULT_MAX_CONCURRENT_TOOL_CALLS = 4;
20
+
19
21
  // Re-export the public surface from the split modules so the
20
22
  // `@dogpile/sdk/runtime/tools` subpath stays stable.
21
23
  export {
@@ -98,12 +100,18 @@ export interface RuntimeToolExecutorOptions {
98
100
  export function createRuntimeToolExecutor(options: RuntimeToolExecutorOptions): RuntimeToolExecutor {
99
101
  validateRuntimeToolRegistrations(options.tools);
100
102
  const tools = Array.from(options.tools);
103
+ const toolsById = new Map<string, RuntimeTool<JsonObject, JsonValue>>();
104
+ for (const tool of tools) {
105
+ if (!toolsById.has(tool.identity.id)) {
106
+ toolsById.set(tool.identity.id, tool);
107
+ }
108
+ }
101
109
  let callCount = 0;
102
110
 
103
111
  return {
104
112
  tools,
105
113
  async execute(request: RuntimeToolExecutionRequest): Promise<RuntimeToolResult> {
106
- const tool = tools.find((candidate) => candidate.identity.id === request.toolId);
114
+ const tool = toolsById.get(request.toolId);
107
115
  const identity = tool?.identity ?? {
108
116
  id: request.toolId,
109
117
  name: request.toolId
@@ -182,9 +190,9 @@ export async function executeModelResponseToolRequests(options: {
182
190
  readonly turn: number;
183
191
  readonly metadata?: JsonObject;
184
192
  }): Promise<readonly TranscriptToolCall[]> {
185
- const toolCalls: TranscriptToolCall[] = [];
193
+ const requests = options.response.toolRequests ?? [];
186
194
 
187
- for (const request of options.response.toolRequests ?? []) {
195
+ return mapWithConcurrency(requests, DEFAULT_MAX_CONCURRENT_TOOL_CALLS, async (request) => {
188
196
  const result = await options.executor.execute({
189
197
  ...request,
190
198
  agentId: request.agentId ?? options.agentId,
@@ -192,15 +200,14 @@ export async function executeModelResponseToolRequests(options: {
192
200
  turn: request.turn ?? options.turn,
193
201
  metadata: mergeToolMetadata(options.metadata, request.metadata)
194
202
  });
195
- toolCalls.push({
203
+
204
+ return {
196
205
  toolCallId: result.toolCallId,
197
206
  tool: result.tool,
198
207
  input: request.input,
199
208
  result
200
- });
201
- }
202
-
203
- return toolCalls;
209
+ };
210
+ });
204
211
  }
205
212
 
206
213
  function runtimeToolIdentityManifest(identity: RuntimeToolIdentity): JsonObject {
@@ -321,3 +328,27 @@ function mergeToolMetadata(base: JsonObject | undefined, request: JsonObject | u
321
328
  function defaultToolCallId(runId: string, callIndex: number): string {
322
329
  return `${runId}:tool-${callIndex + 1}`;
323
330
  }
331
+
332
+ async function mapWithConcurrency<T, R>(
333
+ items: readonly T[],
334
+ maxConcurrent: number,
335
+ mapper: (item: T, index: number) => Promise<R>
336
+ ): Promise<R[]> {
337
+ if (items.length === 0) {
338
+ return [];
339
+ }
340
+
341
+ const results: R[] = new Array(items.length);
342
+ let nextIndex = 0;
343
+ const workerCount = Math.min(maxConcurrent, items.length);
344
+
345
+ await Promise.all(Array.from({ length: workerCount }, async () => {
346
+ while (nextIndex < items.length) {
347
+ const index = nextIndex;
348
+ nextIndex += 1;
349
+ results[index] = await mapper(items[index]!, index);
350
+ }
351
+ }));
352
+
353
+ return results;
354
+ }