@aragon-agent/core 0.2.16 → 0.2.19

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.
@@ -56,7 +56,8 @@ export interface LLMProvider {
56
56
  * Stream a completion from the model.
57
57
  *
58
58
  * The returned iterator yields {@link StreamEvent} items and MUST terminate
59
- * with either a `done` or an `error` event.
59
+ * with either a `done` or an `error` event, unless the caller cancels. Caller
60
+ * cancellation ends silently without executable tool results or a terminal.
60
61
  */
61
62
  stream(request: LLMRequest): AsyncIterableIterator<StreamEvent>;
62
63
  /**
@@ -13,6 +13,7 @@
13
13
  * Keep both usages inside functions.
14
14
  */
15
15
  import { parseRetryAfterMs } from './retry.js';
16
+ import { SseFrameLimitError } from './sse-parser.js';
16
17
  export class LLMError extends Error {
17
18
  provider;
18
19
  errorType;
@@ -106,6 +107,9 @@ export function classifyHttpError(statusCode, body, provider, headers) {
106
107
  export function wrapFetchError(err, provider) {
107
108
  if (err instanceof LLMError)
108
109
  return err;
110
+ if (err instanceof SseFrameLimitError) {
111
+ return new LLMError('SSE_FRAME_TOO_LARGE', provider, 'unknown', false);
112
+ }
109
113
  const message = err instanceof Error ? err.message : String(err);
110
114
  if (err instanceof Error && err.name === 'AbortError') {
111
115
  return new LLMError(`${provider} request aborted: ${message}`, provider, 'timeout', true);
@@ -33,6 +33,8 @@ export class AnthropicProvider {
33
33
  // stream
34
34
  // -----------------------------------------------------------------------
35
35
  async *stream(request) {
36
+ if (request.signal?.aborted)
37
+ return;
36
38
  const baseUrl = (request.baseUrl || this.defaultBaseUrl).replace(/\/+$/, '');
37
39
  const url = `${baseUrl}/v1/messages`;
38
40
  const body = buildRequestBody(request);
@@ -59,9 +61,13 @@ export class AnthropicProvider {
59
61
  errorBody = attempt.bodyText;
60
62
  }
61
63
  catch (err) {
64
+ if (request.signal?.aborted)
65
+ return;
62
66
  yield { type: 'error', error: wrapFetchError(err, this.id) };
63
67
  return;
64
68
  }
69
+ if (request.signal?.aborted)
70
+ return;
65
71
  if (!response.ok) {
66
72
  // `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
67
73
  // `response.headers` carries `Retry-After` / `anthropic-ratelimit-*-reset`
@@ -86,6 +92,8 @@ export class AnthropicProvider {
86
92
  let currentBlockIndex = -1;
87
93
  try {
88
94
  for await (const sse of parseSSEStream(response.body, request.signal)) {
95
+ if (request.signal?.aborted)
96
+ return;
89
97
  // Anthropic uses the `event` field to distinguish event types
90
98
  const eventType = sse.event;
91
99
  if (!eventType || !sse.data)
@@ -220,6 +228,8 @@ export class AnthropicProvider {
220
228
  break;
221
229
  }
222
230
  case 'message_stop': {
231
+ if (request.signal?.aborted)
232
+ return;
223
233
  const message = {
224
234
  role: 'assistant',
225
235
  content: contentBlocks,
@@ -249,44 +259,17 @@ export class AnthropicProvider {
249
259
  break;
250
260
  }
251
261
  }
252
- // ---- The terminal-sentinel contract (llm-api-retry-backoff §4.5a) ----
253
- //
254
- // Reaching here means the SSE ended WITHOUT `message_stop`: that case
255
- // `return`s from inside the loop above, and so does the in-stream `error`
256
- // case. No `sawTerminal` flag is needed for this adapter precisely because
257
- // of those two returns — the absence is the signal. (`openai.ts` and
258
- // `google.ts` DO need a flag, because their terminal frames only `break`.)
259
- //
260
- // TWO VERY DIFFERENT SITUATIONS SHARE THIS LINE, and conflating them is how
261
- // every user abort would become a fake network error (constraint 1 / R-17):
262
- //
263
- // - the request was ABORTED. `parseSSEStream` `break`s on
264
- // `signal.aborted` rather than throwing, so Esc lands here. An abort is
265
- // not a provider failure, and `agent-loop` discards the `done` anyway.
266
- // - the stream was TRUNCATED — a proxy idle cut, a load-balancer timeout,
267
- // an HTTP/2 GOAWAY, `message_stop` never arriving. This USED to yield
268
- // `done` with partial content, which is indistinguishable from success
269
- // at every layer above and is the single failure the retry machinery
270
- // exists to recover. It is now a retryable `network_error`.
271
- //
272
- // An empty-but-complete turn is unaffected: Anthropic always sends
273
- // `message_stop`, so a model that emits zero content still returns above.
274
- if (request.signal?.aborted) {
275
- const message = {
276
- role: 'assistant',
277
- content: contentBlocks,
278
- usage,
279
- stopReason,
280
- };
281
- yield { type: 'done', message, usage };
262
+ // A caller abort is silent; only an incomplete transport EOF is retried.
263
+ if (request.signal?.aborted)
282
264
  return;
283
- }
284
265
  yield {
285
266
  type: 'error',
286
267
  error: new LLMError(`${this.id} stream truncated before message_stop`, this.id, 'network_error', true),
287
268
  };
288
269
  }
289
270
  catch (err) {
271
+ if (request.signal?.aborted)
272
+ return;
290
273
  yield { type: 'error', error: wrapFetchError(err, this.id) };
291
274
  }
292
275
  }
@@ -31,6 +31,8 @@ export class GoogleProvider {
31
31
  // stream
32
32
  // -----------------------------------------------------------------------
33
33
  async *stream(request) {
34
+ if (request.signal?.aborted)
35
+ return;
34
36
  const baseUrl = (request.baseUrl || this.defaultBaseUrl).replace(/\/+$/, '');
35
37
  const url = `${baseUrl}/v1beta/models/${request.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(request.apiKey)}`;
36
38
  const body = buildRequestBody(request);
@@ -53,9 +55,13 @@ export class GoogleProvider {
53
55
  errorBody = attempt.bodyText;
54
56
  }
55
57
  catch (err) {
58
+ if (request.signal?.aborted)
59
+ return;
56
60
  yield { type: 'error', error: wrapFetchError(err, this.id) };
57
61
  return;
58
62
  }
63
+ if (request.signal?.aborted)
64
+ return;
59
65
  if (!response.ok) {
60
66
  // `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
61
67
  // `response.headers` carries `Retry-After` out on `LLMError.retryAfterMs`
@@ -86,6 +92,8 @@ export class GoogleProvider {
86
92
  let sawTerminal = false;
87
93
  try {
88
94
  for await (const sse of parseSSEStream(response.body, request.signal)) {
95
+ if (request.signal?.aborted)
96
+ return;
89
97
  const raw = sse.data.trim();
90
98
  if (!raw)
91
99
  continue;
@@ -120,19 +128,29 @@ export class GoogleProvider {
120
128
  if (!parts)
121
129
  continue;
122
130
  for (const part of parts) {
131
+ if (request.signal?.aborted)
132
+ return;
123
133
  // Text part
124
134
  if (typeof part.text === 'string') {
125
135
  textAccumulator += part.text;
126
136
  yield { type: 'text_delta', delta: part.text };
127
137
  }
138
+ if (request.signal?.aborted)
139
+ return;
128
140
  // Function call — arrives complete in a single chunk
129
141
  const functionCall = part.functionCall;
130
142
  if (functionCall) {
131
143
  const toolName = functionCall.name || '';
132
144
  const args = functionCall.args || {};
133
145
  const toolCallId = `call_${randomUUID().slice(0, 8)}`;
146
+ if (request.signal?.aborted)
147
+ return;
134
148
  yield { type: 'tool_call_start', toolCallId, toolName };
149
+ if (request.signal?.aborted)
150
+ return;
135
151
  yield { type: 'tool_call_end', toolCallId, toolName, args };
152
+ if (request.signal?.aborted)
153
+ return;
136
154
  contentBlocks.push({
137
155
  type: 'tool_call',
138
156
  toolCallId,
@@ -142,13 +160,10 @@ export class GoogleProvider {
142
160
  }
143
161
  }
144
162
  }
145
- // ---- The terminal-sentinel contract (§4.5a) -------------------------
146
- //
147
- // An abort is NOT a truncation: `parseSSEStream` `break`s on
148
- // `signal.aborted` and lands here, and reporting that as a retryable
149
- // `network_error` would re-create the trap `isRetryableError` check 1
150
- // closes (constraint 1 / R-17). Signal first; that path yields `done`.
151
- if (!sawTerminal && !request.signal?.aborted) {
163
+ // A caller abort is silent; only an incomplete transport EOF is retried.
164
+ if (request.signal?.aborted)
165
+ return;
166
+ if (!sawTerminal) {
152
167
  yield {
153
168
  type: 'error',
154
169
  error: new LLMError(`${this.id} stream truncated before a terminal chunk`, this.id, 'network_error', true),
@@ -159,6 +174,8 @@ export class GoogleProvider {
159
174
  if (textAccumulator) {
160
175
  contentBlocks.push({ type: 'text', text: textAccumulator });
161
176
  }
177
+ if (request.signal?.aborted)
178
+ return;
162
179
  const message = {
163
180
  role: 'assistant',
164
181
  content: contentBlocks,
@@ -168,6 +185,8 @@ export class GoogleProvider {
168
185
  yield { type: 'done', message, usage };
169
186
  }
170
187
  catch (err) {
188
+ if (request.signal?.aborted)
189
+ return;
171
190
  yield { type: 'error', error: wrapFetchError(err, this.id) };
172
191
  }
173
192
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { AssistantMessage, ModelInfo, StreamEvent } from '../types.js';
14
14
  import type { LLMProvider, LLMRequest } from '../provider.js';
15
+ export declare const OPENAI_TERMINAL_DRAIN_MS = 750;
15
16
  /**
16
17
  * Which parameter name carries the cap for this model.
17
18
  *
@@ -19,6 +19,7 @@ import { consumeStream } from '../stream-utils.js';
19
19
  // Constants
20
20
  // ---------------------------------------------------------------------------
21
21
  const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
22
+ export const OPENAI_TERMINAL_DRAIN_MS = 750;
22
23
  /** Reasoning-family models reject `max_tokens` in favour of `max_completion_tokens`. */
23
24
  const REASONING_MODEL_PATTERN = /^(o1|o3|o4|gpt-5)/;
24
25
  // Model ID patterns to filter out non-chat models
@@ -68,194 +69,38 @@ export class OpenAIProvider {
68
69
  // stream
69
70
  // -----------------------------------------------------------------------
70
71
  async *stream(request) {
71
- const baseUrl = (request.baseUrl || this.defaultBaseUrl).replace(/\/+$/, '');
72
- const url = `${baseUrl}/chat/completions`;
73
- const body = buildRequestBody(request);
74
- let response;
75
- let errorBody = '';
76
- try {
77
- const attempt = await sendWithOutputLimitRecovery({
78
- providerId: this.id,
79
- modelId: request.model,
80
- body,
81
- tokenField: openAiTokenField(request.model),
82
- send: (payload) => fetch(url, {
83
- method: 'POST',
84
- headers: {
85
- 'Content-Type': 'application/json',
86
- 'Authorization': `Bearer ${request.apiKey}`,
87
- },
88
- body: JSON.stringify(payload),
89
- signal: request.signal,
90
- }),
91
- });
92
- response = attempt.response;
93
- errorBody = attempt.bodyText;
94
- }
95
- catch (err) {
96
- yield { type: 'error', error: wrapFetchError(err, this.id) };
97
- return;
98
- }
99
- if (!response.ok) {
100
- // `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
101
- // `response.headers` carries `Retry-After` / `x-ratelimit-reset-after` out
102
- // on `LLMError.retryAfterMs` so a 429 waits as long as the server asked.
103
- yield {
104
- type: 'error',
105
- error: classifyHttpError(response.status, errorBody, this.id, response.headers),
106
- };
107
- return;
108
- }
109
- if (!response.body) {
110
- yield { type: 'error', error: new LLMError('Response body is null', this.id, 'network_error', true) };
72
+ if (request.signal?.aborted)
111
73
  return;
112
- }
113
- // -- Parse SSE events --
114
- const usage = { inputTokens: 0, outputTokens: 0 };
115
- const contentBlocks = [];
116
- let textAccumulator = '';
117
- let stopReason;
118
- // Accumulate tool calls by index
119
- const toolAccumulators = new Map();
120
- /**
121
- * Whether this stream reached a terminal frame (§4.5a). Unlike Anthropic's
122
- * `message_stop`, neither of OpenAI's terminal signals `return`s, so the
123
- * absence of a return cannot stand in for the flag.
124
- *
125
- * TWO SIGNALS, NOT ONE, and the second is not optional. The `[DONE]`
126
- * sentinel is the documented terminator, but it is a convention of OpenAI's
127
- * own server rather than a property of the wire format: several
128
- * OpenAI-compatible endpoints (the ones `--base-url` exists for) close the
129
- * connection after the last chunk without sending it. Keying truncation on
130
- * `[DONE]` alone would make every request against such a server a retryable
131
- * error, i.e. ten retries and then a failure, for a stream that completed
132
- * perfectly. A `finish_reason` on any choice is the model's own statement
133
- * that it stopped, and a stream cut mid-answer carries neither.
134
- */
135
- let sawTerminal = false;
74
+ const scope = new OpenAIRequestScope(request.signal);
136
75
  try {
137
- for await (const sse of parseSSEStream(response.body, request.signal)) {
138
- const raw = sse.data.trim();
139
- // [DONE] sentinel
140
- if (raw === '[DONE]') {
141
- sawTerminal = true;
142
- break;
143
- }
144
- let data;
145
- try {
146
- data = JSON.parse(raw);
147
- }
148
- catch {
149
- continue;
150
- }
151
- // Extract usage if present (some providers include it in the final chunk)
152
- const usageData = data.usage;
153
- if (usageData) {
154
- usage.inputTokens = usageData.prompt_tokens ?? usage.inputTokens;
155
- usage.outputTokens = usageData.completion_tokens ?? usage.outputTokens;
156
- }
157
- const choices = data.choices;
158
- if (!choices || choices.length === 0)
159
- continue;
160
- const choice = choices[0];
161
- const delta = choice.delta;
162
- const finishReason = choice.finish_reason;
163
- if (delta) {
164
- // Text content
165
- const content = delta.content;
166
- if (content) {
167
- textAccumulator += content;
168
- yield { type: 'text_delta', delta: content };
169
- }
170
- // Tool calls
171
- const toolCalls = delta.tool_calls;
172
- if (toolCalls) {
173
- for (const tc of toolCalls) {
174
- const idx = tc.index ?? 0;
175
- const fn = tc.function;
176
- let acc = toolAccumulators.get(idx);
177
- if (!acc) {
178
- acc = {
179
- index: idx,
180
- toolCallId: tc.id || `call_${idx}`,
181
- toolName: fn?.name || '',
182
- argsJson: '',
183
- started: false,
184
- };
185
- toolAccumulators.set(idx, acc);
186
- }
187
- // Update name if present in this chunk
188
- if (fn?.name) {
189
- acc.toolName = fn.name;
190
- }
191
- // Emit tool_call_start on first encounter
192
- if (!acc.started && acc.toolName) {
193
- acc.started = true;
194
- yield { type: 'tool_call_start', toolCallId: acc.toolCallId, toolName: acc.toolName };
195
- }
196
- // Accumulate argument fragments
197
- if (fn?.arguments) {
198
- const argChunk = fn.arguments;
199
- acc.argsJson += argChunk;
200
- if (acc.started) {
201
- yield { type: 'tool_call_delta', toolCallId: acc.toolCallId, argsDelta: argChunk };
202
- }
203
- }
204
- }
205
- }
206
- }
207
- // Finish reason handling
208
- if (finishReason) {
209
- sawTerminal = true;
210
- stopReason = mapFinishReason(finishReason);
211
- }
212
- }
213
- // ---- The terminal-sentinel contract (§4.5a) -------------------------
214
- //
215
- // An abort is NOT a truncation: `parseSSEStream` `break`s on
216
- // `signal.aborted`, which lands on exactly this path, and turning that into
217
- // a retryable `network_error` would re-create the trap `isRetryableError`
218
- // check 1 exists to close (constraint 1 / R-17). The signal is therefore
219
- // tested FIRST and that path keeps yielding `done`.
220
- if (!sawTerminal && !request.signal?.aborted) {
221
- yield {
222
- type: 'error',
223
- error: new LLMError(`${this.id} stream truncated before a terminal chunk`, this.id, 'network_error', true),
224
- };
76
+ if (request.signal?.aborted)
225
77
  return;
78
+ const attempt = await sendOpenAIRequest(request, this.defaultBaseUrl, scope.signal);
79
+ if (request.signal?.aborted)
80
+ return;
81
+ const response = attempt.response;
82
+ if (!response.ok) {
83
+ throw classifyHttpError(response.status, attempt.bodyText, this.id, response.headers);
226
84
  }
227
- // Flush text block
228
- if (textAccumulator) {
229
- contentBlocks.push({ type: 'text', text: textAccumulator });
85
+ if (!response.body) {
86
+ throw new LLMError('Response body is null', this.id, 'network_error', true);
230
87
  }
231
- // Flush tool calls — parse accumulated args JSON
232
- for (const acc of toolAccumulators.values()) {
233
- let args = {};
234
- try {
235
- args = acc.argsJson ? JSON.parse(acc.argsJson) : {};
236
- }
237
- catch {
238
- args = { __raw: acc.argsJson };
239
- }
240
- contentBlocks.push({
241
- type: 'tool_call',
242
- toolCallId: acc.toolCallId,
243
- toolName: acc.toolName,
244
- args,
245
- });
246
- yield { type: 'tool_call_end', toolCallId: acc.toolCallId, toolName: acc.toolName, args };
88
+ for await (const event of readOpenAIResponse(response.body, scope)) {
89
+ if (request.signal?.aborted)
90
+ return;
91
+ yield event;
92
+ if (request.signal?.aborted)
93
+ return;
247
94
  }
248
- const message = {
249
- role: 'assistant',
250
- content: contentBlocks,
251
- usage,
252
- stopReason,
253
- };
254
- yield { type: 'done', message, usage };
255
95
  }
256
96
  catch (err) {
97
+ if (request.signal?.aborted)
98
+ return;
257
99
  yield { type: 'error', error: wrapFetchError(err, this.id) };
258
100
  }
101
+ finally {
102
+ scope.dispose();
103
+ }
259
104
  }
260
105
  // -----------------------------------------------------------------------
261
106
  // complete
@@ -303,6 +148,208 @@ export class OpenAIProvider {
303
148
  }
304
149
  }
305
150
  }
151
+ /** Owns the request signal and the absolute, non-renewable terminal deadline. */
152
+ class OpenAIRequestScope {
153
+ caller;
154
+ controller = new AbortController();
155
+ timer;
156
+ cancelCause = null;
157
+ onCallerAbort = () => {
158
+ this.cancelCause = 'caller';
159
+ this.controller.abort();
160
+ };
161
+ constructor(caller) {
162
+ this.caller = caller;
163
+ caller?.addEventListener('abort', this.onCallerAbort, { once: true });
164
+ if (caller?.aborted)
165
+ this.onCallerAbort();
166
+ }
167
+ get signal() { return this.controller.signal; }
168
+ get callerAborted() { return this.caller?.aborted === true; }
169
+ get terminalDrainExpired() { return this.cancelCause === 'terminal-drain'; }
170
+ startDrain() {
171
+ if (this.timer !== undefined || this.cancelCause !== null)
172
+ return;
173
+ this.timer = setTimeout(() => {
174
+ if (this.callerAborted) {
175
+ this.onCallerAbort();
176
+ return;
177
+ }
178
+ this.cancelCause = 'terminal-drain';
179
+ this.controller.abort();
180
+ }, OPENAI_TERMINAL_DRAIN_MS);
181
+ }
182
+ dispose() {
183
+ if (this.timer !== undefined)
184
+ clearTimeout(this.timer);
185
+ this.caller?.removeEventListener('abort', this.onCallerAbort);
186
+ }
187
+ }
188
+ function sendOpenAIRequest(request, defaultBaseUrl, signal) {
189
+ const baseUrl = (request.baseUrl || defaultBaseUrl).replace(/\/+$/, '');
190
+ return sendWithOutputLimitRecovery({
191
+ providerId: 'openai',
192
+ modelId: request.model,
193
+ body: buildRequestBody(request),
194
+ tokenField: openAiTokenField(request.model),
195
+ send: (payload) => fetch(`${baseUrl}/chat/completions`, {
196
+ method: 'POST',
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ 'Authorization': `Bearer ${request.apiKey}`,
200
+ },
201
+ body: JSON.stringify(payload),
202
+ signal,
203
+ }),
204
+ });
205
+ }
206
+ async function* readOpenAIResponse(body, scope) {
207
+ const state = new OpenAIStreamState();
208
+ try {
209
+ for await (const sse of parseSSEStream(body, scope.signal)) {
210
+ if (scope.callerAborted)
211
+ return;
212
+ const raw = sse.data.trim();
213
+ if (raw === '[DONE]') {
214
+ state.sawTerminal = true;
215
+ break;
216
+ }
217
+ for (const event of state.consume(raw, scope)) {
218
+ if (scope.callerAborted)
219
+ return;
220
+ yield event;
221
+ if (scope.callerAborted)
222
+ return;
223
+ }
224
+ }
225
+ }
226
+ catch (err) {
227
+ if (scope.callerAborted)
228
+ return;
229
+ if (!scope.terminalDrainExpired || !state.sawTerminal)
230
+ throw err;
231
+ }
232
+ if (scope.callerAborted)
233
+ return;
234
+ if (!state.sawTerminal) {
235
+ throw new LLMError('openai stream truncated before a terminal chunk', 'openai', 'network_error', true);
236
+ }
237
+ for (const event of state.finish()) {
238
+ if (scope.callerAborted)
239
+ return;
240
+ yield event;
241
+ if (scope.callerAborted)
242
+ return;
243
+ }
244
+ }
245
+ class OpenAIStreamState {
246
+ usage = { inputTokens: 0, outputTokens: 0 };
247
+ sawTerminal = false;
248
+ text = '';
249
+ thinking = '';
250
+ stopReason;
251
+ tools = new Map();
252
+ *consume(raw, scope) {
253
+ let chunk;
254
+ try {
255
+ chunk = JSON.parse(raw);
256
+ }
257
+ catch {
258
+ return;
259
+ }
260
+ if (!chunk || typeof chunk !== 'object')
261
+ return;
262
+ const usage = chunk.usage;
263
+ if (usage) {
264
+ this.usage.inputTokens = usage.prompt_tokens ?? this.usage.inputTokens;
265
+ this.usage.outputTokens = usage.completion_tokens ?? this.usage.outputTokens;
266
+ }
267
+ if (this.sawTerminal)
268
+ return;
269
+ const choices = chunk.choices;
270
+ if (!Array.isArray(choices) || choices.length === 0)
271
+ return;
272
+ const choice = choices.find((candidate) => candidate.index === 0)
273
+ ?? (choices[0].index === undefined ? choices[0] : undefined);
274
+ if (!choice)
275
+ return;
276
+ if (typeof choice.finish_reason === 'string' && choice.finish_reason.length > 0) {
277
+ this.sawTerminal = true;
278
+ this.stopReason = mapFinishReason(choice.finish_reason);
279
+ // Start at receipt, including when a consumer pauses on this frame's delta.
280
+ scope.startDrain();
281
+ }
282
+ const delta = choice.delta;
283
+ if (delta)
284
+ yield* this.consumeDelta(delta);
285
+ }
286
+ *consumeDelta(delta) {
287
+ const reasoning = delta.reasoning_content;
288
+ if (typeof reasoning === 'string' && reasoning.length > 0) {
289
+ if (this.thinking.length === 0)
290
+ yield { type: 'thinking_start' };
291
+ this.thinking += reasoning;
292
+ yield { type: 'thinking_delta', delta: reasoning };
293
+ }
294
+ if (typeof delta.content === 'string' && delta.content.length > 0) {
295
+ this.text += delta.content;
296
+ yield { type: 'text_delta', delta: delta.content };
297
+ }
298
+ const tools = delta.tool_calls;
299
+ if (Array.isArray(tools)) {
300
+ for (const tool of tools)
301
+ yield* this.consumeTool(tool);
302
+ }
303
+ }
304
+ *consumeTool(tool) {
305
+ const index = tool.index ?? 0;
306
+ const fn = tool.function;
307
+ let acc = this.tools.get(index);
308
+ if (!acc) {
309
+ acc = {
310
+ index, toolCallId: tool.id || `call_${index}`,
311
+ toolName: fn?.name || '', argsJson: '', started: false,
312
+ };
313
+ this.tools.set(index, acc);
314
+ }
315
+ if (fn?.name)
316
+ acc.toolName = fn.name;
317
+ if (!acc.started && acc.toolName) {
318
+ acc.started = true;
319
+ yield { type: 'tool_call_start', toolCallId: acc.toolCallId, toolName: acc.toolName };
320
+ }
321
+ if (typeof fn?.arguments === 'string' && fn.arguments.length > 0) {
322
+ acc.argsJson += fn.arguments;
323
+ if (acc.started) {
324
+ yield { type: 'tool_call_delta', toolCallId: acc.toolCallId, argsDelta: fn.arguments };
325
+ }
326
+ }
327
+ }
328
+ *finish() {
329
+ const content = [];
330
+ if (this.thinking)
331
+ content.push({ type: 'thinking', text: this.thinking });
332
+ if (this.text)
333
+ content.push({ type: 'text', text: this.text });
334
+ for (const acc of this.tools.values()) {
335
+ let args = {};
336
+ try {
337
+ args = acc.argsJson ? JSON.parse(acc.argsJson) : {};
338
+ }
339
+ catch {
340
+ args = { __raw: acc.argsJson };
341
+ }
342
+ content.push({
343
+ type: 'tool_call', toolCallId: acc.toolCallId, toolName: acc.toolName, args,
344
+ });
345
+ yield { type: 'tool_call_end', toolCallId: acc.toolCallId, toolName: acc.toolName, args };
346
+ }
347
+ const message = {
348
+ role: 'assistant', content, usage: this.usage, stopReason: this.stopReason,
349
+ };
350
+ yield { type: 'done', message, usage: this.usage };
351
+ }
352
+ }
306
353
  // ---------------------------------------------------------------------------
307
354
  // Internal: request body builder
308
355
  // ---------------------------------------------------------------------------
package/dist/llm/retry.js CHANGED
@@ -366,6 +366,8 @@ export async function* withRetry(makeStream, opts) {
366
366
  /** Retries COMPLETED so far. */
367
367
  let retryIndex = 0;
368
368
  for (;;) {
369
+ if (Boolean(signal?.aborted))
370
+ return;
369
371
  let committed = false;
370
372
  const toolCallIds = [];
371
373
  let sawDone = false;
@@ -388,6 +390,8 @@ export async function* withRetry(makeStream, opts) {
388
390
  sawDone = true;
389
391
  yield event;
390
392
  }
393
+ if (Boolean(signal?.aborted))
394
+ return;
391
395
  if (sawDone)
392
396
  return; // success; the hot path ends here
393
397
  if (terminalError === undefined) {
@@ -0,0 +1,25 @@
1
+ import type { SSEEvent } from './stream-utils.js';
2
+ export declare const SSE_FRAME_LIMIT = 1048576;
3
+ /** A single SSE frame exceeded its UTF-16 capacity; retrying cannot repair it. */
4
+ export declare class SseFrameLimitError extends Error {
5
+ constructor();
6
+ }
7
+ /** Incremental model-stream parser; IDs are local to each event, not reconnect state. */
8
+ export declare class SseParser {
9
+ private line;
10
+ private dataLines;
11
+ private event;
12
+ private id;
13
+ private frameUnits;
14
+ private skipNextLf;
15
+ private atStart;
16
+ /** Consume decoded text lazily, throwing SseFrameLimitError on an oversized frame. */
17
+ push(text: string): IterableIterator<SSEEvent>;
18
+ /** Preserve the model adapters' complete-last-line compatibility at normal EOF. */
19
+ finish(): IterableIterator<SSEEvent>;
20
+ /** Discard uncommitted input without dispatching it, including on cancellation. */
21
+ clear(): void;
22
+ private countUnit;
23
+ private finishLine;
24
+ private dispatch;
25
+ }
@@ -0,0 +1,103 @@
1
+ export const SSE_FRAME_LIMIT = 1_048_576;
2
+ /** A single SSE frame exceeded its UTF-16 capacity; retrying cannot repair it. */
3
+ export class SseFrameLimitError extends Error {
4
+ constructor() {
5
+ super(`SSE frame exceeds ${SSE_FRAME_LIMIT} UTF-16 code units`);
6
+ this.name = 'SseFrameLimitError';
7
+ }
8
+ }
9
+ /** Incremental model-stream parser; IDs are local to each event, not reconnect state. */
10
+ export class SseParser {
11
+ line = '';
12
+ dataLines = [];
13
+ event;
14
+ id;
15
+ frameUnits = 0;
16
+ skipNextLf = false;
17
+ atStart = true;
18
+ /** Consume decoded text lazily, throwing SseFrameLimitError on an oversized frame. */
19
+ *push(text) {
20
+ for (let index = 0; index < text.length; index++) {
21
+ const char = text[index];
22
+ if (this.atStart) {
23
+ this.atStart = false;
24
+ if (char === '\uFEFF')
25
+ continue;
26
+ }
27
+ if (this.skipNextLf) {
28
+ this.skipNextLf = false;
29
+ if (char === '\n') {
30
+ // A blank CR already dispatched/reset the frame. Its optional LF
31
+ // belongs to that delimiter, never to the next frame's budget.
32
+ if (this.frameUnits > 0)
33
+ this.countUnit();
34
+ continue;
35
+ }
36
+ }
37
+ this.countUnit();
38
+ if (char !== '\r' && char !== '\n') {
39
+ this.line += char;
40
+ continue;
41
+ }
42
+ this.skipNextLf = char === '\r';
43
+ const event = this.finishLine();
44
+ if (event)
45
+ yield event;
46
+ }
47
+ }
48
+ /** Preserve the model adapters' complete-last-line compatibility at normal EOF. */
49
+ *finish() {
50
+ if (this.line.length > 0)
51
+ this.finishLine();
52
+ const event = this.dispatch();
53
+ if (event)
54
+ yield event;
55
+ }
56
+ /** Discard uncommitted input without dispatching it, including on cancellation. */
57
+ clear() {
58
+ this.line = '';
59
+ this.dataLines = [];
60
+ this.event = undefined;
61
+ this.id = undefined;
62
+ this.frameUnits = 0;
63
+ }
64
+ countUnit() {
65
+ this.frameUnits++;
66
+ if (this.frameUnits <= SSE_FRAME_LIMIT)
67
+ return;
68
+ this.clear();
69
+ throw new SseFrameLimitError();
70
+ }
71
+ finishLine() {
72
+ const line = this.line;
73
+ this.line = '';
74
+ if (line.length === 0)
75
+ return this.dispatch();
76
+ if (line.startsWith(':'))
77
+ return;
78
+ const colon = line.indexOf(':');
79
+ const field = colon < 0 ? line : line.slice(0, colon);
80
+ let value = colon < 0 ? '' : line.slice(colon + 1);
81
+ if (value.startsWith(' '))
82
+ value = value.slice(1);
83
+ switch (field) {
84
+ case 'data':
85
+ this.dataLines.push(value);
86
+ break;
87
+ case 'event':
88
+ this.event = value;
89
+ break;
90
+ case 'id':
91
+ if (!value.includes('\0'))
92
+ this.id = value;
93
+ break;
94
+ }
95
+ }
96
+ dispatch() {
97
+ const event = this.dataLines.length > 0 || this.event !== undefined
98
+ ? { event: this.event, data: this.dataLines.join('\n'), id: this.id }
99
+ : undefined;
100
+ this.clear();
101
+ return event;
102
+ }
103
+ }
@@ -11,16 +11,9 @@ export interface SSEEvent {
11
11
  id?: string;
12
12
  }
13
13
  /**
14
- * Parse a `ReadableStream<Uint8Array>` (typically `Response.body`) into an
15
- * async iterator of {@link SSEEvent} objects.
16
- *
17
- * Handles:
18
- * - Multi-line `data:` fields (concatenated with `\n`)
19
- * - `event:` and `id:` fields
20
- * - Proper `\n\n` event boundary detection
21
- * - Graceful abort via `signal`
22
- *
23
- * The iterator ends when the stream closes or `signal` fires.
14
+ * Parse model SSE frames, retaining the normal EOF compatibility extension.
15
+ * Abort drops pending data; all early exits cancel once without awaiting cleanup.
16
+ * Throws SseFrameLimitError when an individual frame exceeds its capacity.
24
17
  */
25
18
  export declare function parseSSEStream(body: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncIterableIterator<SSEEvent>;
26
19
  /**
@@ -1,106 +1,92 @@
1
1
  /**
2
2
  * SSE stream parser and convenience helpers for LLM streaming.
3
3
  */
4
+ import { SseParser } from './sse-parser.js';
4
5
  // ---------------------------------------------------------------------------
5
6
  // Generic SSE parser
6
7
  // ---------------------------------------------------------------------------
7
8
  /**
8
- * Parse a `ReadableStream<Uint8Array>` (typically `Response.body`) into an
9
- * async iterator of {@link SSEEvent} objects.
10
- *
11
- * Handles:
12
- * - Multi-line `data:` fields (concatenated with `\n`)
13
- * - `event:` and `id:` fields
14
- * - Proper `\n\n` event boundary detection
15
- * - Graceful abort via `signal`
16
- *
17
- * The iterator ends when the stream closes or `signal` fires.
9
+ * Parse model SSE frames, retaining the normal EOF compatibility extension.
10
+ * Abort drops pending data; all early exits cancel once without awaiting cleanup.
11
+ * Throws SseFrameLimitError when an individual frame exceeds its capacity.
18
12
  */
19
13
  export async function* parseSSEStream(body, signal) {
14
+ if (signal?.aborted)
15
+ return;
20
16
  const reader = body.getReader();
21
- const decoder = new TextDecoder();
22
- let buffer = '';
23
- // Set up abort listener to release the reader
24
- const onAbort = () => {
25
- reader.cancel().catch(() => { });
17
+ const decoder = new TextDecoder('utf-8', { ignoreBOM: true });
18
+ const parser = new SseParser();
19
+ let reachedEof = false;
20
+ let cancelled = false;
21
+ const cancelOnce = () => {
22
+ if (cancelled)
23
+ return;
24
+ cancelled = true;
25
+ try {
26
+ // A transport can reject or never settle its cleanup promise.
27
+ void reader.cancel().catch(() => { });
28
+ }
29
+ catch {
30
+ // A synchronous transport cleanup failure must not mask the stream result.
31
+ }
26
32
  };
27
- signal?.addEventListener('abort', onAbort, { once: true });
33
+ signal?.addEventListener('abort', cancelOnce, { once: true });
28
34
  try {
29
- while (true) {
30
- if (signal?.aborted)
31
- break;
35
+ if (signal?.aborted) {
36
+ cancelOnce();
37
+ return;
38
+ }
39
+ while (!signal?.aborted) {
32
40
  const { done, value } = await reader.read();
33
- if (done)
41
+ if (signal?.aborted)
42
+ return;
43
+ if (done) {
44
+ reachedEof = true;
34
45
  break;
35
- buffer += decoder.decode(value, { stream: true });
36
- // Process complete events separated by \n\n
37
- let boundary;
38
- while ((boundary = buffer.indexOf('\n\n')) !== -1) {
39
- const rawEvent = buffer.slice(0, boundary);
40
- buffer = buffer.slice(boundary + 2);
41
- const parsed = parseSSEBlock(rawEvent);
42
- if (parsed) {
43
- yield parsed;
44
- }
45
46
  }
46
- }
47
- // Flush any trailing data that wasn't terminated by \n\n
48
- if (buffer.trim().length > 0) {
49
- const parsed = parseSSEBlock(buffer);
50
- if (parsed) {
51
- yield parsed;
47
+ for (const event of decodeChunk(value, decoder, parser)) {
48
+ if (signal?.aborted)
49
+ return;
50
+ yield event;
51
+ if (signal?.aborted)
52
+ return;
52
53
  }
53
54
  }
55
+ if (signal?.aborted)
56
+ return;
57
+ for (const event of parser.push(decoder.decode())) {
58
+ if (signal?.aborted)
59
+ return;
60
+ yield event;
61
+ }
62
+ if (signal?.aborted)
63
+ return;
64
+ for (const event of parser.finish()) {
65
+ if (signal?.aborted)
66
+ return;
67
+ yield event;
68
+ }
69
+ }
70
+ catch (err) {
71
+ cancelOnce();
72
+ if (signal?.aborted)
73
+ return;
74
+ throw err;
54
75
  }
55
76
  finally {
56
- signal?.removeEventListener('abort', onAbort);
77
+ signal?.removeEventListener('abort', cancelOnce);
78
+ parser.clear();
79
+ if (!reachedEof)
80
+ cancelOnce();
57
81
  reader.releaseLock();
58
82
  }
59
83
  }
60
- /**
61
- * Parse a single SSE block (the text between two `\n\n` boundaries) into an
62
- * {@link SSEEvent}. Returns `undefined` for comment-only or empty blocks.
63
- */
64
- function parseSSEBlock(block) {
65
- let event;
66
- let id;
67
- const dataLines = [];
68
- for (const line of block.split('\n')) {
69
- // Comment lines start with ':'
70
- if (line.startsWith(':'))
71
- continue;
72
- const colonIdx = line.indexOf(':');
73
- if (colonIdx === -1) {
74
- // Field with no value — treat field name as the value (per SSE spec)
75
- continue;
76
- }
77
- const field = line.slice(0, colonIdx);
78
- // Value starts after ':' — strip a single leading space if present
79
- let value = line.slice(colonIdx + 1);
80
- if (value.startsWith(' ')) {
81
- value = value.slice(1);
82
- }
83
- switch (field) {
84
- case 'event':
85
- event = value;
86
- break;
87
- case 'data':
88
- dataLines.push(value);
89
- break;
90
- case 'id':
91
- id = value;
92
- break;
93
- // 'retry' and unknown fields are ignored
94
- }
95
- }
96
- if (dataLines.length === 0 && event === undefined) {
97
- return undefined;
84
+ function* decodeChunk(bytes, decoder, parser) {
85
+ const decodeSliceBytes = 16 * 1024;
86
+ for (let offset = 0; offset < bytes.length; offset += decodeSliceBytes) {
87
+ const slice = bytes.subarray(offset, offset + decodeSliceBytes);
88
+ yield* parser.push(decoder.decode(slice, { stream: true }));
98
89
  }
99
- return {
100
- event,
101
- data: dataLines.join('\n'),
102
- id,
103
- };
104
90
  }
105
91
  // ---------------------------------------------------------------------------
106
92
  // Convenience: streamLLM / completeLLM
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/core",
3
- "version": "0.2.16",
3
+ "version": "0.2.19",
4
4
  "description": "AragonAgent core engine — LLM provider adapters, streaming, tool registry/execution, steering, idle watchdog, and an optional isolated-vm sandbox.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",