agent-accelerator 0.5.4 → 0.5.6

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.
@@ -171,9 +171,11 @@ export function normalizeToolMap(
171
171
  // Shared dynamic-worker helpers (pure, no store access)
172
172
  // ---------------------------------------------------------------------------
173
173
 
174
- /** Resolves per-worker timeout: `>0` fixed, `0` unlimited, `-1` per-task. */
175
- export function normalizeWorkerTimeoutValue(cfgTimeout: number, taskTimeoutMs: unknown): number {
176
- return cfgTimeout > 0 ? cfgTimeout : cfgTimeout === -1 && (taskTimeoutMs as number) > 0 ? Math.floor(taskTimeoutMs as number) : 0;
174
+ // The time limit of one scheduled run in ms, 0 = none: the action's own `timeoutMs`, never above the developer's `workerTimeoutMs` (Infinity = none), which is also the limit when the action sets none; in: Agent.dynamicSubagents.workerTimeoutMs + the saved action, out: Scheduler.attempt.
175
+ export function scheduledRunTimeoutMs(cap: number, taskTimeoutMs: number | undefined): number {
176
+ const own = typeof taskTimeoutMs === "number" && taskTimeoutMs > 0 ? Math.floor(taskTimeoutMs) : Infinity;
177
+ const ms = Math.min(own, cap);
178
+ return Number.isFinite(ms) ? ms : 0;
177
179
  }
178
180
 
179
181
  /**
@@ -236,8 +238,8 @@ export function resolveWorkerCredentials(
236
238
  return { apiKeyToUse: undefined, baseUrlToUse: undefined, attemptedProvider };
237
239
  }
238
240
 
239
- /** How many levels of workers may exist below an agent unless `dynamicSubagents.maxDepth` says otherwise. */
240
- export const DEFAULT_MAX_DEPTH = 3;
241
+ /** How many levels of workers may exist below an agent unless `dynamicSubagents.maxDepth` says otherwise: unlimited. */
242
+ export const DEFAULT_MAX_DEPTH = Infinity;
241
243
 
242
244
  /**
243
245
  * Config of a dynamic worker (scheduled actions, the worker registry): the developer's fixed worker
@@ -271,7 +271,7 @@ export class WorkerRegistry {
271
271
  "[Agent Accelerator] Cannot spawn workers: no sub-agent model is configured. Set dynamicSubagents.model in Agent config or set the SUB_AGENT_MODEL environment variable."
272
272
  );
273
273
  }
274
- const maxOpen = parent.dynamicSubagents?.maxSpawn ?? 4;
274
+ const maxOpen = parent.dynamicSubagents?.maxSpawn ?? Infinity;
275
275
  if (this.list().length >= maxOpen) {
276
276
  throw new ValidationError(`Too many open workers (${maxOpen} is the limit). Close one first; open workers: ${this.list().map((w) => w.id).join(", ")}.`);
277
277
  }
@@ -361,7 +361,7 @@ export class WorkerRegistry {
361
361
  else delete meta.last;
362
362
  this.save(entry);
363
363
  }
364
- const onAbort = (): void => agent.interrupt(opts.signal?.reason);
364
+ const onAbort = (): void => agent.interrupt(opts.signal?.reason instanceof Error ? opts.signal.reason : undefined);
365
365
  try {
366
366
  if (opts.signal?.aborted) throw new Error("aborted before the worker started");
367
367
  opts.signal?.addEventListener("abort", onAbort, { once: true });
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export { Agent, SubAgentModelError } from "./agent/agent.ts";
37
37
  */
38
38
  export { SubAgent, agentFromEnv } from "./agent/subagent.ts";
39
39
  export type { SubAgentConfig } from "./agent/subagent.ts";
40
+ export type { EditableAgentConfig } from "./types/agent.ts";
40
41
  export {
41
42
  tool,
42
43
  toStandardToolDeclarations,
@@ -44,7 +45,6 @@ export {
44
45
  TOOL_NAME_PATTERN,
45
46
  normalizeThinkingV2,
46
47
  resolveWhenBusy,
47
- normalizeWorkerTimeout,
48
48
  normalizeMaxAttempts,
49
49
  } from "./tools/tool.ts";
50
50
  export type { CreateToolOptions } from "./tools/tool.ts";
@@ -94,7 +94,7 @@ export {
94
94
  updateScheduledAction,
95
95
  runScheduledAction,
96
96
  skipScheduledAction,
97
- killScheduledAction,
97
+ deleteScheduledAction,
98
98
  } from "./scheduler/scheduler.ts";
99
99
  export { getDatetimeTool } from "./scheduler/tool.ts";
100
100
  export { setHome, resolveHome } from "./utils/home.ts";
@@ -106,7 +106,7 @@ export type {
106
106
  ScheduledState,
107
107
  ScheduledRunStatus,
108
108
  ScheduleSpec,
109
- KillResult,
109
+ DeleteResult,
110
110
  } from "./types/scheduler.ts";
111
111
 
112
112
  // Providers & Registry — unified multi-provider layer, all native REST
@@ -58,6 +58,8 @@ import {
58
58
  retryOptionsFrom,
59
59
  headersToRecord,
60
60
  parseRetryAfterMs,
61
+ linkStreamAbort,
62
+ withAbortTimer,
61
63
  } from "./shared.ts";
62
64
  import { telemetryBus } from "../telemetry.ts";
63
65
  import { createGenericModelSpec } from "../models/catalog.ts";
@@ -1097,23 +1099,7 @@ export class GoogleInteractionsProvider implements Provider {
1097
1099
  const startTime = Date.now();
1098
1100
  const clean = cleanModelId(model);
1099
1101
 
1100
- const linked = new AbortController();
1101
- const forwardUserAbort = () => {
1102
- try {
1103
- linked.abort((options?.signal as { reason?: unknown })?.reason);
1104
- } catch {
1105
- try {
1106
- linked.abort();
1107
- } catch {}
1108
- }
1109
- };
1110
- if (options?.signal?.aborted) forwardUserAbort();
1111
- else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
1112
- const removeStreamCancel = eventStream.onCancel(() => {
1113
- try {
1114
- linked.abort();
1115
- } catch {}
1116
- });
1102
+ const { linked, release } = linkStreamAbort(eventStream, options?.signal);
1117
1103
 
1118
1104
  (async () => {
1119
1105
  // Hoisted for the catch-block self-heal (consts inside try are not
@@ -1159,21 +1145,7 @@ export class GoogleInteractionsProvider implements Provider {
1159
1145
  // (429/5xx/network) are retried before the first byte; a live stream is never retried.
1160
1146
  const open = (target: string, method: "POST" | "GET"): Promise<Response> =>
1161
1147
  openStreamResponse({
1162
- fetchOnce: async () => {
1163
- const timer =
1164
- streamTimeoutMs && streamTimeoutMs > 0
1165
- ? setTimeout(() => {
1166
- try {
1167
- linked.abort();
1168
- } catch {}
1169
- }, streamTimeoutMs)
1170
- : undefined;
1171
- try {
1172
- return await (options?.fetch ?? fetch)(target, this.requestInit(method, method === "POST" ? body : undefined, options, apiKey, linked.signal));
1173
- } finally {
1174
- if (timer) clearTimeout(timer);
1175
- }
1176
- },
1148
+ fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(target, this.requestInit(method, method === "POST" ? body : undefined, options, apiKey, linked.signal))),
1177
1149
  onHttpError: (r, text) => this.throwIfError(r.status, r.statusText, target, text, clean, headersToRecord(r.headers)),
1178
1150
  noBodyError: () => toConciseProviderError(new Error("Google streaming response had no body"), "google", clean),
1179
1151
  retry: retryOptionsFrom(options),
@@ -1210,7 +1182,7 @@ export class GoogleInteractionsProvider implements Provider {
1210
1182
  () => {
1211
1183
  aborted = true;
1212
1184
  try {
1213
- void reader?.cancel();
1185
+ void reader?.cancel().catch(() => {}); // an aborted body is already errored: its cancel() rejects, and nobody else listens
1214
1186
  } catch {}
1215
1187
  },
1216
1188
  { once: true }
@@ -1465,12 +1437,7 @@ export class GoogleInteractionsProvider implements Provider {
1465
1437
  eventStream.fail(raw);
1466
1438
  }
1467
1439
  } finally {
1468
- try {
1469
- options?.signal?.removeEventListener("abort", forwardUserAbort);
1470
- } catch {}
1471
- try {
1472
- removeStreamCancel();
1473
- } catch {}
1440
+ release();
1474
1441
  }
1475
1442
  })();
1476
1443
 
@@ -52,7 +52,6 @@ import type { ProviderContext, ContentPart } from "../types/message.ts";
52
52
  import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
53
53
  import type { TokenUsage, ThinkingConfig } from "../types/core.ts";
54
54
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
55
- import { SSEParser } from "../streaming/sse-parser.ts";
56
55
  import { AgentResponse } from "../types/response.ts";
57
56
  import { getApiKey, getEnv } from "../utils/env.ts";
58
57
  import { buildSessionHeaders, setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
@@ -68,7 +67,14 @@ import {
68
67
  redactedHeaders,
69
68
  readErrorPayload,
70
69
  incompleteStreamError,
71
- hasTruncatedToolArguments,
70
+ hasUnfinishedToolCall,
71
+ linkStreamAbort,
72
+ withAbortTimer,
73
+ readSseData,
74
+ incompleteStreamCheck,
75
+ failStream,
76
+ toolArgumentsJson,
77
+ toolTurnFinishReason,
72
78
  combinedSignal,
73
79
  timeoutFor,
74
80
  hasAuthHeader,
@@ -450,7 +456,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
450
456
  } = {
451
457
  id: clampToolCallId(part.callId || part.id),
452
458
  type: "function",
453
- function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
459
+ function: { name: part.name, arguments: toolArgumentsJson(part.arguments) },
454
460
  };
455
461
  // Echo provider-issued thought signatures verbatim so endpoints
456
462
  // that minted them (Gemini behind a compat proxy) keep working.
@@ -741,7 +747,7 @@ export class OpenAICompatibleChatProvider implements Provider {
741
747
  const compatTools = toCompatTools(tools);
742
748
  if (compatTools) body.tools = compatTools;
743
749
  // Standard Chat Completions generation fields, only when the caller set them.
744
- Object.assign(body, mapGenerationParamsToChat(options));
750
+ Object.assign(body, mapGenerationParamsToChat(options, this.prefix, `${this.prefix}/${modelId}`));
745
751
  const toolChoice = mapToolChoiceToOpenAI(
746
752
  options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
747
753
  );
@@ -949,23 +955,7 @@ export class OpenAICompatibleChatProvider implements Provider {
949
955
  const prefix = this.prefix;
950
956
  const clean = cleanModelId(this.prefix, model);
951
957
 
952
- const linked = new AbortController();
953
- const forwardUserAbort = () => {
954
- try {
955
- linked.abort((options?.signal as { reason?: unknown })?.reason);
956
- } catch {
957
- try {
958
- linked.abort();
959
- } catch {}
960
- }
961
- };
962
- if (options?.signal?.aborted) forwardUserAbort();
963
- else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
964
- const removeStreamCancel = eventStream.onCancel(() => {
965
- try {
966
- linked.abort();
967
- } catch {}
968
- });
958
+ const { linked, release } = linkStreamAbort(eventStream, options?.signal);
969
959
 
970
960
  (async () => {
971
961
  try {
@@ -994,21 +984,7 @@ export class OpenAICompatibleChatProvider implements Provider {
994
984
  // Transient failures (429/5xx/network) are retried here, before the
995
985
  // first byte; once the stream is live it is never retried.
996
986
  const res = await openStreamResponse({
997
- fetchOnce: async () => {
998
- const timer =
999
- streamTimeoutMs && streamTimeoutMs > 0
1000
- ? setTimeout(() => {
1001
- try {
1002
- linked.abort();
1003
- } catch {}
1004
- }, streamTimeoutMs)
1005
- : undefined;
1006
- try {
1007
- return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
1008
- } finally {
1009
- if (timer) clearTimeout(timer);
1010
- }
1011
- },
987
+ fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))),
1012
988
  onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
1013
989
  noBodyError: () => toConciseProviderError(new Error("OpenAI-compatible streaming response had no body"), prefix, clean),
1014
990
  retry: retryOptionsFrom(options),
@@ -1022,9 +998,6 @@ export class OpenAICompatibleChatProvider implements Provider {
1022
998
  const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
1023
999
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
1024
1000
 
1025
- const parser = new SSEParser();
1026
- const reader = res.body!.getReader(); // openStreamResponse guarantees a body
1027
- const decoder = new TextDecoder();
1028
1001
  let text = "";
1029
1002
  let thinking = "";
1030
1003
  // Tool calls keyed by choice index: { id, name, startArgs, deltaArgs }.
@@ -1040,18 +1013,6 @@ export class OpenAICompatibleChatProvider implements Provider {
1040
1013
  let framesSeen = 0;
1041
1014
  let responseId: string | undefined;
1042
1015
  let completedBody: unknown = undefined;
1043
- let aborted = false;
1044
- linked.signal.addEventListener(
1045
- "abort",
1046
- () => {
1047
- aborted = true;
1048
- try {
1049
- void reader.cancel();
1050
- } catch {}
1051
- },
1052
- { once: true }
1053
- );
1054
-
1055
1016
  const handleMessage = (data: string): void => {
1056
1017
  if (!data) return;
1057
1018
  if (data === "[DONE]") {
@@ -1176,26 +1137,7 @@ export class OpenAICompatibleChatProvider implements Provider {
1176
1137
  }
1177
1138
  };
1178
1139
 
1179
- while (true) {
1180
- if (linked.signal.aborted || eventStream.isCancelled()) {
1181
- try {
1182
- await reader.cancel();
1183
- } catch {}
1184
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1185
- }
1186
- const { done, value } = await reader.read();
1187
- if (done) break;
1188
- const chunk = decoder.decode(value, { stream: true });
1189
- for (const m of parser.feed(chunk)) handleMessage(m.data);
1190
- }
1191
- for (const m of parser.flush()) handleMessage(m.data);
1192
- try {
1193
- reader.releaseLock();
1194
- } catch {}
1195
-
1196
- if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
1197
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1198
- }
1140
+ await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
1199
1141
 
1200
1142
  const toolCalls: ToolCallRecord[] = [];
1201
1143
  for (const [index, c] of calls) {
@@ -1212,9 +1154,11 @@ export class OpenAICompatibleChatProvider implements Provider {
1212
1154
  eventStream.push({ type: "tool_call_complete", toolCall: record });
1213
1155
  }
1214
1156
 
1157
+ // Did the stream really end? Read before the tool-call and refusal overrides below, which also set sawFinishReason.
1158
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1215
1159
  noteProviderTurn(sessionId, prefix);
1216
1160
  if (toolCalls.length > 0) {
1217
- finishReason = "tool_calls";
1161
+ finishReason = toolTurnFinishReason(finishReason, toolCalls);
1218
1162
  sawFinishReason = true;
1219
1163
  } else if (refusalSeen) {
1220
1164
  finishReason = "refusal";
@@ -1224,26 +1168,21 @@ export class OpenAICompatibleChatProvider implements Provider {
1224
1168
  // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
1225
1169
  // instead of resolving empty success. Skipped for non-SSE payloads
1226
1170
  // (JSON mocks) where no SSE frames were ever seen.
1227
- const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1228
- const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1229
- const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1230
- const isSSEPayload =
1231
- (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1232
- framesSeen > 0 ||
1233
- badFrames > 0;
1234
- if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1235
- eventStream.fail(incompleteStreamError(prefix, clean));
1236
- return;
1237
- }
1238
- // Refuse unfinished tool calls whose args never parsed (output_item.done
1239
- // never arrived): truncated `{raw}` must not reach the executor (pi
1240
- // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1241
- if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1242
- eventStream.fail(incompleteStreamError(prefix, clean));
1171
+ const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1172
+ const incomplete = incompleteStreamCheck({
1173
+ provider: prefix,
1174
+ modelId: clean,
1175
+ hasTerminal,
1176
+ empty: !text && !cleanThinking && toolCalls.length === 0,
1177
+ unfinishedCall: hasUnfinishedToolCall(calls.values(), toolCalls),
1178
+ responseHeaders,
1179
+ framesSeen,
1180
+ badFrames,
1181
+ });
1182
+ if (incomplete) {
1183
+ eventStream.fail(incomplete);
1243
1184
  return;
1244
1185
  }
1245
-
1246
- const cleanThinking = cleanThinkingEarly;
1247
1186
  const finalThoughtSignature = toolCalls.map((c) => c.thoughtSignature).find(Boolean);
1248
1187
  const finalResponse = new AgentResponse({
1249
1188
  text,
@@ -1264,21 +1203,9 @@ export class OpenAICompatibleChatProvider implements Provider {
1264
1203
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
1265
1204
  eventStream.end(finalResponse);
1266
1205
  } catch (err: unknown) {
1267
- const raw = err instanceof Error ? err : new Error(String(err));
1268
- const isAbort =
1269
- linked.signal.aborted ||
1270
- eventStream.isCancelled() ||
1271
- (raw as { name?: string }).name === "AbortError";
1272
- eventStream.fail(
1273
- isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1274
- );
1206
+ failStream(eventStream, linked, err);
1275
1207
  } finally {
1276
- try {
1277
- options?.signal?.removeEventListener("abort", forwardUserAbort);
1278
- } catch {}
1279
- try {
1280
- removeStreamCancel();
1281
- } catch {}
1208
+ release();
1282
1209
  }
1283
1210
  })();
1284
1211
 
@@ -44,7 +44,6 @@ import type { ProviderContext, ContentPart } from "../types/message.ts";
44
44
  import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
45
45
  import type { TokenUsage } from "../types/core.ts";
46
46
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
47
- import { SSEParser } from "../streaming/sse-parser.ts";
48
47
  import { AgentResponse } from "../types/response.ts";
49
48
  import { getApiKey, getEnv } from "../utils/env.ts";
50
49
  import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
@@ -60,7 +59,14 @@ import {
60
59
  redactedHeaders,
61
60
  readErrorPayload,
62
61
  incompleteStreamError,
63
- hasTruncatedToolArguments,
62
+ hasUnfinishedToolCall,
63
+ linkStreamAbort,
64
+ withAbortTimer,
65
+ readSseData,
66
+ incompleteStreamCheck,
67
+ failStream,
68
+ toolArgumentsJson,
69
+ toolTurnFinishReason,
64
70
  combinedSignal,
65
71
  timeoutFor,
66
72
  hasAuthHeader,
@@ -340,7 +346,7 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
340
346
  id: normalizeResponsesItemId(itemId),
341
347
  call_id: normalizeResponsesIdPart(callId),
342
348
  name: part.name,
343
- arguments: JSON.stringify(part.arguments || {}),
349
+ arguments: toolArgumentsJson(part.arguments),
344
350
  });
345
351
  } else if (part.type === "text" && part.text) {
346
352
  texts.push(part.text);
@@ -776,23 +782,7 @@ export class OpenAIResponsesProvider implements Provider {
776
782
  const startTime = Date.now();
777
783
  const clean = cleanModelId(model);
778
784
 
779
- const linked = new AbortController();
780
- const forwardUserAbort = () => {
781
- try {
782
- linked.abort((options?.signal as { reason?: unknown })?.reason);
783
- } catch {
784
- try {
785
- linked.abort();
786
- } catch {}
787
- }
788
- };
789
- if (options?.signal?.aborted) forwardUserAbort();
790
- else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
791
- const removeStreamCancel = eventStream.onCancel(() => {
792
- try {
793
- linked.abort();
794
- } catch {}
795
- });
785
+ const { linked, release } = linkStreamAbort(eventStream, options?.signal);
796
786
 
797
787
  (async () => {
798
788
  try {
@@ -827,21 +817,7 @@ export class OpenAIResponsesProvider implements Provider {
827
817
  // Transient failures (429/5xx/network) are retried here, before the
828
818
  // first byte; once the stream is live it is never retried.
829
819
  const res = await openStreamResponse({
830
- fetchOnce: async () => {
831
- const timer =
832
- streamTimeoutMs && streamTimeoutMs > 0
833
- ? setTimeout(() => {
834
- try {
835
- linked.abort();
836
- } catch {}
837
- }, streamTimeoutMs)
838
- : undefined;
839
- try {
840
- return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
841
- } finally {
842
- if (timer) clearTimeout(timer);
843
- }
844
- },
820
+ fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))),
845
821
  onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
846
822
  noBodyError: () => toConciseProviderError(new Error("OpenAI streaming response had no body"), "openai", clean),
847
823
  retry: retryOptionsFrom(options),
@@ -855,9 +831,6 @@ export class OpenAIResponsesProvider implements Provider {
855
831
  const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
856
832
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
857
833
 
858
- const parser = new SSEParser();
859
- const reader = res.body!.getReader(); // openStreamResponse guarantees a body
860
- const decoder = new TextDecoder();
861
834
  let text = "";
862
835
  let thinking = "";
863
836
  // Tool calls keyed by output_index: { itemId, callId, name, startArgs, deltaArgs }.
@@ -871,18 +844,6 @@ export class OpenAIResponsesProvider implements Provider {
871
844
  let framesSeen = 0;
872
845
  let responseId: string | undefined;
873
846
  let completedBody: unknown = undefined;
874
- let aborted = false;
875
- linked.signal.addEventListener(
876
- "abort",
877
- () => {
878
- aborted = true;
879
- try {
880
- void reader.cancel();
881
- } catch {}
882
- },
883
- { once: true }
884
- );
885
-
886
847
  const handleMessage = (data: string): void => {
887
848
  if (!data || data === "[DONE]") {
888
849
  if (data === "[DONE]") sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
@@ -1035,26 +996,7 @@ export class OpenAIResponsesProvider implements Provider {
1035
996
  }
1036
997
  };
1037
998
 
1038
- while (true) {
1039
- if (linked.signal.aborted || eventStream.isCancelled()) {
1040
- try {
1041
- await reader.cancel();
1042
- } catch {}
1043
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1044
- }
1045
- const { done, value } = await reader.read();
1046
- if (done) break;
1047
- const chunk = decoder.decode(value, { stream: true });
1048
- for (const m of parser.feed(chunk)) handleMessage(m.data);
1049
- }
1050
- for (const m of parser.flush()) handleMessage(m.data);
1051
- try {
1052
- reader.releaseLock();
1053
- } catch {}
1054
-
1055
- if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
1056
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1057
- }
999
+ await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
1058
1000
 
1059
1001
  const toolCalls: ToolCallRecord[] = [];
1060
1002
  for (const c of calls.values()) {
@@ -1070,9 +1012,11 @@ export class OpenAIResponsesProvider implements Provider {
1070
1012
  eventStream.push({ type: "tool_call_complete", toolCall: record });
1071
1013
  }
1072
1014
 
1015
+ // Did the stream really end? Read before the tool-call and refusal overrides below, which also set sawFinishReason.
1016
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1073
1017
  noteProviderTurn(sessionId, "openai");
1074
1018
  if (toolCalls.length > 0) {
1075
- finishReason = "tool_calls";
1019
+ finishReason = toolTurnFinishReason(finishReason, toolCalls);
1076
1020
  sawFinishReason = true;
1077
1021
  } else if (refusalSeen && finishReason !== "length") {
1078
1022
  finishReason = "refusal";
@@ -1085,26 +1029,21 @@ export class OpenAIResponsesProvider implements Provider {
1085
1029
  // Skipped for non-SSE payloads (e.g. JSON mocks in delegation tests)
1086
1030
  // where no SSE frames were ever seen — those preserve legacy empty
1087
1031
  // success rather than failing the caller on a mock artifact.
1088
- const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1089
- const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1090
- const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1091
- const isSSEPayload =
1092
- (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1093
- framesSeen > 0 ||
1094
- badFrames > 0;
1095
- if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1096
- eventStream.fail(incompleteStreamError("openai", clean));
1097
- return;
1098
- }
1099
- // Refuse unfinished tool calls whose args never parsed (output_item.done
1100
- // never arrived): truncated `{raw}` must not reach the executor (pi
1101
- // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1102
- if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1103
- eventStream.fail(incompleteStreamError("openai", clean));
1032
+ const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1033
+ const incomplete = incompleteStreamCheck({
1034
+ provider: "openai",
1035
+ modelId: clean,
1036
+ hasTerminal,
1037
+ empty: !text && !cleanThinking && toolCalls.length === 0,
1038
+ unfinishedCall: hasUnfinishedToolCall(calls.values(), toolCalls),
1039
+ responseHeaders,
1040
+ framesSeen,
1041
+ badFrames,
1042
+ });
1043
+ if (incomplete) {
1044
+ eventStream.fail(incomplete);
1104
1045
  return;
1105
1046
  }
1106
-
1107
- const cleanThinking = cleanThinkingEarly;
1108
1047
  const finalResponse = new AgentResponse({
1109
1048
  text,
1110
1049
  thinking: cleanThinking || undefined,
@@ -1124,21 +1063,9 @@ export class OpenAIResponsesProvider implements Provider {
1124
1063
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
1125
1064
  eventStream.end(finalResponse);
1126
1065
  } catch (err: unknown) {
1127
- const raw = err instanceof Error ? err : new Error(String(err));
1128
- const isAbort =
1129
- linked.signal.aborted ||
1130
- eventStream.isCancelled() ||
1131
- (raw as { name?: string }).name === "AbortError";
1132
- eventStream.fail(
1133
- isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1134
- );
1066
+ failStream(eventStream, linked, err);
1135
1067
  } finally {
1136
- try {
1137
- options?.signal?.removeEventListener("abort", forwardUserAbort);
1138
- } catch {}
1139
- try {
1140
- removeStreamCancel();
1141
- } catch {}
1068
+ release();
1142
1069
  }
1143
1070
  })();
1144
1071