@aeye/core 0.3.8 → 0.4.0

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