@tonyclaw/agent-inspector 3.1.19 → 3.1.21

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 (61) hide show
  1. package/.output/backend/nitro.json +1 -1
  2. package/.output/cli.js +140 -20
  3. package/.output/server/_ssr/index.mjs +2 -2
  4. package/.output/server/_ssr/{router-0qlvqhVh.mjs → router-Be6BLuut.mjs} +2454 -1135
  5. package/.output/server/_tanstack-start-manifest_v-ClTaMkEj.mjs +4 -0
  6. package/.output/server/index.mjs +1 -1
  7. package/.output/ui/assets/{CompareDrawer-BOk9hd-Z.js → CompareDrawer-CGk2HFI1.js} +1 -1
  8. package/.output/ui/assets/{InspectorPet-DhHDBpk7.js → InspectorPet-CDM7SdOT.js} +1 -1
  9. package/.output/ui/assets/ProxyViewerContainer-CaWD78tw.js +60 -0
  10. package/.output/ui/assets/{ReplayDialog-DJhKtuNB.js → ReplayDialog-DqaeV1He.js} +1 -1
  11. package/.output/ui/assets/{RequestAnatomy-hP0tsqDC.js → RequestAnatomy-BtHl9P0k.js} +1 -1
  12. package/.output/ui/assets/{ResponseView-BSiUkwCb.js → ResponseView-C6t1vpxs.js} +1 -1
  13. package/.output/ui/assets/{StreamingChunkSequence-CzQqwv4X.js → StreamingChunkSequence-R4mgd3GQ.js} +1 -1
  14. package/.output/ui/assets/{_sessionId-BU-i6h0V.js → _sessionId-CL3hdLlQ.js} +1 -1
  15. package/.output/ui/assets/{_sessionId-C2Pq4Jb0.js → _sessionId-HS666S72.js} +1 -1
  16. package/.output/ui/assets/index-B-Qod-aA.css +1 -0
  17. package/.output/ui/assets/{index-D8C-t-2U.js → index-Bd9jPL4n.js} +1 -1
  18. package/.output/ui/assets/{index-zHpVF0jk.js → index-C4NHrclw.js} +1 -1
  19. package/.output/ui/assets/{index-UUnRwbOJ.js → index-UF1PJEcq.js} +1 -1
  20. package/.output/ui/assets/{index-B7NeIBDf.js → index-tX38BNV2.js} +2 -2
  21. package/.output/ui/assets/{json-viewer-DOzS5rUT.js → json-viewer-KSgzN_Ov.js} +1 -1
  22. package/.output/ui/assets/{jszip.min-CCCY7qNk.js → jszip.min-BiMDxghN.js} +1 -1
  23. package/.output/ui/index.html +2 -2
  24. package/.output/workers/logFinalizer.worker.js +261 -0
  25. package/.output/workers/sessionWorkerEntry.js +261 -0
  26. package/package.json +13 -6
  27. package/src/backend/routes/api/logs.$id.replay.ts +6 -1
  28. package/src/backend/routes/api/logs.stream.ts +7 -0
  29. package/src/backend/routes/api/providers.$providerId.model-metadata.ts +15 -4
  30. package/src/backend/routes/api/storage.ts +56 -0
  31. package/src/backend/routes/metrics.ts +14 -0
  32. package/src/cli/doctor.ts +135 -2
  33. package/src/cli.ts +7 -4
  34. package/src/components/ProxyViewerContainer.tsx +41 -24
  35. package/src/components/providers/SettingsDialog.tsx +261 -70
  36. package/src/components/proxy-viewer/LogEntry.tsx +19 -5
  37. package/src/components/proxy-viewer/bodyHydration.ts +67 -0
  38. package/src/components/proxy-viewer/proxyViewerContainerLogic.ts +78 -0
  39. package/src/contracts/index.ts +2 -0
  40. package/src/contracts/log.ts +5 -0
  41. package/src/knowledge/openclawClient.ts +11 -2
  42. package/src/knowledge/openclawGatewayClient.ts +6 -1
  43. package/src/lib/resourceLimits.ts +191 -0
  44. package/src/lib/safeFetch.ts +428 -20
  45. package/src/lib/stopReason.ts +66 -39
  46. package/src/proxy/ecosystemTasks.ts +20 -0
  47. package/src/proxy/handler.ts +17 -4
  48. package/src/proxy/identityProxy.ts +36 -16
  49. package/src/proxy/logIndex.ts +57 -6
  50. package/src/proxy/logger.ts +4 -0
  51. package/src/proxy/rawStreamCapture.ts +16 -1
  52. package/src/proxy/runtimeHealth.ts +4 -2
  53. package/src/proxy/runtimeMetrics.ts +149 -0
  54. package/src/proxy/schemas.ts +2 -0
  55. package/src/proxy/sessionRuntime.ts +38 -0
  56. package/src/proxy/sqliteLogIndex.ts +78 -2
  57. package/src/proxy/storageLifecycle.ts +432 -0
  58. package/src/proxy/store.ts +69 -13
  59. package/.output/server/_tanstack-start-manifest_v-DnbdNeun.mjs +0 -4
  60. package/.output/ui/assets/ProxyViewerContainer-BRYf987G.js +0 -59
  61. package/.output/ui/assets/index-IBEDJoV_.css +0 -1
@@ -1,5 +1,8 @@
1
1
  import { lookup } from "node:dns/promises";
2
+ import { request as requestHttp } from "node:http";
3
+ import { request as requestHttps } from "node:https";
2
4
  import { isIP } from "node:net";
5
+ import type { IncomingMessage, RequestOptions } from "node:http";
3
6
  import { evaluateUpstreamUrl, isInternalHost, type SsrfGuardOptions } from "./ssrfGuard";
4
7
 
5
8
  export type ResolvedAddress = {
@@ -11,11 +14,18 @@ export type HostResolver = (hostname: string) => Promise<readonly ResolvedAddres
11
14
 
12
15
  export type SafeFetchOptions = SsrfGuardOptions & {
13
16
  resolver?: HostResolver;
17
+ deadlineMs?: number;
18
+ responseBytes?: number;
19
+ pinDns?: boolean;
14
20
  };
15
21
 
16
22
  export type SafeFetchResult =
17
23
  | { ok: true; response: Response }
18
- | { ok: false; kind: "network-policy" | "redirect"; reason: string };
24
+ | {
25
+ ok: false;
26
+ kind: "network-policy" | "redirect" | "resource-limit" | "timeout";
27
+ reason: string;
28
+ };
19
29
 
20
30
  async function defaultResolver(hostname: string): Promise<readonly ResolvedAddress[]> {
21
31
  return lookup(hostname, { all: true, verbatim: true });
@@ -35,12 +45,28 @@ export async function evaluateResolvedUpstreamUrl(
35
45
  rawUrl: string,
36
46
  options: SafeFetchOptions = {},
37
47
  ): Promise<{ allow: true } | { allow: false; reason: string }> {
48
+ const decision = await evaluateResolvedUpstreamUrlForFetch(rawUrl, options);
49
+ return decision.allow ? { allow: true } : { allow: false, reason: decision.reason };
50
+ }
51
+
52
+ type ResolvedUpstreamUrlDecision =
53
+ | { allow: true; hostname: string; addresses: readonly ResolvedAddress[] }
54
+ | { allow: false; reason: string };
55
+
56
+ async function evaluateResolvedUpstreamUrlForFetch(
57
+ rawUrl: string,
58
+ options: SafeFetchOptions,
59
+ ): Promise<ResolvedUpstreamUrlDecision> {
38
60
  const initial = evaluateUpstreamUrl(rawUrl, options);
39
- if (!initial.allow || options.bypass === true) return initial;
61
+ if (!initial.allow) return initial;
40
62
 
41
63
  const parsed = new URL(rawUrl);
42
64
  const hostname = parsed.hostname.replace(/^\[|]$/g, "");
43
- if (isIP(hostname) !== 0 || hostname.toLowerCase() === "localhost") return initial;
65
+ if (options.bypass === true) return { allow: true, hostname, addresses: [] };
66
+
67
+ const ipFamily = isIP(hostname);
68
+ if (ipFamily !== 0)
69
+ return { allow: true, hostname, addresses: [{ address: hostname, family: ipFamily }] };
44
70
 
45
71
  let addresses: readonly ResolvedAddress[];
46
72
  try {
@@ -65,36 +91,418 @@ export async function evaluateResolvedUpstreamUrl(
65
91
  };
66
92
  }
67
93
 
68
- return { allow: true };
94
+ return { allow: true, hostname, addresses };
69
95
  }
70
96
 
71
97
  function isRedirectStatus(status: number): boolean {
72
98
  return status >= 300 && status < 400;
73
99
  }
74
100
 
101
+ function formatByteLimit(limit: number): string {
102
+ return `${String(limit)} bytes`;
103
+ }
104
+
105
+ function parseContentLength(value: string | null): number | null {
106
+ if (value === null) return null;
107
+ const parsed = Number(value);
108
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
109
+ return parsed;
110
+ }
111
+
112
+ function isPinnedDnsEnabled(options: SafeFetchOptions): boolean {
113
+ const disabledForTest =
114
+ typeof process !== "undefined" &&
115
+ process.env["NODE_ENV"] === "test" &&
116
+ process.env["AGENT_INSPECTOR_DISABLE_DNS_PINNING_FOR_TESTS"] === "1";
117
+ if (disabledForTest) return false;
118
+ return options.pinDns !== false && options.bypass !== true;
119
+ }
120
+
121
+ function choosePinnedAddress(addresses: readonly ResolvedAddress[]): ResolvedAddress | null {
122
+ return addresses[0] ?? null;
123
+ }
124
+
125
+ function hostHeaderValue(parsed: URL): string {
126
+ return parsed.port.length === 0 ? parsed.host : `${parsed.hostname}:${parsed.port}`;
127
+ }
128
+
129
+ function headersToRecord(headers: HeadersInit | undefined, parsed: URL): Record<string, string> {
130
+ const normalized = new Headers(headers);
131
+ if (!normalized.has("host")) {
132
+ normalized.set("host", hostHeaderValue(parsed));
133
+ }
134
+ const result: Record<string, string> = {};
135
+ normalized.forEach((value, key) => {
136
+ result[key] = value;
137
+ });
138
+ return result;
139
+ }
140
+
141
+ async function requestBodyToUint8Array(
142
+ body: BodyInit | null | undefined,
143
+ ): Promise<
144
+ { ok: true; body: Uint8Array | null; contentType: string | null } | { ok: false; reason: string }
145
+ > {
146
+ if (body === undefined || body === null) return { ok: true, body: null, contentType: null };
147
+ if (typeof body === "string") {
148
+ return { ok: true, body: new TextEncoder().encode(body), contentType: null };
149
+ }
150
+ if (body instanceof URLSearchParams) {
151
+ return {
152
+ ok: true,
153
+ body: new TextEncoder().encode(body.toString()),
154
+ contentType: "application/x-www-form-urlencoded;charset=UTF-8",
155
+ };
156
+ }
157
+ if (body instanceof Uint8Array) return { ok: true, body, contentType: null };
158
+ if (body instanceof ArrayBuffer) {
159
+ return { ok: true, body: new Uint8Array(body), contentType: null };
160
+ }
161
+ if (body instanceof Blob) {
162
+ return { ok: true, body: new Uint8Array(await body.arrayBuffer()), contentType: body.type };
163
+ }
164
+ return {
165
+ ok: false,
166
+ reason: "Pinned upstream fetch only supports buffered request bodies",
167
+ };
168
+ }
169
+
170
+ function pinnedLookup(
171
+ pinnedAddress: ResolvedAddress,
172
+ ): (
173
+ hostname: string,
174
+ options: { all?: boolean },
175
+ callback: (error: Error | null, address: string | ResolvedAddress[], family?: number) => void,
176
+ ) => void {
177
+ return (_hostname, options, callback) => {
178
+ if (options.all === true) {
179
+ callback(null, [pinnedAddress]);
180
+ return;
181
+ }
182
+ callback(null, pinnedAddress.address, pinnedAddress.family);
183
+ };
184
+ }
185
+
186
+ function requestOptionsForPinnedFetch(
187
+ parsed: URL,
188
+ init: RequestInit,
189
+ pinnedAddress: ResolvedAddress,
190
+ contentType: string | null,
191
+ ): RequestOptions {
192
+ const headers = headersToRecord(init.headers, parsed);
193
+ if (contentType !== null && !new Headers(headers).has("content-type")) {
194
+ headers["content-type"] = contentType;
195
+ }
196
+
197
+ return {
198
+ protocol: parsed.protocol,
199
+ hostname: parsed.hostname,
200
+ port: parsed.port,
201
+ path: `${parsed.pathname}${parsed.search}`,
202
+ method: init.method ?? "GET",
203
+ headers,
204
+ lookup: pinnedLookup(pinnedAddress),
205
+ };
206
+ }
207
+
208
+ function streamFromIncomingMessage(
209
+ incoming: IncomingMessage,
210
+ maxBytes: number | undefined,
211
+ ): ReadableStream<Uint8Array> {
212
+ let seenBytes = 0;
213
+ return new ReadableStream<Uint8Array>({
214
+ start(controller) {
215
+ incoming.on("data", (chunk: Uint8Array) => {
216
+ seenBytes += chunk.byteLength;
217
+ if (maxBytes !== undefined && seenBytes > maxBytes) {
218
+ incoming.destroy(
219
+ new Error(
220
+ `Upstream response exceeded configured limit of ${formatByteLimit(maxBytes)}`,
221
+ ),
222
+ );
223
+ controller.error(
224
+ new Error(
225
+ `Upstream response exceeded configured limit of ${formatByteLimit(maxBytes)}`,
226
+ ),
227
+ );
228
+ return;
229
+ }
230
+ controller.enqueue(chunk);
231
+ });
232
+ incoming.on("end", () => {
233
+ controller.close();
234
+ });
235
+ incoming.on("error", (error) => {
236
+ controller.error(error);
237
+ });
238
+ },
239
+ cancel() {
240
+ incoming.destroy();
241
+ },
242
+ });
243
+ }
244
+
245
+ async function cancelBody(response: Response): Promise<void> {
246
+ if (response.body === null) return;
247
+ await response.body.cancel().catch(() => undefined);
248
+ }
249
+
250
+ function responseWithByteLimit(response: Response, maxBytes: number): Response {
251
+ if (response.body === null) return response;
252
+
253
+ let seenBytes = 0;
254
+ const reader = response.body.getReader();
255
+ const body = new ReadableStream<Uint8Array>({
256
+ async pull(controller) {
257
+ const chunk = await reader.read();
258
+ if (chunk.done) {
259
+ controller.close();
260
+ return;
261
+ }
262
+
263
+ seenBytes += chunk.value.byteLength;
264
+ if (seenBytes > maxBytes) {
265
+ void reader.cancel().catch(() => undefined);
266
+ controller.error(
267
+ new Error(`Upstream response exceeded configured limit of ${formatByteLimit(maxBytes)}`),
268
+ );
269
+ return;
270
+ }
271
+
272
+ controller.enqueue(chunk.value);
273
+ },
274
+ cancel(reason) {
275
+ return reader.cancel(reason);
276
+ },
277
+ });
278
+
279
+ return new Response(body, {
280
+ status: response.status,
281
+ statusText: response.statusText,
282
+ headers: response.headers,
283
+ });
284
+ }
285
+
286
+ function shouldUseBudgetedSignal(init: RequestInit, deadlineMs: number | undefined): boolean {
287
+ return deadlineMs !== undefined || init.signal !== undefined;
288
+ }
289
+
290
+ async function pinnedNodeFetch(
291
+ rawUrl: string,
292
+ init: RequestInit,
293
+ options: SafeFetchOptions,
294
+ decision: Extract<ResolvedUpstreamUrlDecision, { allow: true }>,
295
+ ): Promise<SafeFetchResult> {
296
+ const parsed = new URL(rawUrl);
297
+ const pinnedAddress = choosePinnedAddress(decision.addresses);
298
+ if (pinnedAddress === null) {
299
+ return {
300
+ ok: false,
301
+ kind: "network-policy",
302
+ reason: `Could not pin a resolved upstream address for "${decision.hostname}"`,
303
+ };
304
+ }
305
+
306
+ const encodedBody = await requestBodyToUint8Array(init.body);
307
+ if (!encodedBody.ok) {
308
+ return { ok: false, kind: "resource-limit", reason: encodedBody.reason };
309
+ }
310
+
311
+ return await new Promise<SafeFetchResult>((resolve, reject) => {
312
+ const linkedSignal = init.signal ?? undefined;
313
+ const transport = parsed.protocol === "https:" ? requestHttps : requestHttp;
314
+ const requestOptions = requestOptionsForPinnedFetch(
315
+ parsed,
316
+ init,
317
+ pinnedAddress,
318
+ encodedBody.contentType,
319
+ );
320
+ const request = transport(requestOptions, (incoming) => {
321
+ const status = incoming.statusCode ?? 0;
322
+ if (isRedirectStatus(status)) {
323
+ incoming.resume();
324
+ const location = incoming.headers.location;
325
+ resolve({
326
+ ok: false,
327
+ kind: "redirect",
328
+ reason:
329
+ location === undefined
330
+ ? `Upstream returned blocked redirect HTTP ${String(status)}`
331
+ : `Upstream redirect to "${location}" is blocked; configure the final API URL directly`,
332
+ });
333
+ return;
334
+ }
335
+
336
+ const maxBytes = options.responseBytes;
337
+ const headers = new Headers();
338
+ for (const [key, value] of Object.entries(incoming.headers)) {
339
+ if (Array.isArray(value)) {
340
+ headers.set(key, value.join(", "));
341
+ continue;
342
+ }
343
+ if (value !== undefined) headers.set(key, value);
344
+ }
345
+
346
+ if (maxBytes !== undefined) {
347
+ const declaredBytes = parseContentLength(headers.get("content-length"));
348
+ if (declaredBytes !== null && declaredBytes > maxBytes) {
349
+ incoming.destroy();
350
+ resolve({
351
+ ok: false,
352
+ kind: "resource-limit",
353
+ reason: `Upstream declared ${String(declaredBytes)} response bytes, exceeding configured limit of ${formatByteLimit(maxBytes)}`,
354
+ });
355
+ return;
356
+ }
357
+ }
358
+
359
+ resolve({
360
+ ok: true,
361
+ response: new Response(streamFromIncomingMessage(incoming, maxBytes), {
362
+ status,
363
+ statusText: incoming.statusMessage,
364
+ headers,
365
+ }),
366
+ });
367
+ });
368
+
369
+ let timedOut = false;
370
+ let timeout: ReturnType<typeof setTimeout> | undefined;
371
+ const finishAbort = (): void => {
372
+ request.destroy(new DOMException("Safe fetch aborted", "AbortError"));
373
+ };
374
+
375
+ if (linkedSignal !== undefined) {
376
+ if (linkedSignal.aborted) {
377
+ finishAbort();
378
+ } else {
379
+ linkedSignal.addEventListener("abort", finishAbort, { once: true });
380
+ }
381
+ }
382
+
383
+ if (options.deadlineMs !== undefined) {
384
+ timeout = setTimeout(() => {
385
+ timedOut = true;
386
+ request.destroy(new DOMException("Safe fetch deadline exceeded", "TimeoutError"));
387
+ }, options.deadlineMs);
388
+ }
389
+
390
+ request.on("error", (error) => {
391
+ if (timeout !== undefined) clearTimeout(timeout);
392
+ if (linkedSignal !== undefined) {
393
+ linkedSignal.removeEventListener("abort", finishAbort);
394
+ }
395
+ if (timedOut) {
396
+ resolve({
397
+ ok: false,
398
+ kind: "timeout",
399
+ reason: `Upstream request exceeded configured deadline of ${String(options.deadlineMs)} ms`,
400
+ });
401
+ return;
402
+ }
403
+ reject(error);
404
+ });
405
+
406
+ request.on("close", () => {
407
+ if (timeout !== undefined) clearTimeout(timeout);
408
+ if (linkedSignal !== undefined) {
409
+ linkedSignal.removeEventListener("abort", finishAbort);
410
+ }
411
+ });
412
+
413
+ if (encodedBody.body !== null) request.write(encodedBody.body);
414
+ request.end();
415
+ });
416
+ }
417
+
75
418
  export async function safeFetch(
76
419
  rawUrl: string,
77
420
  init: RequestInit = {},
78
421
  options: SafeFetchOptions = {},
79
422
  ): Promise<SafeFetchResult> {
80
- const decision = await evaluateResolvedUpstreamUrl(rawUrl, options);
81
- if (!decision.allow) {
82
- return { ok: false, kind: "network-policy", reason: decision.reason };
423
+ const resolvedDecision = await evaluateResolvedUpstreamUrlForFetch(rawUrl, options);
424
+ if (!resolvedDecision.allow) {
425
+ return { ok: false, kind: "network-policy", reason: resolvedDecision.reason };
83
426
  }
84
427
 
85
- const response = await fetch(rawUrl, { ...init, redirect: "manual" });
86
- if (!isRedirectStatus(response.status)) return { ok: true, response };
87
-
88
- if (response.body !== null) {
89
- await response.body.cancel().catch(() => undefined);
428
+ if (isPinnedDnsEnabled(options)) {
429
+ return await pinnedNodeFetch(rawUrl, init, options, resolvedDecision);
90
430
  }
91
- const location = response.headers.get("location");
92
- return {
93
- ok: false,
94
- kind: "redirect",
95
- reason:
96
- location === null
97
- ? `Upstream returned blocked redirect HTTP ${String(response.status)}`
98
- : `Upstream redirect to "${location}" is blocked; configure the final API URL directly`,
431
+
432
+ const deadlineMs = options.deadlineMs;
433
+ const useBudgetedSignal = shouldUseBudgetedSignal(init, deadlineMs);
434
+ const controller = useBudgetedSignal ? new AbortController() : undefined;
435
+ let timedOut = false;
436
+ let timeout: ReturnType<typeof setTimeout> | undefined;
437
+ const linkedSignal = init.signal ?? undefined;
438
+ const forwardAbort = (): void => {
439
+ controller?.abort(linkedSignal?.reason);
99
440
  };
441
+
442
+ if (controller !== undefined && linkedSignal !== undefined) {
443
+ if (linkedSignal.aborted) {
444
+ forwardAbort();
445
+ } else {
446
+ linkedSignal.addEventListener("abort", forwardAbort, { once: true });
447
+ }
448
+ }
449
+
450
+ if (controller !== undefined && deadlineMs !== undefined) {
451
+ timeout = setTimeout(() => {
452
+ timedOut = true;
453
+ controller.abort(new DOMException("Safe fetch deadline exceeded", "TimeoutError"));
454
+ }, deadlineMs);
455
+ }
456
+
457
+ try {
458
+ const response = await fetch(rawUrl, {
459
+ ...init,
460
+ redirect: "manual",
461
+ signal: controller?.signal ?? init.signal,
462
+ });
463
+ if (!isRedirectStatus(response.status)) {
464
+ const maxBytes = options.responseBytes;
465
+ if (maxBytes === undefined) return { ok: true, response };
466
+
467
+ const declaredBytes = parseContentLength(response.headers.get("content-length"));
468
+ if (declaredBytes !== null && declaredBytes > maxBytes) {
469
+ await cancelBody(response);
470
+ return {
471
+ ok: false,
472
+ kind: "resource-limit",
473
+ reason: `Upstream declared ${String(declaredBytes)} response bytes, exceeding configured limit of ${formatByteLimit(maxBytes)}`,
474
+ };
475
+ }
476
+
477
+ return { ok: true, response: responseWithByteLimit(response, maxBytes) };
478
+ }
479
+
480
+ await cancelBody(response);
481
+ const location = response.headers.get("location");
482
+ return {
483
+ ok: false,
484
+ kind: "redirect",
485
+ reason:
486
+ location === null
487
+ ? `Upstream returned blocked redirect HTTP ${String(response.status)}`
488
+ : `Upstream redirect to "${location}" is blocked; configure the final API URL directly`,
489
+ };
490
+ } catch (error) {
491
+ if (timedOut) {
492
+ return {
493
+ ok: false,
494
+ kind: "timeout",
495
+ reason:
496
+ deadlineMs === undefined
497
+ ? "Upstream request exceeded its configured deadline"
498
+ : `Upstream request exceeded configured deadline of ${String(deadlineMs)} ms`,
499
+ };
500
+ }
501
+ return Promise.reject(error);
502
+ } finally {
503
+ if (timeout !== undefined) clearTimeout(timeout);
504
+ if (controller !== undefined && linkedSignal !== undefined) {
505
+ linkedSignal.removeEventListener("abort", forwardAbort);
506
+ }
507
+ }
100
508
  }
@@ -1,7 +1,7 @@
1
- import type { CapturedLog } from "../contracts";
1
+ import type { CapturedLog, StopReason } from "../contracts";
2
2
  import { isRecord } from "./unknown";
3
3
 
4
- export type StopReason = "end_turn" | "tool_use" | "stop" | "length" | null;
4
+ export type { StopReason } from "../contracts";
5
5
 
6
6
  function normalizeAnthropicStopReason(value: string): StopReason {
7
7
  switch (value) {
@@ -15,6 +15,67 @@ function normalizeAnthropicStopReason(value: string): StopReason {
15
15
  }
16
16
  }
17
17
 
18
+ function extractStopReasonFromJsonValue(value: unknown): StopReason {
19
+ if (!isRecord(value)) return null;
20
+
21
+ // Anthropic shape: { stop_reason: "end_turn" | "tool_use" | ... }
22
+ if (typeof value.stop_reason === "string") {
23
+ return normalizeAnthropicStopReason(value.stop_reason);
24
+ }
25
+
26
+ // Anthropic SSE shape: { type: "message_delta", delta: { stop_reason: "..." } }
27
+ if (isRecord(value.delta) && typeof value.delta.stop_reason === "string") {
28
+ return normalizeAnthropicStopReason(value.delta.stop_reason);
29
+ }
30
+
31
+ // OpenAI Chat shape: { choices: [{ finish_reason: "stop" | "length" | ... }] }
32
+ if (Array.isArray(value.choices)) {
33
+ for (const choice of value.choices) {
34
+ if (!isRecord(choice) || typeof choice.finish_reason !== "string") continue;
35
+ const reason = normalizeOpenAIFinishReason(choice.finish_reason);
36
+ if (reason !== null) return reason;
37
+ }
38
+ }
39
+
40
+ // OpenAI Responses shape: { status: "completed", output: [...] }
41
+ if (Array.isArray(value.output)) {
42
+ for (const item of value.output) {
43
+ if (isRecord(item) && item.type === "function_call") {
44
+ return "tool_use";
45
+ }
46
+ }
47
+ if (value.status === "completed") {
48
+ return "stop";
49
+ }
50
+ }
51
+
52
+ if (value.status === "incomplete" && isRecord(value.incomplete_details)) {
53
+ const reason = value.incomplete_details.reason;
54
+ if (typeof reason === "string" && isResponsesLengthLimitReason(reason)) {
55
+ return "length";
56
+ }
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ function extractStopReasonFromSseText(text: string): StopReason {
63
+ let stopReason: StopReason = null;
64
+ for (const rawLine of text.split("\n")) {
65
+ const line = rawLine.trim();
66
+ if (!line.startsWith("data: ")) continue;
67
+ const data = line.slice(6).trim();
68
+ if (data === "[DONE]") break;
69
+ try {
70
+ const reason = extractStopReasonFromJsonValue(JSON.parse(data));
71
+ if (reason !== null) stopReason = reason;
72
+ } catch {
73
+ // Ignore malformed or non-JSON SSE data lines.
74
+ }
75
+ }
76
+ return stopReason;
77
+ }
78
+
18
79
  function normalizeOpenAIFinishReason(value: string): StopReason {
19
80
  switch (value) {
20
81
  case "stop":
@@ -45,6 +106,7 @@ function isResponsesLengthLimitReason(value: string): boolean {
45
106
  * while the request was classified as OpenAI).
46
107
  */
47
108
  export function extractStopReason(log: CapturedLog): StopReason {
109
+ if (log.stopReason !== undefined) return log.stopReason;
48
110
  if (log.responseText === null) return null;
49
111
 
50
112
  try {
@@ -53,44 +115,9 @@ export function extractStopReason(log: CapturedLog): StopReason {
53
115
  if (typeof json === "string") {
54
116
  json = JSON.parse(json);
55
117
  }
56
- if (!isRecord(json)) return null;
57
-
58
- // Anthropic shape: { stop_reason: "end_turn" | "tool_use" | ... }
59
- if (typeof json.stop_reason === "string") {
60
- return normalizeAnthropicStopReason(json.stop_reason);
61
- }
62
-
63
- // OpenAI Chat shape: { choices: [{ finish_reason: "stop" | "length" | ... }] }
64
- if (Array.isArray(json.choices)) {
65
- for (const choice of json.choices) {
66
- if (!isRecord(choice) || typeof choice.finish_reason !== "string") continue;
67
- const reason = normalizeOpenAIFinishReason(choice.finish_reason);
68
- if (reason !== null) return reason;
69
- }
70
- }
71
-
72
- // OpenAI Responses shape: { status: "completed", output: [...] }
73
- if (Array.isArray(json.output)) {
74
- for (const item of json.output) {
75
- if (isRecord(item) && item.type === "function_call") {
76
- return "tool_use";
77
- }
78
- }
79
- if (json.status === "completed") {
80
- return "stop";
81
- }
82
- }
83
-
84
- if (json.status === "incomplete" && isRecord(json.incomplete_details)) {
85
- const reason = json.incomplete_details.reason;
86
- if (typeof reason === "string" && isResponsesLengthLimitReason(reason)) {
87
- return "length";
88
- }
89
- }
90
-
91
- return null;
118
+ return extractStopReasonFromJsonValue(json);
92
119
  } catch {
93
- return null;
120
+ return extractStopReasonFromSseText(log.responseText);
94
121
  }
95
122
  }
96
123
 
@@ -14,6 +14,7 @@ import {
14
14
  signalProcessTree,
15
15
  type CommandSpec,
16
16
  } from "./platformCommands";
17
+ import { DEFAULT_RUNTIME_BUDGET_POLICY } from "../lib/resourceLimits";
17
18
 
18
19
  const MAX_TASKS = 50;
19
20
  const OUTPUT_LIMIT = 40_000;
@@ -34,6 +35,12 @@ const activeTasks = new Map<string, ActiveTask>();
34
35
  let acceptingTasks = true;
35
36
  let shutdownForceTimer: ReturnType<typeof setTimeout> | null = null;
36
37
 
38
+ function activeTaskLimit(): number {
39
+ const configured = Number(process.env["ECOSYSTEM_TASK_ACTIVE_LIMIT"]);
40
+ if (Number.isSafeInteger(configured) && configured > 0) return configured;
41
+ return DEFAULT_RUNTIME_BUDGET_POLICY.queues.backgroundWorkers;
42
+ }
43
+
37
44
  function nowIso(): string {
38
45
  return new Date().toISOString();
39
46
  }
@@ -258,6 +265,7 @@ export function startEcosystemTask(
258
265
  action: EcosystemTaskAction,
259
266
  ): EcosystemTask | null {
260
267
  if (!acceptingTasks) return null;
268
+ if (activeTasks.size >= activeTaskLimit()) return null;
261
269
  if (action === "recipe") return null;
262
270
  const definition =
263
271
  action === "runner-presets"
@@ -291,6 +299,7 @@ export function startEcosystemTask(
291
299
 
292
300
  export function startEcosystemRecipeTask(recipeId: string): EcosystemTask | null {
293
301
  if (!acceptingTasks) return null;
302
+ if (activeTasks.size >= activeTaskLimit()) return null;
294
303
  const recipe = findEcosystemRecipe(recipeId);
295
304
  if (recipe === null) return null;
296
305
  if (!recipe.runnable) return null;
@@ -367,6 +376,7 @@ export function _startEcosystemTaskForTests(
367
376
  timeoutMs = RECIPE_TIMEOUT_MS,
368
377
  ): EcosystemTask | null {
369
378
  if (!acceptingTasks) return null;
379
+ if (activeTasks.size >= activeTaskLimit()) return null;
370
380
  const task: EcosystemTask = {
371
381
  id: randomUUID(),
372
382
  packageId: "test-package",
@@ -393,3 +403,13 @@ export function _resetEcosystemTasksForTests(): void {
393
403
  activeTasks.clear();
394
404
  acceptingTasks = true;
395
405
  }
406
+
407
+ export function _getEcosystemTaskAdmissionHealthForTests(): {
408
+ activeTasks: number;
409
+ activeTaskLimit: number;
410
+ } {
411
+ return {
412
+ activeTasks: activeTasks.size,
413
+ activeTaskLimit: activeTaskLimit(),
414
+ };
415
+ }