@aeye/core 0.3.8 → 0.3.9
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/README.md +103 -2
- package/aeye-core-prompts.md +171 -0
- package/aeye-core-schema.md +117 -0
- package/aeye-core-tools.md +177 -0
- package/aeye-core-types.md +178 -0
- package/aeye-core.md +88 -0
- package/dist/common.d.ts +36 -0
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +101 -0
- package/dist/common.js.map +1 -1
- package/dist/prompt.d.ts +261 -16
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +653 -297
- package/dist/prompt.js.map +1 -1
- package/dist/schema.d.ts +432 -18
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1403 -209
- package/dist/schema.js.map +1 -1
- package/dist/tool.d.ts +107 -17
- package/dist/tool.d.ts.map +1 -1
- package/dist/tool.js +128 -9
- package/dist/tool.js.map +1 -1
- package/dist/types.d.ts +50 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/prompt.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import Handlebars from "handlebars";
|
|
2
2
|
import { ZodString } from 'zod';
|
|
3
|
-
import { accumulateReasoning, accumulateUsage, getChunksFromResponse, getInputTokens, getModel, getOutputTokens, getTotalTokens, resolve, resolveFn, yieldAll } from "./common";
|
|
3
|
+
import { accumulateReasoning, accumulateUsage, extractJSONObject, getChunksFromResponse, getInputTokens, getModel, getOutputTokens, getTotalTokens, resolve, resolveFn, yieldAll } from "./common";
|
|
4
4
|
import { ToolInterrupt, PromptSuspend } from "./tool";
|
|
5
|
-
import { strictify,
|
|
5
|
+
import { getDescriptorById, strictify, decodeWire } from "./schema";
|
|
6
|
+
/** Default cap (chars) for validation error messages surfaced back to the
|
|
7
|
+
* LLM. Read by `Prompt.truncateValidationError`; subclasses may override
|
|
8
|
+
* the method to ignore this. Anything past `max` is replaced with a
|
|
9
|
+
* `… (N more characters)` marker. */
|
|
10
|
+
const DEFAULT_VALIDATION_ERROR_MAX_LENGTH = 4096;
|
|
6
11
|
/**
|
|
7
12
|
* A Prompt component that generates AI responses based on input, context, and available tools.
|
|
8
13
|
* Prompts orchestrate interactions with AI models, handle tool calls, and manage streaming responses.
|
|
@@ -48,7 +53,12 @@ export class Prompt {
|
|
|
48
53
|
}
|
|
49
54
|
return Handlebars.compile(template, { noEscape: true });
|
|
50
55
|
}
|
|
51
|
-
constructor(input, retool = resolveFn(input.retool),
|
|
56
|
+
constructor(input, retool = resolveFn(input.retool),
|
|
57
|
+
// Schema stays raw. The matching strictify is applied lazily at validation
|
|
58
|
+
// time using the descriptor pinned on `request.responseFormat.descriptor`
|
|
59
|
+
// by whichever provider built the request — so the validator always
|
|
60
|
+
// matches the wire shape the model actually saw.
|
|
61
|
+
schema = resolveFn(input.schema), config = resolveFn(input.config), translate = resolveFn(input.input), content = Prompt.compileContent(input.content, !!input.tools?.length), metadataFn = resolveFn(input.metadataFn)) {
|
|
52
62
|
this.input = input;
|
|
53
63
|
this.retool = retool;
|
|
54
64
|
this.schema = schema;
|
|
@@ -265,9 +275,21 @@ export class Prompt {
|
|
|
265
275
|
let forgetRetries = this.input.forgetRetries ?? ctx.forgetRetries ?? 1;
|
|
266
276
|
let toolIterations = this.input.toolIterations ?? 3;
|
|
267
277
|
let toolRetries = this.input.toolRetries ?? ctx.toolRetries ?? 2;
|
|
278
|
+
const toolsComplete = this.input.toolsComplete ?? true;
|
|
268
279
|
let result = undefined;
|
|
269
280
|
let lastError = undefined;
|
|
270
281
|
let completeText = '';
|
|
282
|
+
// Runtime schema-delivery fallback guard. A model whose descriptor ALLOWS
|
|
283
|
+
// the schema (so the static `canExpress` check passes and structured output
|
|
284
|
+
// is sent) can still fail at runtime in two ways: it returns EMPTY or
|
|
285
|
+
// unparseable content (see the json-parsing branch below), or the request
|
|
286
|
+
// itself FAILS with an error (e.g. a provider `400` on a too-complex schema —
|
|
287
|
+
// see the request-error catch around the stream). Under `schemaDelivery:
|
|
288
|
+
// 'auto'` (and unless `runtimeSchemaFallback` is `false`) either case promotes
|
|
289
|
+
// delivery to prompt-text for the RETRY. This flag keeps the promotion
|
|
290
|
+
// idempotent within a single prompt run — we switch at most once and never
|
|
291
|
+
// loop. Per-run only; nothing is persisted across requests.
|
|
292
|
+
let promotedToPromptDelivery = false;
|
|
271
293
|
let maxIterations = outputRetries + forgetRetries + toolIterations + toolRetries + 1;
|
|
272
294
|
let requestUsageSent = false;
|
|
273
295
|
let usage = undefined;
|
|
@@ -294,6 +316,16 @@ export class Prompt {
|
|
|
294
316
|
};
|
|
295
317
|
// Main execution loop!
|
|
296
318
|
while (iterations < maxIterations) {
|
|
319
|
+
// Honor the caller's abort signal at iteration boundaries — if a
|
|
320
|
+
// tool dispatch already paired its tool_calls via the synthesis
|
|
321
|
+
// pass on the previous iteration, there's no reason to start
|
|
322
|
+
// another round-trip. Without this guard the loop would spin
|
|
323
|
+
// until `maxIterations` because each inner stream call would
|
|
324
|
+
// immediately observe `signal.aborted`, yield nothing, and fall
|
|
325
|
+
// through to "no tool calls" — wasting time and tokens for an
|
|
326
|
+
// already-cancelled request.
|
|
327
|
+
if (ctx.signal?.aborted)
|
|
328
|
+
break;
|
|
297
329
|
const toolExecutors = [];
|
|
298
330
|
const toolExecutorMap = new Map();
|
|
299
331
|
const toolErrorsPrevious = (toolCallErrors + toolParseErrors);
|
|
@@ -311,341 +343,537 @@ export class Prompt {
|
|
|
311
343
|
...(await this.metadataFn(input, ctx) || {}),
|
|
312
344
|
};
|
|
313
345
|
yield emit({ type: 'request', request, iterations });
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
346
|
+
// Wrap request execution so a REQUEST-TIME failure can drive the runtime
|
|
347
|
+
// schema-delivery fallback (Mode 4). A provider that accepts a simple
|
|
348
|
+
// json_schema but 400s on our complex recursive one THROWS here rather
|
|
349
|
+
// than returning empty content, so the json-parsing fallback inside the
|
|
350
|
+
// loop never sees it. On such a failure — under `auto`, structured
|
|
351
|
+
// actually attempted, fallback enabled, not already promoted, retries
|
|
352
|
+
// left, and not aborted — promote to prompt-text and retry; else rethrow.
|
|
353
|
+
let requestFailedNeedsRetry = false;
|
|
354
|
+
try {
|
|
355
|
+
const stream = streamer(request, ctx, metadata, streamController.signal);
|
|
356
|
+
for await (const chunk of stream) {
|
|
357
|
+
if (streamController.signal.aborted) {
|
|
358
|
+
break;
|
|
324
359
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
refusal += chunk.refusal;
|
|
333
|
-
yield emit({ type: 'textPartial', content: chunk.refusal, request });
|
|
334
|
-
}
|
|
335
|
-
if (chunk.reasoning) {
|
|
336
|
-
reasoning = accumulateReasoning(reasoning, chunk.reasoning);
|
|
337
|
-
yield emit({ type: 'reasonPartial', reasoning, request });
|
|
338
|
-
}
|
|
339
|
-
// Handle tool calls
|
|
340
|
-
if (chunk.toolCallNamed) {
|
|
341
|
-
const toolExecutor = newToolExecution(ctx, chunk.toolCallNamed, toolMap.get(chunk.toolCallNamed.name));
|
|
342
|
-
toolExecutors.push(toolExecutor);
|
|
343
|
-
toolExecutorMap.set(chunk.toolCallNamed.id, toolExecutor);
|
|
344
|
-
if (toolExecutor.tool) {
|
|
345
|
-
yield emit({ type: 'toolParseName', tool: toolExecutor.tool, request });
|
|
360
|
+
if (chunk.usage) {
|
|
361
|
+
usage = chunk.usage;
|
|
362
|
+
if (!requestUsageSent) {
|
|
363
|
+
yield emit({ type: 'requestUsage', usage: chunk.usage, request });
|
|
364
|
+
requestUsageSent = true;
|
|
365
|
+
}
|
|
366
|
+
accumulateUsage(accumulatedUsage, chunk.usage);
|
|
346
367
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
368
|
+
if (chunk.content) {
|
|
369
|
+
content += chunk.content;
|
|
370
|
+
yield emit({ type: 'textPartial', content: chunk.content, request });
|
|
350
371
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (toolExecutor) {
|
|
355
|
-
toolExecutor.toolCall = chunk.toolCallArguments;
|
|
356
|
-
yield emit({ type: 'toolParseArguments', tool: toolExecutor.tool, args: chunk.toolCallArguments.arguments, request });
|
|
372
|
+
if (chunk.refusal) {
|
|
373
|
+
refusal += chunk.refusal;
|
|
374
|
+
yield emit({ type: 'textPartial', content: chunk.refusal, request });
|
|
357
375
|
}
|
|
358
|
-
|
|
359
|
-
|
|
376
|
+
if (chunk.reasoning) {
|
|
377
|
+
reasoning = accumulateReasoning(reasoning, chunk.reasoning);
|
|
378
|
+
yield emit({ type: 'reasonPartial', reasoning, request });
|
|
360
379
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
toolExecutor
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
380
|
+
// Handle tool calls
|
|
381
|
+
if (chunk.toolCallNamed) {
|
|
382
|
+
const toolName = chunk.toolCallNamed.name;
|
|
383
|
+
const onToolResult = this.input.onToolResult;
|
|
384
|
+
const toolExecutor = newToolExecution(ctx, chunk.toolCallNamed, toolMap.get(chunk.toolCallNamed.name), this.input.validationErrorMaxLength, this.truncateValidationError.bind(this),
|
|
385
|
+
// Boundary: the streaming loop has erased the per-tool types, so
|
|
386
|
+
// build the discriminated event from the runtime tool name +
|
|
387
|
+
// parsed args/result and cast once to the public event type
|
|
388
|
+
// (mirrors the `as PromptEvent<...>` boundary cast in `emitTool`).
|
|
389
|
+
onToolResult
|
|
390
|
+
? (result, args) => onToolResult({
|
|
391
|
+
tool: toolName,
|
|
392
|
+
result,
|
|
393
|
+
args,
|
|
394
|
+
ctx,
|
|
395
|
+
request,
|
|
396
|
+
})
|
|
397
|
+
: undefined);
|
|
398
|
+
toolExecutors.push(toolExecutor);
|
|
399
|
+
toolExecutorMap.set(chunk.toolCallNamed.id, toolExecutor);
|
|
400
|
+
if (toolExecutor.tool) {
|
|
401
|
+
yield emit({ type: 'toolParseName', tool: toolExecutor.tool, request });
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
streamController.abort(toolExecutor.error);
|
|
405
|
+
break;
|
|
369
406
|
}
|
|
370
407
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
finishReason = chunk.finishReason;
|
|
377
|
-
}
|
|
378
|
-
// In immediate mode we might be getting more chunks while executing, emit events as soon as possible.
|
|
379
|
-
if (toolMode === 'immediate') {
|
|
380
|
-
for (const toolCall of toolExecutors) {
|
|
381
|
-
if (toolCall.emitStart()) {
|
|
382
|
-
yield emitTool({ type: 'toolStart', tool: toolCall.tool, args: toolCall.args, request });
|
|
408
|
+
if (chunk.toolCallArguments) {
|
|
409
|
+
const toolExecutor = toolExecutorMap.get(chunk.toolCallArguments.id);
|
|
410
|
+
if (toolExecutor) {
|
|
411
|
+
toolExecutor.toolCall = chunk.toolCallArguments;
|
|
412
|
+
yield emit({ type: 'toolParseArguments', tool: toolExecutor.tool, args: chunk.toolCallArguments.arguments, request });
|
|
383
413
|
}
|
|
384
|
-
|
|
385
|
-
|
|
414
|
+
else {
|
|
415
|
+
console.error(`Received tool call arguments for unknown tool call ID ${chunk.toolCallArguments.id}`);
|
|
386
416
|
}
|
|
387
|
-
|
|
388
|
-
|
|
417
|
+
}
|
|
418
|
+
if (chunk.toolCall) {
|
|
419
|
+
const toolExecutor = toolExecutorMap.get(chunk.toolCall.id);
|
|
420
|
+
if (toolExecutor) {
|
|
421
|
+
toolExecutor.toolCall = chunk.toolCall;
|
|
422
|
+
if (toolMode === 'immediate') {
|
|
423
|
+
// Start execution immediately
|
|
424
|
+
setImmediate(toolExecutor.run);
|
|
425
|
+
}
|
|
389
426
|
}
|
|
390
|
-
|
|
391
|
-
|
|
427
|
+
else {
|
|
428
|
+
console.error(`Received tool call for unknown tool call ID ${chunk.toolCall.id}`);
|
|
392
429
|
}
|
|
393
|
-
|
|
394
|
-
|
|
430
|
+
}
|
|
431
|
+
if (chunk.finishReason) {
|
|
432
|
+
finishReason = chunk.finishReason;
|
|
433
|
+
}
|
|
434
|
+
// In immediate mode we might be getting more chunks while executing, emit events as soon as possible.
|
|
435
|
+
if (toolMode === 'immediate') {
|
|
436
|
+
for (const toolCall of toolExecutors) {
|
|
437
|
+
if (toolCall.emitStart()) {
|
|
438
|
+
yield emitTool({ type: 'toolStart', tool: toolCall.tool, args: toolCall.args, request });
|
|
439
|
+
}
|
|
440
|
+
if (toolCall.emitOutput()) {
|
|
441
|
+
yield emitTool({ type: 'toolOutput', tool: toolCall.tool, args: toolCall.args, result: toolCall.result, toModel: toolCall.toModel, request });
|
|
442
|
+
}
|
|
443
|
+
if (toolCall.emitInterrupt()) {
|
|
444
|
+
yield emitTool({ type: 'toolInterrupt', tool: toolCall.tool, args: toolCall.args, request });
|
|
445
|
+
}
|
|
446
|
+
if (toolCall.emitSuspend()) {
|
|
447
|
+
yield emitTool({ type: 'toolSuspend', tool: toolCall.tool, args: toolCall.args, request });
|
|
448
|
+
}
|
|
449
|
+
if (toolCall.emitError()) {
|
|
450
|
+
yield emitTool({ type: 'toolError', tool: toolCall.tool, args: toolCall.args, error: toolCall.error, rawArgs: toolCall.rawArgs, request });
|
|
451
|
+
}
|
|
395
452
|
}
|
|
396
453
|
}
|
|
397
454
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
yield emit({ type: 'reason', reasoning, request });
|
|
403
|
-
}
|
|
404
|
-
// If the model refused to answer and stop
|
|
405
|
-
if (finishReason === 'refusal' || refusal) {
|
|
406
|
-
yield emit({ type: 'refusal', content: refusal || 'unspecified', request });
|
|
407
|
-
lastError = refusal || 'Model refused to answer.';
|
|
408
|
-
break;
|
|
409
|
-
}
|
|
410
|
-
// If the model was stopped due to content filtering
|
|
411
|
-
if (finishReason === 'content_filter') {
|
|
412
|
-
yield emit({ type: 'refusal', content: 'Content filtered by AI model', request });
|
|
413
|
-
lastError = 'Model response was filtered due to content policy.';
|
|
414
|
-
break;
|
|
415
|
-
}
|
|
416
|
-
// If we sent too much, forget the past homie
|
|
417
|
-
if (finishReason === 'length') {
|
|
418
|
-
if (usage && forgetRetries > 0) {
|
|
419
|
-
request.messages = this.forget(request, ctx, usage);
|
|
420
|
-
forgetRetries--;
|
|
421
|
-
yield emit({ type: 'textReset', reason: 'length', request });
|
|
422
|
-
// Lets retry immediately
|
|
423
|
-
continue;
|
|
455
|
+
ctx.signal?.removeEventListener('abort', streamAbort);
|
|
456
|
+
// If the model reasoned, yield it
|
|
457
|
+
if (reasoning) {
|
|
458
|
+
yield emit({ type: 'reason', reasoning, request });
|
|
424
459
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
460
|
+
// If the model refused to answer and stop
|
|
461
|
+
if (finishReason === 'refusal' || refusal) {
|
|
462
|
+
yield emit({ type: 'refusal', content: refusal || 'unspecified', request });
|
|
463
|
+
lastError = refusal || 'Model refused to answer.';
|
|
428
464
|
break;
|
|
429
465
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
});
|
|
445
|
-
// If there are any error/invalid - just stop and add their errors and retry
|
|
446
|
-
let skip = false;
|
|
447
|
-
for (const toolExecutor of toolExecutors) {
|
|
448
|
-
if (toolExecutor.error) {
|
|
449
|
-
skip = true;
|
|
466
|
+
// If the model was stopped due to content filtering
|
|
467
|
+
if (finishReason === 'content_filter') {
|
|
468
|
+
yield emit({ type: 'refusal', content: 'Content filtered by AI model', request });
|
|
469
|
+
lastError = 'Model response was filtered due to content policy.';
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
// If we sent too much, forget the past homie
|
|
473
|
+
if (finishReason === 'length') {
|
|
474
|
+
if (usage && forgetRetries > 0) {
|
|
475
|
+
request.messages = this.forget(request, ctx, usage);
|
|
476
|
+
forgetRetries--;
|
|
477
|
+
yield emit({ type: 'textReset', reason: 'length', request });
|
|
478
|
+
// Lets retry immediately
|
|
479
|
+
continue;
|
|
450
480
|
}
|
|
451
481
|
else {
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (toolExecutor.emitStart()) {
|
|
456
|
-
yield emitTool({ type: 'toolStart', tool: toolExecutor.tool, args: toolExecutor.args, request });
|
|
457
|
-
}
|
|
458
|
-
if (toolExecutor.emitError()) {
|
|
459
|
-
yield emitTool({ type: 'toolError', tool: toolExecutor.tool, args: toolExecutor.args, error: toolExecutor.error, request });
|
|
482
|
+
// Stop iteration - we can't trim without usage info
|
|
483
|
+
lastError = 'Model indicated length finish reason but no token usage was provided so context cannot be trimmed.';
|
|
484
|
+
break;
|
|
460
485
|
}
|
|
461
486
|
}
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
if (toolExecutor.emitError()) {
|
|
483
|
-
yield emitTool({ type: 'toolError', tool: toolExecutor.tool, args: toolExecutor.args, error: toolExecutor.error, request });
|
|
484
|
-
}
|
|
487
|
+
// Yield text event if content exists before processing tool calls
|
|
488
|
+
if (content.length > 0) {
|
|
489
|
+
yield emit({ type: 'text', content, request });
|
|
490
|
+
}
|
|
491
|
+
// If we need to make some tool calls, lets do it!
|
|
492
|
+
// We might not have a finish_reason if we got a bad tool name.
|
|
493
|
+
if (finishReason === 'tool_calls' || toolExecutors.length) {
|
|
494
|
+
// Add the assistant's response with tool calls to the conversation
|
|
495
|
+
yield emitMessage({
|
|
496
|
+
role: 'assistant',
|
|
497
|
+
content,
|
|
498
|
+
toolCalls: toolExecutors.map(te => te.toolCall),
|
|
499
|
+
reasoning,
|
|
500
|
+
});
|
|
501
|
+
// If there are any error/invalid - just stop and add their errors and retry
|
|
502
|
+
let skip = false;
|
|
503
|
+
for (const toolExecutor of toolExecutors) {
|
|
504
|
+
if (toolExecutor.error) {
|
|
505
|
+
skip = true;
|
|
485
506
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
507
|
+
else {
|
|
508
|
+
// Non-blocking call, we don't want to hold up execution here. But if we can emit start or error early below this we will try.
|
|
509
|
+
toolExecutor.parse();
|
|
510
|
+
}
|
|
511
|
+
if (toolExecutor.emitRepairAttempt()) {
|
|
512
|
+
yield emit({ type: 'toolArgRepairAttempt', tool: toolExecutor.tool, fields: toolExecutor.repairAttempt.fields, success: toolExecutor.repairAttempt.success, request });
|
|
513
|
+
}
|
|
514
|
+
if (toolExecutor.emitStart()) {
|
|
515
|
+
yield emitTool({ type: 'toolStart', tool: toolExecutor.tool, args: toolExecutor.args, request });
|
|
516
|
+
}
|
|
517
|
+
if (toolExecutor.emitError()) {
|
|
518
|
+
yield emitTool({ type: 'toolError', tool: toolExecutor.tool, args: toolExecutor.args, error: toolExecutor.error, rawArgs: toolExecutor.rawArgs, request });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// The execution mode for this iteration.
|
|
522
|
+
const iterationMode = skip ? 'skip' : toolMode;
|
|
523
|
+
// All tool calls are valid, lets start this!
|
|
524
|
+
switch (iterationMode) {
|
|
525
|
+
case 'sequential':
|
|
526
|
+
for (const toolExecutor of toolExecutors) {
|
|
527
|
+
// Abort-aware dispatch — once the caller's signal trips,
|
|
528
|
+
// stop starting subsequent tools so Ctrl+C unwinds within
|
|
529
|
+
// ~1 tool's duration instead of grinding through the rest
|
|
530
|
+
// of the batch. The synthesis pass below pairs every
|
|
531
|
+
// unstarted tool_call with an `[aborted]` placeholder.
|
|
532
|
+
// We check `ctx.signal` directly (not the streamController
|
|
533
|
+
// relay) because the relay's listener was already
|
|
534
|
+
// removed by the time tool dispatch runs.
|
|
535
|
+
if (ctx.signal?.aborted)
|
|
536
|
+
break;
|
|
537
|
+
await toolExecutor.parse();
|
|
538
|
+
if (toolExecutor.emitRepairAttempt()) {
|
|
539
|
+
yield emit({ type: 'toolArgRepairAttempt', tool: toolExecutor.tool, fields: toolExecutor.repairAttempt.fields, success: toolExecutor.repairAttempt.success, request });
|
|
540
|
+
}
|
|
541
|
+
if (toolExecutor.emitStart()) {
|
|
542
|
+
yield emitTool({ type: 'toolStart', tool: toolExecutor.tool, args: toolExecutor.args, request });
|
|
543
|
+
}
|
|
544
|
+
await toolExecutor.run();
|
|
545
|
+
if (toolExecutor.emitOutput()) {
|
|
546
|
+
yield emitTool({ type: 'toolOutput', tool: toolExecutor.tool, args: toolExecutor.args, result: toolExecutor.result, toModel: toolExecutor.toModel, request });
|
|
547
|
+
}
|
|
548
|
+
if (toolExecutor.emitInterrupt()) {
|
|
549
|
+
yield emitTool({ type: 'toolInterrupt', tool: toolExecutor.tool, args: toolExecutor.args, request });
|
|
550
|
+
}
|
|
551
|
+
if (toolExecutor.emitSuspend()) {
|
|
552
|
+
yield emitTool({ type: 'toolSuspend', tool: toolExecutor.tool, args: toolExecutor.args, request });
|
|
553
|
+
}
|
|
554
|
+
if (toolExecutor.emitError()) {
|
|
555
|
+
yield emitTool({ type: 'toolError', tool: toolExecutor.tool, args: toolExecutor.args, error: toolExecutor.error, rawArgs: toolExecutor.rawArgs, request });
|
|
556
|
+
}
|
|
503
557
|
}
|
|
504
|
-
|
|
505
|
-
|
|
558
|
+
break;
|
|
559
|
+
case 'parallel':
|
|
560
|
+
case 'immediate':
|
|
561
|
+
const parseRuns = toolExecutors.map(tc => [tc.parse(), tc.run()]).flat();
|
|
562
|
+
for await (const { result: toolCallPromise } of yieldAll(parseRuns)) {
|
|
563
|
+
// The promises in `parseRuns` were started eagerly, so
|
|
564
|
+
// tools already in flight will keep running in the
|
|
565
|
+
// background (orphaned). What we control here is whether
|
|
566
|
+
// we accumulate their results / emit their events. Bail
|
|
567
|
+
// on abort so the caller doesn't have to wait for stragglers
|
|
568
|
+
// to finish — synthesis below covers any unpaired tool_call.
|
|
569
|
+
// (We check `ctx.signal` directly — streamController's
|
|
570
|
+
// listener was already removed before tool dispatch.)
|
|
571
|
+
if (ctx.signal?.aborted)
|
|
572
|
+
break;
|
|
573
|
+
const toolExecutor = await toolCallPromise;
|
|
574
|
+
if (toolExecutor.emitRepairAttempt()) {
|
|
575
|
+
yield emit({ type: 'toolArgRepairAttempt', tool: toolExecutor.tool, fields: toolExecutor.repairAttempt.fields, success: toolExecutor.repairAttempt.success, request });
|
|
576
|
+
}
|
|
577
|
+
if (toolExecutor.emitStart()) {
|
|
578
|
+
yield emitTool({ type: 'toolStart', tool: toolExecutor.tool, args: toolExecutor.args, request });
|
|
579
|
+
}
|
|
580
|
+
if (toolExecutor.emitOutput()) {
|
|
581
|
+
yield emitTool({ type: 'toolOutput', tool: toolExecutor.tool, args: toolExecutor.args, result: toolExecutor.result, toModel: toolExecutor.toModel, request });
|
|
582
|
+
}
|
|
583
|
+
if (toolExecutor.emitInterrupt()) {
|
|
584
|
+
yield emitTool({ type: 'toolInterrupt', tool: toolExecutor.tool, args: toolExecutor.args, request });
|
|
585
|
+
}
|
|
586
|
+
if (toolExecutor.emitSuspend()) {
|
|
587
|
+
yield emitTool({ type: 'toolSuspend', tool: toolExecutor.tool, args: toolExecutor.args, request });
|
|
588
|
+
}
|
|
589
|
+
if (toolExecutor.emitError()) {
|
|
590
|
+
yield emitTool({ type: 'toolError', tool: toolExecutor.tool, args: toolExecutor.args, error: toolExecutor.error, rawArgs: toolExecutor.rawArgs, request });
|
|
591
|
+
}
|
|
506
592
|
}
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
// Emit tool results for every executor. The pairing guarantee:
|
|
596
|
+
// every assistant `tool_calls[i]` pushed by this block must
|
|
597
|
+
// have a matching `role: 'tool'` entry by the end of the loop,
|
|
598
|
+
// otherwise the next round-trip 400s (OpenAI / Anthropic both
|
|
599
|
+
// reject unpaired tool_calls).
|
|
600
|
+
//
|
|
601
|
+
// - `suspended`: skipped on purpose (suspend/resume relies on
|
|
602
|
+
// the missing slot to know which tool to resume).
|
|
603
|
+
// - incomplete (`ready` / `parsed` / `executing` / `interrupted`
|
|
604
|
+
// without an error string): no real content. With
|
|
605
|
+
// `toolsComplete: true` (default) we synthesize a marker
|
|
606
|
+
// (`[aborted: …]`, `[interrupted]`) so the model knows WHY
|
|
607
|
+
// the slot is empty. With `toolsComplete: false` we omit
|
|
608
|
+
// the message entirely — the caller is on their own.
|
|
609
|
+
// - complete (`success` / `error` / `invalid` / `interrupted`
|
|
610
|
+
// with an error string): emit the real result / error.
|
|
611
|
+
let anySuspended = false;
|
|
612
|
+
for (const toolExecutor of toolExecutors) {
|
|
613
|
+
if (toolExecutor.status === 'suspended') {
|
|
614
|
+
anySuspended = true;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const hasError = !!toolExecutor.error;
|
|
618
|
+
const hasResult = toolExecutor.result !== undefined && toolExecutor.result !== null;
|
|
619
|
+
const isComplete = hasError || hasResult || toolExecutor.status === 'success';
|
|
620
|
+
if (!isComplete && !toolsComplete) {
|
|
621
|
+
// Opt-out — leave the unfinished tool_call without a
|
|
622
|
+
// paired result. Caller has accepted responsibility for
|
|
623
|
+
// handling the broken history shape (e.g. they're about
|
|
624
|
+
// to discard the request entirely, or they have their
|
|
625
|
+
// own retry logic).
|
|
626
|
+
continue;
|
|
507
627
|
}
|
|
628
|
+
// Model-facing value: on a real success we present `toModel`
|
|
629
|
+
// (the raw result, or whatever `onToolResult` transformed it into
|
|
630
|
+
// — set inside run() before success is marked). Any other slot
|
|
631
|
+
// with a lingering result keeps the raw `result`. Serialized
|
|
632
|
+
// exactly as before: strings verbatim, everything else JSON.
|
|
633
|
+
const modelValue = toolExecutor.status === 'success' ? toolExecutor.toModel : toolExecutor.result;
|
|
634
|
+
const content = hasError
|
|
635
|
+
? toolExecutor.error
|
|
636
|
+
: hasResult
|
|
637
|
+
? typeof modelValue === 'string'
|
|
638
|
+
? modelValue
|
|
639
|
+
: JSON.stringify(modelValue)
|
|
640
|
+
: this.synthesizeUnpairedResult(toolExecutor);
|
|
641
|
+
yield emitMessage({
|
|
642
|
+
role: 'tool',
|
|
643
|
+
content,
|
|
644
|
+
toolCallId: toolExecutor.toolCall.id,
|
|
645
|
+
});
|
|
646
|
+
if (toolExecutor.status === 'invalid') {
|
|
647
|
+
toolParseErrors++;
|
|
648
|
+
}
|
|
649
|
+
else if (toolExecutor.status === 'error') {
|
|
650
|
+
toolCallErrors++;
|
|
651
|
+
}
|
|
652
|
+
else if (toolExecutor.status === 'success') {
|
|
653
|
+
toolSuccesses++;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (anySuspended) {
|
|
657
|
+
suspended = true;
|
|
508
658
|
break;
|
|
509
|
-
}
|
|
510
|
-
// Emit tool results for all completed tools. If any tool suspended, skip its result
|
|
511
|
-
// (the caller will supply it on resume) and break after processing the rest.
|
|
512
|
-
// request.messages ends with: [...history, assistantMsg(toolCalls), toolResult(completed)...]
|
|
513
|
-
let anySuspended = false;
|
|
514
|
-
for (const toolExecutor of toolExecutors) {
|
|
515
|
-
if (toolExecutor.status === 'suspended') {
|
|
516
|
-
anySuspended = true;
|
|
517
|
-
continue; // Omit result — the pending result will be supplied on resume
|
|
518
659
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
660
|
+
if ((toolCallErrors + toolParseErrors) > toolErrorsPrevious) {
|
|
661
|
+
if (toolRetries > 0) {
|
|
662
|
+
toolRetries--;
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
disableTools = true;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
// No tool calls, just add the assistant response
|
|
526
671
|
yield emitMessage({
|
|
527
|
-
role: '
|
|
672
|
+
role: 'assistant',
|
|
528
673
|
content,
|
|
529
|
-
|
|
674
|
+
reasoning,
|
|
530
675
|
});
|
|
531
|
-
if (toolExecutor.status === 'invalid') {
|
|
532
|
-
toolParseErrors++;
|
|
533
|
-
}
|
|
534
|
-
else if (toolExecutor.status === 'error') {
|
|
535
|
-
toolCallErrors++;
|
|
536
|
-
}
|
|
537
|
-
else if (toolExecutor.status === 'success') {
|
|
538
|
-
toolSuccesses++;
|
|
539
|
-
}
|
|
540
676
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
677
|
+
const hadToolErrors = toolParseErrorsPrevious !== toolParseErrors;
|
|
678
|
+
const hitMax = this.input.toolsMax && toolSuccesses >= this.input.toolsMax;
|
|
679
|
+
// If if there are only tool calls wanted...
|
|
680
|
+
if (onlyTools) {
|
|
681
|
+
const noTools = toolExecutors.length === 0;
|
|
682
|
+
// If we met our max tool calls, or had some successes with no new errors, or there are no more tools to call, end it.
|
|
683
|
+
if (hitMax || noTools) {
|
|
684
|
+
// got what we needed!
|
|
685
|
+
lastError = undefined;
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
544
688
|
}
|
|
545
|
-
|
|
546
|
-
if
|
|
547
|
-
|
|
689
|
+
else {
|
|
690
|
+
// We don't only want tools, but if we had some successes and no new parse errors, remove tool requirement
|
|
691
|
+
if (fixedToolChoice && toolSuccesses > 0 && !hadToolErrors) {
|
|
692
|
+
delete request.toolChoice;
|
|
548
693
|
}
|
|
549
|
-
|
|
694
|
+
// If we met our max tool calls, remove the tools from the request
|
|
695
|
+
if (hitMax) {
|
|
696
|
+
// No more tools for you!
|
|
550
697
|
disableTools = true;
|
|
551
698
|
}
|
|
552
699
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
yield emitMessage({
|
|
557
|
-
role: 'assistant',
|
|
558
|
-
content,
|
|
559
|
-
reasoning,
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
const hadToolErrors = toolParseErrorsPrevious !== toolParseErrors;
|
|
563
|
-
const hitMax = this.input.toolsMax && toolSuccesses >= this.input.toolsMax;
|
|
564
|
-
// If if there are only tool calls wanted...
|
|
565
|
-
if (onlyTools) {
|
|
566
|
-
const noTools = toolExecutors.length === 0;
|
|
567
|
-
// If we met our max tool calls, or had some successes with no new errors, or there are no more tools to call, end it.
|
|
568
|
-
if (hitMax || noTools) {
|
|
569
|
-
// got what we needed!
|
|
570
|
-
lastError = undefined;
|
|
571
|
-
break;
|
|
700
|
+
// Accumulate text content from this iteration
|
|
701
|
+
if (content.length > 0) {
|
|
702
|
+
completeText += content;
|
|
572
703
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
704
|
+
// Determine if we should stop
|
|
705
|
+
const stop = finishReason === 'stop' && toolExecutors.length === 0 ||
|
|
706
|
+
toolExecutors.some(toolExecutor => toolExecutor.status === 'interrupted');
|
|
707
|
+
// If we are finished, parse the output
|
|
708
|
+
if (stop) {
|
|
709
|
+
if (!schema || (schema instanceof ZodString)) {
|
|
710
|
+
result = content;
|
|
711
|
+
break; // All good!
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
// Grab the JSON part from the content just in case. In the
|
|
715
|
+
// schema-delivery prompt-text fallback the model replies with
|
|
716
|
+
// freeform text (possibly fenced / prose-wrapped), so extract the
|
|
717
|
+
// outermost balanced `{…}` (see `extractJSONObject`).
|
|
718
|
+
const potentialJSON = extractJSONObject(content);
|
|
719
|
+
let errorMessage = '';
|
|
720
|
+
let resetReason = '';
|
|
721
|
+
const errMax = this.input.validationErrorMaxLength;
|
|
722
|
+
try {
|
|
723
|
+
const parsedJSON = JSON.parse(potentialJSON);
|
|
724
|
+
if (this.input.parse) {
|
|
725
|
+
// Custom parser REPLACES Zod validation of the structured
|
|
726
|
+
// output entirely. Mirrors Tool.parse's `parse` hook: the
|
|
727
|
+
// JSON.parse-d value is first DECODEd back from the provider
|
|
728
|
+
// wire shape to the CONCEPTUAL value via `decodeWire` (symmetric
|
|
729
|
+
// with the strictify ENCODE the provider used to build the
|
|
730
|
+
// request), so the caller's parser stays provider-agnostic — it
|
|
731
|
+
// never sees array-of-pairs records, numeric-key tuples, or
|
|
732
|
+
// null-for-optional. The parser returns the typed TOutput on
|
|
733
|
+
// success or an Error (returned OR thrown) carrying rich,
|
|
734
|
+
// compiler-style diagnostics. Zod's own validation is skipped. A
|
|
735
|
+
// returned/thrown Error flows through the SAME output-retry
|
|
736
|
+
// channel a Zod failure would, surfacing its own `.message`
|
|
737
|
+
// (no Zod vocabulary — Zod never ran). Absent ⇒ unchanged.
|
|
738
|
+
// Decode only when a descriptor was pinned; otherwise the wire
|
|
739
|
+
// shape IS the conceptual shape and `parsedJSON` passes through.
|
|
740
|
+
const customDescriptorId = typeof request.responseFormat === 'object'
|
|
741
|
+
? request.responseFormat.descriptor
|
|
742
|
+
: undefined;
|
|
743
|
+
const decoded = customDescriptorId
|
|
744
|
+
? decodeWire(schema, parsedJSON, getDescriptorById(customDescriptorId))
|
|
745
|
+
: parsedJSON;
|
|
746
|
+
let customResult;
|
|
747
|
+
try {
|
|
748
|
+
customResult = await this.input.parse(decoded, ctx);
|
|
749
|
+
}
|
|
750
|
+
catch (customError) {
|
|
751
|
+
customResult = customError instanceof Error ? customError : new Error(String(customError));
|
|
752
|
+
}
|
|
753
|
+
if (customResult instanceof Error) {
|
|
754
|
+
errorMessage = this.truncateValidationError(customResult.message, errMax);
|
|
755
|
+
resetReason = 'schema-parsing';
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
result = customResult;
|
|
759
|
+
try {
|
|
760
|
+
await this.input.validate?.(result, ctx);
|
|
761
|
+
}
|
|
762
|
+
catch (validationError) {
|
|
763
|
+
errorMessage = this.truncateValidationError(`The output failed validation:\n${validationError.message}`, errMax);
|
|
764
|
+
resetReason = 'validation';
|
|
765
|
+
}
|
|
766
|
+
}
|
|
617
767
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
768
|
+
else {
|
|
769
|
+
// Apply the same strictify rewrite the provider used for the wire
|
|
770
|
+
// shape, so array-of-pairs records / numeric-key tuples / etc.
|
|
771
|
+
// normalize back into the natural Zod shape before validation.
|
|
772
|
+
// The descriptor was pinned on the response format when the
|
|
773
|
+
// provider built the request. Fast cache lookup on retries.
|
|
774
|
+
const responseDescriptorId = typeof request.responseFormat === 'object'
|
|
775
|
+
? request.responseFormat.descriptor
|
|
776
|
+
: undefined;
|
|
777
|
+
const validationSchema = responseDescriptorId
|
|
778
|
+
? strictify(schema, getDescriptorById(responseDescriptorId))
|
|
779
|
+
: schema;
|
|
780
|
+
const parsedSafe = await validationSchema.safeParseAsync(parsedJSON);
|
|
781
|
+
if (!parsedSafe.success) {
|
|
782
|
+
const issueSummary = parsedSafe.error.issues
|
|
783
|
+
.map(i => `- ${i.path.join('.')}: ${i.message}${['string', 'boolean', 'number'].includes(typeof i.input) ? ` (input: ${i.input})` : ''}`)
|
|
784
|
+
.join('\n');
|
|
785
|
+
errorMessage = this.truncateValidationError(`The output was an invalid format:\n${issueSummary}`, errMax);
|
|
786
|
+
resetReason = 'schema-parsing';
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
result = parsedSafe.data;
|
|
790
|
+
try {
|
|
791
|
+
await this.input.validate?.(result, ctx);
|
|
792
|
+
}
|
|
793
|
+
catch (validationError) {
|
|
794
|
+
errorMessage = this.truncateValidationError(`The output failed validation:\n${validationError.message}`, errMax);
|
|
795
|
+
resetReason = 'validation';
|
|
796
|
+
}
|
|
797
|
+
}
|
|
621
798
|
}
|
|
622
799
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
800
|
+
catch (parseError) {
|
|
801
|
+
errorMessage = this.truncateValidationError(`The output was not valid JSON:\n${parseError.message}`, errMax);
|
|
802
|
+
resetReason = 'json-parsing';
|
|
803
|
+
}
|
|
804
|
+
// Runtime schema-delivery fallback. The structured response was EMPTY
|
|
805
|
+
// or its JSON was UNPARSEABLE (`json-parsing` reset — either the
|
|
806
|
+
// content had no JSON object to extract or `JSON.parse` threw). Some
|
|
807
|
+
// models (e.g. meta-llama/llama-4-maverick via OpenRouter) accept a
|
|
808
|
+
// `response_format` json_schema whose descriptor CAN express the
|
|
809
|
+
// schema yet reply with `finish: stop` and empty content, so the
|
|
810
|
+
// static `canExpress` fallback in the ai layer never fires. Promote
|
|
811
|
+
// this prompt's schema delivery to prompt-text for the RETRY: flip
|
|
812
|
+
// the outgoing request's `responseFormat.schemaDelivery` to 'prompt'
|
|
813
|
+
// so `applySchemaDeliveryFallback` (ai layer) drops `response_format`
|
|
814
|
+
// and appends the schema as text + fallback instruction on the next
|
|
815
|
+
// request build. Only when delivery is 'auto' and structured output
|
|
816
|
+
// was actually attempted (`responseFormat` still an object), and only
|
|
817
|
+
// once per run (`promotedToPromptDelivery` guard) so we never loop.
|
|
818
|
+
// The switch is not remembered beyond this run. This reuses the
|
|
819
|
+
// existing outputRetries re-prompt mechanism below to re-issue.
|
|
820
|
+
if (this.input.runtimeSchemaFallback !== false &&
|
|
821
|
+
resetReason === 'json-parsing' &&
|
|
822
|
+
!promotedToPromptDelivery &&
|
|
823
|
+
typeof request.responseFormat === 'object' &&
|
|
824
|
+
(request.responseFormat.schemaDelivery ?? 'auto') === 'auto') {
|
|
825
|
+
request.responseFormat.schemaDelivery = 'prompt';
|
|
826
|
+
promotedToPromptDelivery = true;
|
|
827
|
+
}
|
|
828
|
+
if (errorMessage) {
|
|
829
|
+
if (outputRetries > 0) {
|
|
830
|
+
outputRetries--;
|
|
831
|
+
yield emit({ type: 'textReset', reason: resetReason, request });
|
|
832
|
+
yield emitMessage({
|
|
833
|
+
role: 'user',
|
|
834
|
+
content: errorMessage,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
lastError = errorMessage;
|
|
839
|
+
break; // No more retries left
|
|
840
|
+
}
|
|
636
841
|
}
|
|
637
842
|
else {
|
|
638
|
-
|
|
639
|
-
|
|
843
|
+
// A result was successfully parsed and validated!
|
|
844
|
+
lastError = undefined;
|
|
845
|
+
break;
|
|
640
846
|
}
|
|
641
847
|
}
|
|
642
|
-
else {
|
|
643
|
-
// A result was successfully parsed and validated!
|
|
644
|
-
lastError = undefined;
|
|
645
|
-
break;
|
|
646
|
-
}
|
|
647
848
|
}
|
|
648
849
|
}
|
|
850
|
+
catch (requestError) {
|
|
851
|
+
if (!ctx.signal?.aborted &&
|
|
852
|
+
!streamController.signal.aborted &&
|
|
853
|
+
this.input.runtimeSchemaFallback !== false &&
|
|
854
|
+
!promotedToPromptDelivery &&
|
|
855
|
+
typeof request.responseFormat === 'object' &&
|
|
856
|
+
(request.responseFormat.schemaDelivery ?? 'auto') === 'auto' &&
|
|
857
|
+
outputRetries > 0) {
|
|
858
|
+
// Mode-4 fallback: promote to prompt-text and retry (mirrors the
|
|
859
|
+
// empty/unparseable json-parsing promotion above). Consumes one
|
|
860
|
+
// `outputRetries`; the guard makes it fire at most once per run.
|
|
861
|
+
request.responseFormat.schemaDelivery = 'prompt';
|
|
862
|
+
promotedToPromptDelivery = true;
|
|
863
|
+
outputRetries--;
|
|
864
|
+
yield emit({ type: 'textReset', reason: 'request-error', request });
|
|
865
|
+
requestFailedNeedsRetry = true;
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
throw requestError;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// A request-time failure was promoted to prompt-text delivery: skip the
|
|
872
|
+
// rest of this iteration (reconfig/dynamic/parse) and re-issue.
|
|
873
|
+
if (requestFailedNeedsRetry) {
|
|
874
|
+
iterations++;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
649
877
|
// Call reconfig if provided
|
|
650
878
|
if (this.input.reconfig) {
|
|
651
879
|
const stats = {
|
|
@@ -739,6 +967,14 @@ export class Prompt {
|
|
|
739
967
|
}
|
|
740
968
|
// We don't emit complete without a valid result unless toolsOnly is set
|
|
741
969
|
if (result === undefined && !onlyTools) {
|
|
970
|
+
// Abort is not an error — the caller asked us to stop. Exit
|
|
971
|
+
// silently with the partial `request.messages` they may want
|
|
972
|
+
// for resume/inspection, without emitting `complete` (no real
|
|
973
|
+
// output to surface) and without raising. Mirrors the suspend
|
|
974
|
+
// path's `return undefined` semantics.
|
|
975
|
+
if (ctx.signal?.aborted) {
|
|
976
|
+
return undefined;
|
|
977
|
+
}
|
|
742
978
|
if (!lastError && iterations === maxIterations) {
|
|
743
979
|
lastError = `Maximum iterations (${maxIterations}) reached without a valid response.`;
|
|
744
980
|
}
|
|
@@ -833,7 +1069,7 @@ export class Prompt {
|
|
|
833
1069
|
const content = this.content(contentInput);
|
|
834
1070
|
// Determine response format
|
|
835
1071
|
const responseFormat = schema && !(schema instanceof ZodString)
|
|
836
|
-
? { type: schema, strict: this.input.strict ??
|
|
1072
|
+
? { type: schema, strict: this.input.strict ?? 1, schemaDelivery: this.input.schemaDelivery ?? 'auto' }
|
|
837
1073
|
: 'text';
|
|
838
1074
|
return { config, content, tools, toolObjects, responseFormat, schema };
|
|
839
1075
|
}
|
|
@@ -853,6 +1089,65 @@ export class Prompt {
|
|
|
853
1089
|
return response;
|
|
854
1090
|
};
|
|
855
1091
|
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Truncate a validation error so the corrective user message we send back
|
|
1094
|
+
* to the LLM stays bounded. Lengthy zod errors against deep recursive
|
|
1095
|
+
* schemas can run 100k+ characters — eating context and burning tokens
|
|
1096
|
+
* on noise the model can't usefully act on. Anything past `max` is
|
|
1097
|
+
* replaced with a `… (N more characters)` marker so the LLM both knows
|
|
1098
|
+
* the message was clipped and roughly how much was lost.
|
|
1099
|
+
*
|
|
1100
|
+
* `protected` so a subclass can override — e.g. to emit a shorter
|
|
1101
|
+
* marker, route errors through a custom formatter, or skip truncation
|
|
1102
|
+
* entirely when targeting a model with a large context window.
|
|
1103
|
+
*/
|
|
1104
|
+
truncateValidationError(message, max) {
|
|
1105
|
+
const cap = max ?? DEFAULT_VALIDATION_ERROR_MAX_LENGTH;
|
|
1106
|
+
if (cap <= 0 || message.length <= cap)
|
|
1107
|
+
return message;
|
|
1108
|
+
const dropped = message.length - cap;
|
|
1109
|
+
return `${message.slice(0, cap)}… (${dropped} more characters)`;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Build a short, model-readable placeholder for a `tool_call` that
|
|
1113
|
+
* never produced a real result. Used by the result-emit loop when
|
|
1114
|
+
* `toolsComplete` is true to keep `request.messages` well-paired
|
|
1115
|
+
* even when the dispatch loop short-circuited (signal abort or
|
|
1116
|
+
* `ToolInterrupt` cutting a parallel batch short) or a tool errored
|
|
1117
|
+
* before its content could be accumulated.
|
|
1118
|
+
*
|
|
1119
|
+
* The marker prefix (`[aborted: …]`, `[interrupted]`, `[error: …]`)
|
|
1120
|
+
* gives the model a clear cue and lets callers detect synthesized
|
|
1121
|
+
* replies by inspecting the message content if they need to
|
|
1122
|
+
* differentiate. Suspended tools are intentionally NOT routed
|
|
1123
|
+
* through here — the suspend/resume protocol depends on the
|
|
1124
|
+
* missing result slot.
|
|
1125
|
+
*
|
|
1126
|
+
* `protected` so a subclass can override — e.g. to emit
|
|
1127
|
+
* project-specific markers, log diagnostics for every synthesized
|
|
1128
|
+
* result, or fall back to a model-specific instruction string.
|
|
1129
|
+
*/
|
|
1130
|
+
synthesizeUnpairedResult(te) {
|
|
1131
|
+
switch (te.status) {
|
|
1132
|
+
case 'interrupted':
|
|
1133
|
+
return te.error
|
|
1134
|
+
? `[interrupted: ${te.error}]`
|
|
1135
|
+
: '[interrupted]';
|
|
1136
|
+
case 'error':
|
|
1137
|
+
case 'invalid':
|
|
1138
|
+
return te.error
|
|
1139
|
+
? `[error: ${te.error}]`
|
|
1140
|
+
: '[error]';
|
|
1141
|
+
case 'ready':
|
|
1142
|
+
case 'parsed':
|
|
1143
|
+
case 'executing':
|
|
1144
|
+
// Never reached a terminal status — the dispatch loop cut out
|
|
1145
|
+
// mid-flight (abort or interrupt of a sibling).
|
|
1146
|
+
return '[aborted: tool call did not complete before the request was cancelled]';
|
|
1147
|
+
default:
|
|
1148
|
+
return '[no result]';
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
856
1151
|
/**
|
|
857
1152
|
* Trims messages from the request to fit within token limits.
|
|
858
1153
|
*
|
|
@@ -989,12 +1284,27 @@ function emitter() {
|
|
|
989
1284
|
};
|
|
990
1285
|
return emitter;
|
|
991
1286
|
}
|
|
992
|
-
function newToolExecution(ctx, toolCall, toolInfo
|
|
1287
|
+
function newToolExecution(ctx, toolCall, toolInfo, validationErrorMaxLength,
|
|
1288
|
+
// Pluggable truncator — the owning Prompt instance forwards its
|
|
1289
|
+
// (overridable) `truncateValidationError` method here so a subclass
|
|
1290
|
+
// can change how tool-arg parse errors are formatted without having
|
|
1291
|
+
// to fork the whole prompt loop.
|
|
1292
|
+
truncate,
|
|
1293
|
+
// Optional per-prompt result transformer (PromptInput.onToolResult),
|
|
1294
|
+
// pre-bound by the loop to build the discriminated event (tool name,
|
|
1295
|
+
// ctx, request) so this function only needs to feed it the raw result
|
|
1296
|
+
// and parsed args. Runs on the SUCCESS path only, INSIDE run() before
|
|
1297
|
+
// the tool is marked `success` / its output emitter is armed — so the
|
|
1298
|
+
// presented value lands on `toModel` and any throw converts the slot to
|
|
1299
|
+
// a normal tool error (correct events + preserved pairing). Absent ⇒
|
|
1300
|
+
// `toModel` is just the raw result.
|
|
1301
|
+
present) {
|
|
993
1302
|
const start = emitter();
|
|
994
1303
|
const output = emitter();
|
|
995
1304
|
const error = emitter();
|
|
996
1305
|
const interrupt = emitter();
|
|
997
1306
|
const suspend = emitter();
|
|
1307
|
+
const repairAttempt = emitter();
|
|
998
1308
|
if (!toolInfo) {
|
|
999
1309
|
error.ready = true;
|
|
1000
1310
|
}
|
|
@@ -1009,20 +1319,49 @@ function newToolExecution(ctx, toolCall, toolInfo) {
|
|
|
1009
1319
|
emitError: error.emit,
|
|
1010
1320
|
emitInterrupt: interrupt.emit,
|
|
1011
1321
|
emitSuspend: suspend.emit,
|
|
1322
|
+
emitRepairAttempt: repairAttempt.emit,
|
|
1012
1323
|
parse: once(async () => {
|
|
1013
1324
|
// Already ran or failed earlier?
|
|
1014
1325
|
if (execution.status !== 'ready') {
|
|
1015
1326
|
return execution;
|
|
1016
1327
|
}
|
|
1017
|
-
const
|
|
1328
|
+
const rawArgs = execution.toolCall.arguments;
|
|
1329
|
+
const isEmpty = !rawArgs || rawArgs.trim() === '';
|
|
1330
|
+
const args = isEmpty ? '{}' : rawArgs;
|
|
1018
1331
|
try {
|
|
1019
|
-
execution.args = await toolInfo.tool.parse(ctx, args, toolInfo.definition.parameters)
|
|
1332
|
+
execution.args = await toolInfo.tool.parse(ctx, args, toolInfo.definition.parameters, toolInfo.definition.descriptor, (info) => {
|
|
1333
|
+
// String-encoded-field fallback ran a repair attempt
|
|
1334
|
+
// (success OR failure). Stash the outcome so the outer
|
|
1335
|
+
// loop can emit `toolArgRepairAttempt`. Keeping failures
|
|
1336
|
+
// visible matters as much as successes — they tell us the
|
|
1337
|
+
// model double-encoded AND the inner content was also
|
|
1338
|
+
// bad, which is a different bug than either alone.
|
|
1339
|
+
execution.repairAttempt = info;
|
|
1340
|
+
repairAttempt.ready = true;
|
|
1341
|
+
});
|
|
1020
1342
|
execution.status = 'parsed';
|
|
1021
1343
|
start.ready = true;
|
|
1022
1344
|
}
|
|
1023
1345
|
catch (e) {
|
|
1024
1346
|
execution.status = 'invalid';
|
|
1025
|
-
|
|
1347
|
+
// Preserve the untruncated raw args alongside the truncated
|
|
1348
|
+
// model-facing error so the diagnostic log gets the full
|
|
1349
|
+
// payload (post-mortem reproduction is impossible without it).
|
|
1350
|
+
execution.rawArgs = args;
|
|
1351
|
+
// Distinguish "model sent no arguments at all" from "model sent
|
|
1352
|
+
// bad arguments". The former is usually a streaming-relay issue
|
|
1353
|
+
// (OpenRouter/Anthropic) or a genuine model gaffe — calling it
|
|
1354
|
+
// out by name in the error gives the model a clearer cue to
|
|
1355
|
+
// fix itself on the retry turn instead of repeating the empty
|
|
1356
|
+
// call. The latter (bad arguments) keeps its original Zod /
|
|
1357
|
+
// JSON.parse message, which already names the offending field.
|
|
1358
|
+
const reason = isEmpty
|
|
1359
|
+
? `the tool was called with NO arguments. The schema requires arguments — re-call this tool with the required fields populated. Validation: ${e.message}`
|
|
1360
|
+
: `${e.message}, args: ${args}`;
|
|
1361
|
+
const formatted = `Error parsing tool arguments: ${reason}`;
|
|
1362
|
+
execution.error = truncate
|
|
1363
|
+
? truncate(formatted, validationErrorMaxLength)
|
|
1364
|
+
: formatted;
|
|
1026
1365
|
error.ready = true;
|
|
1027
1366
|
}
|
|
1028
1367
|
return execution;
|
|
@@ -1035,6 +1374,23 @@ function newToolExecution(ctx, toolCall, toolInfo) {
|
|
|
1035
1374
|
try {
|
|
1036
1375
|
execution.status = 'executing';
|
|
1037
1376
|
execution.result = await resolve(toolInfo.tool.run(execution.args, { ...ctx, toolCallId: toolCall.id }));
|
|
1377
|
+
// Tool ran successfully. Apply the optional per-prompt result
|
|
1378
|
+
// transformer BEFORE marking success / arming the output emitter,
|
|
1379
|
+
// so the `toolOutput` event carries the presented `toModel` and the
|
|
1380
|
+
// paired `role: 'tool'` content reflects it. A handler that THROWS
|
|
1381
|
+
// is treated exactly like a tool error for this slot — the raw
|
|
1382
|
+
// `result` stays on the executor (get('tools') / streamTools keep
|
|
1383
|
+
// it) while the model-facing channel emits the error, preserving
|
|
1384
|
+
// the tool_call ↔ role:'tool' pairing guarantee.
|
|
1385
|
+
try {
|
|
1386
|
+
execution.toModel = present ? await present(execution.result, execution.args) : execution.result;
|
|
1387
|
+
}
|
|
1388
|
+
catch (e) {
|
|
1389
|
+
execution.status = 'error';
|
|
1390
|
+
execution.error = `Error transforming tool result: ${e.message}`;
|
|
1391
|
+
error.ready = true;
|
|
1392
|
+
return execution;
|
|
1393
|
+
}
|
|
1038
1394
|
execution.status = 'success';
|
|
1039
1395
|
output.ready = true;
|
|
1040
1396
|
}
|