@yeaft/webchat-agent 1.0.629 → 1.0.631

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.
Binary file
@@ -17,6 +17,6 @@
17
17
  </head>
18
18
  <body>
19
19
  <div id="app"></div>
20
- <script type="module" src="app.bundle.js?v=81a8eab2"></script>
20
+ <script type="module" src="app.bundle.js?v=ed297286"></script>
21
21
  </body>
22
22
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.629",
3
+ "version": "1.0.631",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -182,6 +182,21 @@ export class LLMServerError extends Error {
182
182
  }
183
183
  }
184
184
 
185
+ /**
186
+ * Invalid/incomplete provider tool batch — recover by correcting arguments, not
187
+ * by replaying the same provider request. stream() publishes this as an error
188
+ * event with retryable:false (legacy Engine retries thrown LLMServerError by
189
+ * class); call() throws it. No tool_call may be published from the rejected batch.
190
+ */
191
+ export class LLMToolInputError extends LLMServerError {
192
+ constructor(message) {
193
+ super(message, 0);
194
+ this.name = 'LLMToolInputError';
195
+ this.toolInputRejected = true;
196
+ this.retryable = false;
197
+ }
198
+ }
199
+
185
200
  /** Streaming response went idle after the server accepted the request. Retryable. */
186
201
  export class LLMStreamIdleTimeoutError extends LLMServerError {
187
202
  constructor(message, idleMs) {
@@ -16,6 +16,7 @@ import {
16
16
  classifyPolicyError,
17
17
  LLMContextError,
18
18
  LLMServerError,
19
+ LLMToolInputError,
19
20
  LLMAbortError,
20
21
  readStreamChunkWithIdleTimeout,
21
22
  resolveStreamIdleTimeoutMs,
@@ -75,7 +76,7 @@ function hasNonEmptyText(value) {
75
76
 
76
77
  function requireToolInputObject(input) {
77
78
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
78
- throw new LLMServerError('Anthropic returned invalid or incomplete tool input', 0);
79
+ throw new LLMToolInputError('Anthropic returned invalid or incomplete tool input');
79
80
  }
80
81
  return input;
81
82
  }
@@ -334,12 +335,18 @@ export class AnthropicAdapter extends LLMAdapter {
334
335
  cacheReadTokens: result.usage?.cache_read_input_tokens || 0, cacheWriteTokens: result.usage?.cache_creation_input_tokens || 0,
335
336
  ...reasoningUsage(result.usage, 'anthropic') };
336
337
  if (signal?.aborted) throw new LLMAbortError();
337
- for (const block of result.content || []) {
338
- if (block.type !== 'tool_use') continue;
339
- requireToolInputObject(block.input);
340
- if (result.stop_reason === 'max_tokens') {
341
- throw new LLMServerError('Anthropic tool response was truncated at max_tokens', 0);
338
+ try {
339
+ for (const block of result.content || []) {
340
+ if (block.type !== 'tool_use') continue;
341
+ requireToolInputObject(block.input);
342
+ if (result.stop_reason === 'max_tokens') {
343
+ throw new LLMToolInputError('Anthropic tool response was truncated at max_tokens');
344
+ }
342
345
  }
346
+ } catch (error) {
347
+ if (!(error instanceof LLMToolInputError)) throw error;
348
+ yield { type: 'error', error, retryable: false };
349
+ return;
343
350
  }
344
351
  const state = createProviderState({ context, identity: requestIdentity, items: result.content, responseId: result.id });
345
352
  for (const block of result.content || []) {
@@ -389,10 +396,12 @@ export class AnthropicAdapter extends LLMAdapter {
389
396
  let sawMessageStart = false;
390
397
  let cumulativeOutputTokens = 0;
391
398
  let toolInputError = null;
399
+ let malformedStream = false;
392
400
  const hasToolBlocks = () => toolInputError !== null || [...blockByIndex.values()].some(block => block.kind === 'tool_use')
393
401
  || [...completedBlocks.values()].some(block => block.type === 'tool_use');
394
402
  const requireClosedBlocks = () => {
395
403
  if (blockByIndex.size > 0) {
404
+ if (hasToolBlocks()) throw new LLMToolInputError('Anthropic stream ended with an incomplete tool batch');
396
405
  throw new LLMServerError('Anthropic stream ended with an incomplete content block', 0);
397
406
  }
398
407
  };
@@ -420,7 +429,7 @@ export class AnthropicAdapter extends LLMAdapter {
420
429
  if (data === '[DONE]') {
421
430
  // Legacy text-only gateways may use [DONE]. Tools require the
422
431
  // native message_stop boundary; never seal a partial batch here.
423
- if (hasToolBlocks()) throw new LLMServerError('Anthropic stream ended before stop event', 0);
432
+ if (hasToolBlocks()) throw new LLMToolInputError('Anthropic tool stream ended before stop event');
424
433
  requireClosedBlocks();
425
434
  sawStop = true;
426
435
  continue;
@@ -430,9 +439,9 @@ export class AnthropicAdapter extends LLMAdapter {
430
439
  try {
431
440
  event = JSON.parse(data);
432
441
  } catch {
433
- // A lost input delta can otherwise turn the start placeholder {}
434
- // into executable arguments. Do not silently skip malformed SSE.
435
- throw new LLMServerError('Anthropic returned a malformed stream event', 0);
442
+ // Keep draining for usage, but reject any tools in this response.
443
+ malformedStream = true;
444
+ continue;
436
445
  }
437
446
 
438
447
  const type = event?.type;
@@ -492,7 +501,9 @@ export class AnthropicAdapter extends LLMAdapter {
492
501
  st.signature += delta.signature || '';
493
502
  }
494
503
  } else if (delta?.type === 'input_json_delta') {
495
- if (st?.kind === 'tool_use' && typeof delta.partial_json === 'string' && delta.partial_json.length > 0) {
504
+ if (st?.kind !== 'tool_use' || typeof delta.partial_json !== 'string') {
505
+ toolInputError = new LLMToolInputError('Anthropic returned invalid or incomplete tool input');
506
+ } else if (delta.partial_json.length > 0) {
496
507
  st.input += delta.partial_json;
497
508
  // No content and no throttling: the last delta defines idle time.
498
509
  yield { type: 'provider_activity' };
@@ -511,7 +522,7 @@ export class AnthropicAdapter extends LLMAdapter {
511
522
  } catch {
512
523
  // Reject the entire batch, but drain to the message boundary so
513
524
  // the provider's final usage is not lost on truncated JSON.
514
- toolInputError = new LLMServerError('Anthropic returned invalid or incomplete tool input', 0);
525
+ toolInputError = new LLMToolInputError('Anthropic returned invalid or incomplete tool input');
515
526
  }
516
527
  // Publish tools only after message_stop validates the entire
517
528
  // response, including parallel siblings and max_tokens truncation.
@@ -567,7 +578,7 @@ export class AnthropicAdapter extends LLMAdapter {
567
578
  if (signal?.aborted) throw new LLMAbortError();
568
579
  const stopReason = event.delta?.stop_reason;
569
580
  if (stopReason === 'max_tokens' && hasToolBlocks()) {
570
- toolInputError = new LLMServerError('Anthropic tool response was truncated at max_tokens', 0);
581
+ toolInputError = new LLMToolInputError('Anthropic tool response was truncated at max_tokens');
571
582
  }
572
583
  if (stopReason && !toolInputError) {
573
584
  // Only message_stop seals native state. EOF after message_delta
@@ -575,6 +586,10 @@ export class AnthropicAdapter extends LLMAdapter {
575
586
  yield { type: 'stop', stopReason: this.#mapStopReason(stopReason) };
576
587
  }
577
588
  } else if (type === 'message_stop') {
589
+ if (malformedStream) {
590
+ if (hasToolBlocks()) throw new LLMToolInputError('Anthropic returned a malformed tool stream');
591
+ throw new LLMServerError('Anthropic returned a malformed stream event', 0);
592
+ }
578
593
  if (toolInputError) throw toolInputError;
579
594
  requireClosedBlocks();
580
595
  sawStop = true;
@@ -611,22 +626,35 @@ export class AnthropicAdapter extends LLMAdapter {
611
626
  } else yield { type: 'usage', reportedTokenFields: [], usageIncomplete: true };
612
627
  } else if (type === 'error') {
613
628
  stateFailed = true;
629
+ const rejected = toolInputError || (malformedStream && hasToolBlocks()
630
+ ? new LLMToolInputError('Anthropic returned a malformed tool stream') : null);
614
631
  yield {
615
632
  type: 'error',
616
- error: new Error(event.error?.message || 'Unknown streaming error'),
617
- retryable: event.error?.type === 'overloaded_error',
633
+ error: rejected || new Error(event.error?.message || 'Unknown streaming error'),
634
+ retryable: !rejected && event.error?.type === 'overloaded_error',
618
635
  };
636
+ return;
619
637
  }
620
638
  }
621
639
  }
622
640
  if (signal?.aborted) throw new LLMAbortError();
623
641
  if (toolInputError) throw toolInputError;
642
+ if (malformedStream) {
643
+ if (hasToolBlocks()) throw new LLMToolInputError('Anthropic returned a malformed tool stream');
644
+ throw new LLMServerError('Anthropic returned a malformed stream event', 0);
645
+ }
624
646
  if (!sawStop && (sawMessageStart || hasToolBlocks())) {
647
+ if (hasToolBlocks()) throw new LLMToolInputError('Anthropic tool stream ended before stop event');
625
648
  throw new LLMServerError('Anthropic stream ended before stop event', 0);
626
649
  }
627
650
  requireClosedBlocks();
628
651
  } catch (err) {
629
- throw classifyFetchError(err, { providerLabel: 'Anthropic', signal });
652
+ // Once malformed input is known, a later drain failure cannot make the
653
+ // same rejected request eligible for transport retry. Abort still wins.
654
+ if (err instanceof LLMToolInputError) yield { type: 'error', error: err, retryable: false };
655
+ else if (!signal?.aborted && (toolInputError || (malformedStream && hasToolBlocks()))) {
656
+ yield { type: 'error', error: toolInputError || new LLMToolInputError('Anthropic returned a malformed tool stream'), retryable: false };
657
+ } else throw classifyFetchError(err, { providerLabel: 'Anthropic', signal });
630
658
  } finally {
631
659
  // Close malformed/aborted streams and transports that stay open after
632
660
  // message_stop. Cancellation must not delay the caller or mask its error.
@@ -708,6 +736,11 @@ export class AnthropicAdapter extends LLMAdapter {
708
736
  }
709
737
 
710
738
  const result = await response.json();
739
+ for (const block of result.content || []) {
740
+ if (block.type !== 'tool_use') continue;
741
+ requireToolInputObject(block.input);
742
+ if (result.stop_reason === 'max_tokens') throw new LLMToolInputError('Anthropic tool response was truncated at max_tokens');
743
+ }
711
744
  const text = result.content
712
745
  ?.filter(b => b.type === 'text')
713
746
  .map(b => b.text)
@@ -34,6 +34,7 @@ import {
34
34
  classifyPolicyError,
35
35
  LLMContextError,
36
36
  LLMServerError,
37
+ LLMToolInputError,
37
38
  LLMAbortError,
38
39
  classifyFetchError,
39
40
  retryAfterFromResponse,
@@ -74,6 +75,44 @@ function effortForResponses(effort) {
74
75
  return mapEffortToOpenAIReasoning(effort);
75
76
  }
76
77
 
78
+ // Never turn malformed/missing wire arguments into an executable empty object.
79
+ function parseToolInput(argumentsJson) {
80
+ let input;
81
+ try {
82
+ if (typeof argumentsJson !== 'string') throw new Error();
83
+ input = JSON.parse(argumentsJson);
84
+ } catch {
85
+ throw new LLMToolInputError('OpenAI returned invalid or incomplete tool input');
86
+ }
87
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
88
+ throw new LLMToolInputError('OpenAI returned non-object tool input');
89
+ }
90
+ return input;
91
+ }
92
+
93
+ // Match the bounded UnifiedToolCall identity accepted by the provider collector.
94
+ // The Responses item.id is not a call_id and must never substitute for one.
95
+ function validateToolCallId(id) {
96
+ if (typeof id !== 'string' || !id.trim() || id.length > 256) {
97
+ throw new LLMToolInputError('OpenAI returned invalid tool call identity');
98
+ }
99
+ return id;
100
+ }
101
+
102
+ function projectToolCalls(items, incomplete = false) {
103
+ const calls = [];
104
+ const ids = new Set();
105
+ for (const item of items) {
106
+ if (item?.type !== 'function_call') continue;
107
+ const id = validateToolCallId(item.call_id);
108
+ if (ids.has(id)) throw new LLMToolInputError('OpenAI returned duplicate tool call identity');
109
+ ids.add(id);
110
+ if (incomplete || item.status === 'incomplete') throw new LLMToolInputError('OpenAI tool response was truncated');
111
+ calls.push({ type: 'tool_call', id, name: item.name || '', input: parseToolInput(item.arguments) });
112
+ }
113
+ return calls;
114
+ }
115
+
77
116
  export class OpenAIResponsesAdapter extends LLMAdapter {
78
117
  #apiKey;
79
118
  #baseUrl;
@@ -355,15 +394,26 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
355
394
  if (response.headers?.get('content-type')?.includes('application/json')) {
356
395
  const result = await response.json();
357
396
  const items = Array.isArray(result.output) ? result.output : [];
397
+ if (result.status === 'failed') throw new LLMServerError('OpenAI JSON response failed', 0);
398
+ let calls;
399
+ try {
400
+ calls = projectToolCalls(items, result.status === 'incomplete');
401
+ } catch (error) {
402
+ if (!(error instanceof LLMToolInputError)) throw error;
403
+ const usage = result.usage || {};
404
+ yield { type: 'usage', reportedTokenFields: reportedUsageFields(result.usage, 'openai-responses'), usageIncomplete: !hasUsageCounts(result.usage),
405
+ inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0,
406
+ cacheReadTokens: usage.input_tokens_details?.cached_tokens || 0, cacheWriteTokens: 0,
407
+ cacheTokensAreIncludedInInput: true, ...reasoningUsage(usage, 'openai-responses') };
408
+ yield { type: 'error', error, retryable: false };
409
+ return;
410
+ }
358
411
  for (const item of items) {
359
- if (item.type === 'message') {
412
+ if (item?.type === 'message') {
360
413
  for (const part of item.content || []) if (part.type === 'output_text') yield { type: 'text_delta', text: part.text };
361
- } else if (item.type === 'function_call') {
362
- let input = {};
363
- try { input = JSON.parse(item.arguments); } catch { /* legacy projection */ }
364
- yield { type: 'tool_call', id: item.call_id, name: item.name, input };
365
414
  }
366
415
  }
416
+ for (const call of calls) yield call;
367
417
  if (['completed', 'incomplete'].includes(result.status)) {
368
418
  const state = createProviderState({ context, identity: requestIdentity, items, responseId: result.id });
369
419
  if (state) yield { type: 'provider_state', providerState: state, providerStateBytes: providerStateBytes(state) };
@@ -389,10 +439,27 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
389
439
  /** Accumulate tool call arguments by output_index.
390
440
  * Value: { callId, name, arguments } */
391
441
  const toolCallAccum = new Map();
392
- /** call_ids already emitted as tool_call events (to avoid duplicating on completed fallback). */
393
- const emittedToolCallIds = new Set();
442
+ // Hold calls by raw output slot, never by call_id: sibling IDs must be
443
+ // validated before any merge can overwrite or skip an executable call.
444
+ const completedToolCalls = new Map();
445
+ const toolSlotIds = new Map();
446
+ const toolIdSlots = new Map();
447
+ const registerToolSlot = (index, id) => {
448
+ validateToolCallId(id);
449
+ if (!Number.isSafeInteger(index) || index < 0
450
+ || (toolSlotIds.has(index) && toolSlotIds.get(index) !== id)
451
+ || (toolIdSlots.has(id) && toolIdSlots.get(id) !== index)) {
452
+ throw new LLMToolInputError('OpenAI returned inconsistent tool call identity');
453
+ }
454
+ toolSlotIds.set(index, id);
455
+ toolIdSlots.set(id, index);
456
+ };
457
+ let toolInputError = null;
458
+ let malformedStream = false;
394
459
  let sawToolCall = false;
395
460
  const completedItems = new Map();
461
+ const hasToolItems = () => toolCallAccum.size > 0 || completedToolCalls.size > 0
462
+ || [...completedItems.values()].some(item => item?.type === 'function_call');
396
463
  let emittedText = false;
397
464
 
398
465
  // Keep raw SSE chunks only until the engine receives the bounded exchange
@@ -404,7 +471,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
404
471
  let sawTerminalEvent = false;
405
472
 
406
473
  try {
407
- while (true) {
474
+ while (!sawTerminalEvent) {
408
475
  const { done, value } = await readStreamChunkWithIdleTimeout(reader, {
409
476
  signal,
410
477
  idleMs,
@@ -425,28 +492,49 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
425
492
  if (!data) continue;
426
493
  if (data === '[DONE]') continue;
427
494
 
495
+ if (signal?.aborted) throw new LLMAbortError();
496
+ if (sawTerminalEvent) continue;
428
497
  let event;
429
498
  try {
430
499
  event = JSON.parse(data);
431
500
  } catch {
501
+ // Drain to the native boundary for usage, but never publish tools
502
+ // after losing a possible argument delta. Text-only failures retain
503
+ // the ordinary provider-error contract.
504
+ malformedStream = true;
432
505
  continue;
433
506
  }
434
507
 
435
- const type = event.type;
508
+ const type = event?.type;
436
509
 
437
- if (signal?.aborted) throw new LLMAbortError();
438
- if (sawTerminalEvent) continue;
439
510
  if (type === 'response.output_item.done') {
440
- if (Number.isInteger(event.output_index) && event.item) completedItems.set(event.output_index, event.item);
511
+ const item = event.item;
512
+ const idx = event.output_index;
513
+ if (item?.type === 'function_call') {
514
+ try {
515
+ registerToolSlot(idx, item.call_id);
516
+ const [call] = projectToolCalls([item]);
517
+ completedToolCalls.set(idx, call);
518
+ toolCallAccum.delete(idx);
519
+ } catch (error) {
520
+ toolInputError = error;
521
+ }
522
+ }
523
+ if (Number.isSafeInteger(idx) && idx >= 0 && item) completedItems.set(idx, item);
441
524
  } else if (type === 'response.output_item.added') {
442
525
  const item = event.item;
443
526
  const idx = event.output_index;
444
527
  if (item?.type === 'function_call') {
445
- toolCallAccum.set(idx, {
446
- callId: item.call_id || item.id || '',
447
- name: item.name || '',
448
- arguments: '',
449
- });
528
+ try {
529
+ registerToolSlot(idx, item.call_id);
530
+ toolCallAccum.set(idx, {
531
+ callId: item.call_id,
532
+ name: item.name || '',
533
+ arguments: '',
534
+ });
535
+ } catch (error) {
536
+ toolInputError = error;
537
+ }
450
538
  }
451
539
  } else if (type === 'response.output_text.delta') {
452
540
  if (typeof event.delta === 'string' && event.delta.length > 0) {
@@ -456,7 +544,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
456
544
  } else if (type === 'response.function_call_arguments.delta') {
457
545
  const idx = event.output_index;
458
546
  const accum = toolCallAccum.get(idx);
459
- if (accum && typeof event.delta === 'string' && event.delta.length > 0) {
547
+ if (!accum || typeof event.delta !== 'string') {
548
+ toolInputError = new LLMToolInputError('OpenAI returned invalid or incomplete tool input');
549
+ } else if (event.delta.length > 0) {
460
550
  accum.arguments += event.delta;
461
551
  yield { type: 'provider_activity' };
462
552
  }
@@ -468,62 +558,57 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
468
558
  } else if (type === 'response.function_call_arguments.done') {
469
559
  const idx = event.output_index;
470
560
  const accum = toolCallAccum.get(idx);
471
- if (accum) {
472
- // Prefer the .done event's authoritative arguments string if present
473
- const argsStr = typeof event.arguments === 'string' ? event.arguments : accum.arguments;
474
- let parsed = {};
475
- try {
476
- parsed = argsStr ? JSON.parse(argsStr) : {};
477
- } catch {
478
- parsed = {};
479
- }
480
- if (accum.callId && !emittedToolCallIds.has(accum.callId)) {
481
- emittedToolCallIds.add(accum.callId);
482
- sawToolCall = true;
483
- yield {
484
- type: 'tool_call',
485
- id: accum.callId,
486
- name: accum.name,
487
- input: parsed,
488
- };
489
- }
490
- toolCallAccum.delete(idx);
561
+ // Validate even an untracked done slot: a valid terminal projection
562
+ // must not hide a malformed sibling whose added event was omitted.
563
+ const argsStr = event.arguments === undefined ? accum?.arguments : event.arguments;
564
+ try {
565
+ const input = parseToolInput(argsStr);
566
+ registerToolSlot(idx, accum?.callId);
567
+ completedToolCalls.set(idx, { type: 'tool_call', id: accum.callId, name: accum.name, input });
568
+ } catch (error) {
569
+ // Drain through final usage before rejecting every sibling.
570
+ toolInputError = error;
491
571
  }
572
+ toolCallAccum.delete(idx);
492
573
  } else if (type === 'response.completed' || type === 'response.incomplete') {
493
574
  sawTerminalEvent = true;
494
575
  const respObj = event.response || {};
495
576
 
496
577
  // Fallback: flush any function_call items in the final output that we
497
578
  // didn't see a .done event for (defensive against partial streams).
498
- const outputArr = Array.isArray(respObj.output) ? respObj.output : [...completedItems].sort(([a], [b]) => a - b).map(([, item]) => item);
579
+ const outputSlots = Array.isArray(respObj.output)
580
+ ? [...respObj.output.entries()] : [...completedItems].sort(([a], [b]) => a - b);
581
+ const outputArr = outputSlots.map(([, item]) => item);
499
582
  if (!emittedText) {
500
583
  for (const item of outputArr) if (item?.type === 'message') {
501
584
  for (const part of item.content || []) if (part?.type === 'output_text') yield { type: 'text_delta', text: part.text };
502
585
  }
503
586
  }
504
- for (const item of outputArr) {
505
- if (item?.type !== 'function_call') continue;
506
- const cid = item.call_id || item.id || '';
507
- if (!cid || emittedToolCallIds.has(cid)) continue;
508
- let parsed = {};
509
- try {
510
- parsed = item.arguments ? JSON.parse(item.arguments) : {};
511
- } catch {
512
- parsed = {};
587
+ try {
588
+ // Validate all completed slots, even if a gateway omits a sibling
589
+ // from its final output projection.
590
+ projectToolCalls([...completedItems.values()]);
591
+ const calls = projectToolCalls(outputArr, type === 'response.incomplete' || respObj.status === 'incomplete');
592
+ // Register every raw terminal slot before merging. A repeated
593
+ // representation of the same slot is allowed; sibling reuse and
594
+ // identity changes are not. Keep fallback output_index values.
595
+ const toolSlots = outputSlots.filter(([, item]) => item?.type === 'function_call');
596
+ for (const [idx, item] of toolSlots) registerToolSlot(idx, item.call_id);
597
+ for (let i = 0; i < calls.length; i++) {
598
+ const [idx] = toolSlots[i];
599
+ completedToolCalls.set(idx, calls[i]);
600
+ // Terminal output can finish this slot missing arguments.done.
601
+ toolCallAccum.delete(idx);
513
602
  }
514
- emittedToolCallIds.add(cid);
515
- sawToolCall = true;
516
- yield {
517
- type: 'tool_call',
518
- id: cid,
519
- name: item.name || '',
520
- input: parsed,
521
- };
603
+ if (toolCallAccum.size > 0) throw new LLMToolInputError('OpenAI stream ended with incomplete tool arguments');
604
+ if ((type === 'response.incomplete' || respObj.status === 'incomplete') && completedToolCalls.size > 0) {
605
+ throw new LLMToolInputError('OpenAI tool response was truncated');
606
+ }
607
+ } catch (error) {
608
+ if (!(error instanceof LLMToolInputError)) throw error;
609
+ toolInputError = error;
522
610
  }
523
611
 
524
- const state = createProviderState({ context, identity: requestIdentity, items: outputArr, responseId: respObj.id });
525
- if (state) yield { type: 'provider_state', providerState: state, providerStateBytes: providerStateBytes(state) };
526
-
527
612
  // Usage
528
613
  const usage = respObj.usage || {};
529
614
  yield {
@@ -538,6 +623,22 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
538
623
  ...reasoningUsage(usage, 'openai-responses'),
539
624
  };
540
625
 
626
+ if (malformedStream) {
627
+ if (toolInputError || hasToolItems()) {
628
+ toolInputError = new LLMToolInputError('OpenAI returned a malformed tool stream');
629
+ } else throw new LLMServerError('OpenAI returned a malformed stream event', 0);
630
+ }
631
+ if (toolInputError) {
632
+ yield { type: 'error', error: toolInputError, retryable: false };
633
+ return;
634
+ }
635
+ const state = createProviderState({ context, identity: requestIdentity, items: outputArr, responseId: respObj.id });
636
+ // Completion events may arrive out of order; dispatch in model output order.
637
+ for (const [, call] of [...completedToolCalls].sort(([a], [b]) => a - b)) {
638
+ sawToolCall = true;
639
+ yield call;
640
+ }
641
+ if (state) yield { type: 'provider_state', providerState: state, providerStateBytes: providerStateBytes(state) };
541
642
  yield {
542
643
  type: 'stop',
543
644
  stopReason: this.#mapStopReason(respObj, sawToolCall),
@@ -552,17 +653,33 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
552
653
  const message = errObj.message || event.message || `${type}: ${code}`;
553
654
  const failure = new Error(message);
554
655
  failure.code = code;
555
- yield { type: 'error', error: failure, retryable: false };
656
+ const rejected = toolInputError || (malformedStream && hasToolItems()
657
+ ? new LLMToolInputError('OpenAI returned a malformed tool stream') : null);
658
+ yield { type: 'error', error: rejected || failure, retryable: false };
659
+ return;
556
660
  }
557
661
  // Other semantic events (output_item.done, content_part.added, etc.) are ignored.
558
662
  }
559
663
  }
664
+ if (toolInputError) throw toolInputError;
665
+ if (malformedStream && !sawTerminalEvent && !hasToolItems()) {
666
+ throw new LLMServerError('OpenAI returned a malformed stream event', 0);
667
+ }
560
668
  if (!sawTerminalEvent) {
669
+ if (hasToolItems()) {
670
+ throw new LLMToolInputError('OpenAI tool stream ended before terminal event');
671
+ }
561
672
  throw new LLMServerError('OpenAI stream ended before terminal event', 0);
562
673
  }
563
674
  } catch (err) {
564
- throw classifyFetchError(err, { providerLabel: 'OpenAI', signal });
675
+ // A transport failure while draining an already rejected batch must not
676
+ // turn argument correction into an automatic request replay.
677
+ if (err instanceof LLMToolInputError) yield { type: 'error', error: err, retryable: false };
678
+ else if (!signal?.aborted && (toolInputError || (malformedStream && hasToolItems()))) {
679
+ yield { type: 'error', error: toolInputError || new LLMToolInputError('OpenAI returned a malformed tool stream'), retryable: false };
680
+ } else throw classifyFetchError(err, { providerLabel: 'OpenAI', signal });
565
681
  } finally {
682
+ try { Promise.resolve(reader.cancel()).catch(() => {}); } catch { /* best-effort */ }
566
683
  try { reader.releaseLock(); } catch { /* noop */ }
567
684
  // Emit raw exchange after stream completes (or errors). Body is the
568
685
  // verbatim SSE — never truncated. Parity with anthropic.js.
@@ -648,6 +765,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
648
765
  }
649
766
 
650
767
  const result = await response.json();
768
+ projectToolCalls(Array.isArray(result.output) ? result.output : [], result.status === 'incomplete');
651
769
 
652
770
  // Prefer the `output_text` convenience field; fall back to output[].content[].text
653
771
  let text = '';