@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.
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +69 -69
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/llm/adapter.js +15 -0
- package/yeaft/llm/anthropic.js +49 -16
- package/yeaft/llm/openai-responses.js +179 -61
- package/yeaft/person/capabilities.js +61 -5
- package/yeaft/person/contracts.js +7 -13
- package/yeaft/person/execution-evidence.js +14 -0
- package/yeaft/person/execution-protocol.js +288 -0
- package/yeaft/person/local-memory-worker.js +23 -13
- package/yeaft/person/native-tools.js +17 -10
- package/yeaft/person/provider.js +65 -18
- package/yeaft/person/request-budget.js +26 -0
- package/yeaft/person/runtime.js +155 -142
- package/yeaft/person/service.js +17 -6
- package/yeaft/person/sqlite-repository.js +3 -0
- package/yeaft/person/sqlite-store.js +113 -38
- package/yeaft/person/tool-arguments.js +76 -0
- package/yeaft/person/turn-diagnostics.js +23 -7
- package/yeaft/tools/bash.js +20 -17
- package/yeaft/tools/file-edit.js +4 -1
|
Binary file
|
package/package.json
CHANGED
package/yeaft/llm/adapter.js
CHANGED
|
@@ -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) {
|
package/yeaft/llm/anthropic.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
393
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
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
|
-
|
|
505
|
-
if
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = '';
|