@aeye/core 0.3.7 → 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/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";
4
- import { ToolInterrupt } from "./tool";
5
- import { strictify, toJSONSchema } from "./schema";
3
+ import { accumulateReasoning, accumulateUsage, extractJSONObject, getChunksFromResponse, getInputTokens, getModel, getOutputTokens, getTotalTokens, resolve, resolveFn, yieldAll } from "./common";
4
+ import { ToolInterrupt, PromptSuspend } from "./tool";
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,14 +275,27 @@ 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;
274
296
  let iterations = 0;
275
297
  let accumulatedUsage = {};
298
+ let suspended = false;
276
299
  // Track stats for reconfig
277
300
  let toolParseErrors = 0;
278
301
  let toolCallErrors = 0;
@@ -293,6 +316,16 @@ export class Prompt {
293
316
  };
294
317
  // Main execution loop!
295
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;
296
329
  const toolExecutors = [];
297
330
  const toolExecutorMap = new Map();
298
331
  const toolErrorsPrevious = (toolCallErrors + toolParseErrors);
@@ -310,320 +343,537 @@ export class Prompt {
310
343
  ...(await this.metadataFn(input, ctx) || {}),
311
344
  };
312
345
  yield emit({ type: 'request', request, iterations });
313
- const stream = streamer(request, ctx, metadata, streamController.signal);
314
- for await (const chunk of stream) {
315
- if (streamController.signal.aborted) {
316
- break;
317
- }
318
- if (chunk.usage) {
319
- usage = chunk.usage;
320
- if (!requestUsageSent) {
321
- yield emit({ type: 'requestUsage', usage: chunk.usage, request });
322
- 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;
323
359
  }
324
- accumulateUsage(accumulatedUsage, chunk.usage);
325
- }
326
- if (chunk.content) {
327
- content += chunk.content;
328
- yield emit({ type: 'textPartial', content: chunk.content, request });
329
- }
330
- if (chunk.refusal) {
331
- refusal += chunk.refusal;
332
- yield emit({ type: 'textPartial', content: chunk.refusal, request });
333
- }
334
- if (chunk.reasoning) {
335
- reasoning = accumulateReasoning(reasoning, chunk.reasoning);
336
- yield emit({ type: 'reasonPartial', reasoning, request });
337
- }
338
- // Handle tool calls
339
- if (chunk.toolCallNamed) {
340
- const toolExecutor = newToolExecution(ctx, chunk.toolCallNamed, toolMap.get(chunk.toolCallNamed.name));
341
- toolExecutors.push(toolExecutor);
342
- toolExecutorMap.set(chunk.toolCallNamed.id, toolExecutor);
343
- if (toolExecutor.tool) {
344
- 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);
345
367
  }
346
- else {
347
- streamController.abort(toolExecutor.error);
348
- break;
368
+ if (chunk.content) {
369
+ content += chunk.content;
370
+ yield emit({ type: 'textPartial', content: chunk.content, request });
349
371
  }
350
- }
351
- if (chunk.toolCallArguments) {
352
- const toolExecutor = toolExecutorMap.get(chunk.toolCallArguments.id);
353
- if (toolExecutor) {
354
- toolExecutor.toolCall = chunk.toolCallArguments;
355
- 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 });
356
375
  }
357
- else {
358
- 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 });
359
379
  }
360
- }
361
- if (chunk.toolCall) {
362
- const toolExecutor = toolExecutorMap.get(chunk.toolCall.id);
363
- if (toolExecutor) {
364
- toolExecutor.toolCall = chunk.toolCall;
365
- if (toolMode === 'immediate') {
366
- // Start execution immediately
367
- setImmediate(toolExecutor.run);
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;
368
406
  }
369
407
  }
370
- else {
371
- console.error(`Received tool call for unknown tool call ID ${chunk.toolCall.id}`);
372
- }
373
- }
374
- if (chunk.finishReason) {
375
- finishReason = chunk.finishReason;
376
- }
377
- // In immediate mode we might be getting more chunks while executing, emit events as soon as possible.
378
- if (toolMode === 'immediate') {
379
- for (const toolCall of toolExecutors) {
380
- if (toolCall.emitStart()) {
381
- 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 });
382
413
  }
383
- if (toolCall.emitOutput()) {
384
- yield emitTool({ type: 'toolOutput', tool: toolCall.tool, args: toolCall.args, result: toolCall.result, request });
414
+ else {
415
+ console.error(`Received tool call arguments for unknown tool call ID ${chunk.toolCallArguments.id}`);
385
416
  }
386
- if (toolCall.emitInterrupt()) {
387
- yield emitTool({ type: 'toolInterrupt', tool: toolCall.tool, args: toolCall.args, request });
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
+ }
388
426
  }
389
- if (toolCall.emitError()) {
390
- yield emitTool({ type: 'toolError', tool: toolCall.tool, args: toolCall.args, error: toolCall.error, request });
427
+ else {
428
+ console.error(`Received tool call for unknown tool call ID ${chunk.toolCall.id}`);
429
+ }
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
+ }
391
452
  }
392
453
  }
393
454
  }
394
- }
395
- ctx.signal?.removeEventListener('abort', streamAbort);
396
- // If the model reasoned, yield it
397
- if (reasoning) {
398
- yield emit({ type: 'reason', reasoning, request });
399
- }
400
- // If the model refused to answer and stop
401
- if (finishReason === 'refusal' || refusal) {
402
- yield emit({ type: 'refusal', content: refusal || 'unspecified', request });
403
- lastError = refusal || 'Model refused to answer.';
404
- break;
405
- }
406
- // If the model was stopped due to content filtering
407
- if (finishReason === 'content_filter') {
408
- yield emit({ type: 'refusal', content: 'Content filtered by AI model', request });
409
- lastError = 'Model response was filtered due to content policy.';
410
- break;
411
- }
412
- // If we sent too much, forget the past homie
413
- if (finishReason === 'length') {
414
- if (usage && forgetRetries > 0) {
415
- request.messages = this.forget(request, ctx, usage);
416
- forgetRetries--;
417
- yield emit({ type: 'textReset', reason: 'length', request });
418
- // Lets retry immediately
419
- continue;
455
+ ctx.signal?.removeEventListener('abort', streamAbort);
456
+ // If the model reasoned, yield it
457
+ if (reasoning) {
458
+ yield emit({ type: 'reason', reasoning, request });
420
459
  }
421
- else {
422
- // Stop iteration - we can't trim without usage info
423
- lastError = 'Model indicated length finish reason but no token usage was provided so context cannot be trimmed.';
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.';
424
464
  break;
425
465
  }
426
- }
427
- // Yield text event if content exists before processing tool calls
428
- if (content.length > 0) {
429
- yield emit({ type: 'text', content, request });
430
- }
431
- // If we need to make some tool calls, lets do it!
432
- // We might not have a finish_reason if we got a bad tool name.
433
- if (finishReason === 'tool_calls' || toolExecutors.length) {
434
- // Add the assistant's response with tool calls to the conversation
435
- yield emitMessage({
436
- role: 'assistant',
437
- content,
438
- toolCalls: toolExecutors.map(te => te.toolCall),
439
- reasoning,
440
- });
441
- // If there are any error/invalid - just stop and add their errors and retry
442
- let skip = false;
443
- for (const toolExecutor of toolExecutors) {
444
- if (toolExecutor.error) {
445
- 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;
446
480
  }
447
481
  else {
448
- // 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.
449
- toolExecutor.parse();
450
- }
451
- if (toolExecutor.emitStart()) {
452
- yield emitTool({ type: 'toolStart', tool: toolExecutor.tool, args: toolExecutor.args, request });
453
- }
454
- if (toolExecutor.emitError()) {
455
- 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;
456
485
  }
457
486
  }
458
- // The execution mode for this iteration.
459
- const iterationMode = skip ? 'skip' : toolMode;
460
- // All tool calls are valid, lets start this!
461
- switch (iterationMode) {
462
- case 'sequential':
463
- for (const toolExecutor of toolExecutors) {
464
- await toolExecutor.parse();
465
- if (toolExecutor.emitStart()) {
466
- yield emitTool({ type: 'toolStart', tool: toolExecutor.tool, args: toolExecutor.args, request });
467
- }
468
- await toolExecutor.run();
469
- if (toolExecutor.emitOutput()) {
470
- yield emitTool({ type: 'toolOutput', tool: toolExecutor.tool, args: toolExecutor.args, result: toolExecutor.result, request });
471
- }
472
- if (toolExecutor.emitInterrupt()) {
473
- yield emitTool({ type: 'toolInterrupt', tool: toolExecutor.tool, args: toolExecutor.args, request });
474
- }
475
- if (toolExecutor.emitError()) {
476
- yield emitTool({ type: 'toolError', tool: toolExecutor.tool, args: toolExecutor.args, error: toolExecutor.error, request });
477
- }
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;
478
506
  }
479
- break;
480
- case 'parallel':
481
- case 'immediate':
482
- const parseRuns = toolExecutors.map(tc => [tc.parse(), tc.run()]).flat();
483
- for await (const { result: toolCallPromise } of yieldAll(parseRuns)) {
484
- const toolExecutor = await toolCallPromise;
485
- if (toolExecutor.emitStart()) {
486
- yield emitTool({ type: 'toolStart', tool: toolExecutor.tool, args: toolExecutor.args, request });
487
- }
488
- if (toolExecutor.emitOutput()) {
489
- yield emitTool({ type: 'toolOutput', tool: toolExecutor.tool, args: toolExecutor.args, result: toolExecutor.result, request });
490
- }
491
- if (toolExecutor.emitInterrupt()) {
492
- yield emitTool({ type: 'toolInterrupt', tool: toolExecutor.tool, args: toolExecutor.args, request });
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
+ }
493
557
  }
494
- if (toolExecutor.emitError()) {
495
- yield emitTool({ type: 'toolError', tool: toolExecutor.tool, args: toolExecutor.args, error: toolExecutor.error, request });
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
+ }
496
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;
497
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;
498
658
  break;
659
+ }
660
+ if ((toolCallErrors + toolParseErrors) > toolErrorsPrevious) {
661
+ if (toolRetries > 0) {
662
+ toolRetries--;
663
+ }
664
+ else {
665
+ disableTools = true;
666
+ }
667
+ }
499
668
  }
500
- for (const toolExecutor of toolExecutors) {
501
- const content = toolExecutor.error
502
- ? toolExecutor.error
503
- : toolExecutor.result
504
- ? typeof toolExecutor.result === 'string'
505
- ? toolExecutor.result
506
- : JSON.stringify(toolExecutor.result)
507
- : '';
669
+ else {
670
+ // No tool calls, just add the assistant response
508
671
  yield emitMessage({
509
- role: 'tool',
672
+ role: 'assistant',
510
673
  content,
511
- toolCallId: toolExecutor.toolCall.id,
674
+ reasoning,
512
675
  });
513
- if (toolExecutor.status === 'invalid') {
514
- toolParseErrors++;
515
- }
516
- else if (toolExecutor.status === 'error') {
517
- toolCallErrors++;
518
- }
519
- else if (toolExecutor.status === 'success') {
520
- toolSuccesses++;
676
+ }
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;
521
687
  }
522
688
  }
523
- if ((toolCallErrors + toolParseErrors) > toolErrorsPrevious) {
524
- if (toolRetries > 0) {
525
- toolRetries--;
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;
526
693
  }
527
- else {
694
+ // If we met our max tool calls, remove the tools from the request
695
+ if (hitMax) {
696
+ // No more tools for you!
528
697
  disableTools = true;
529
698
  }
530
699
  }
531
- }
532
- else {
533
- // No tool calls, just add the assistant response
534
- yield emitMessage({
535
- role: 'assistant',
536
- content,
537
- reasoning,
538
- });
539
- }
540
- const hadToolErrors = toolParseErrorsPrevious !== toolParseErrors;
541
- const hitMax = this.input.toolsMax && toolSuccesses >= this.input.toolsMax;
542
- // If if there are only tool calls wanted...
543
- if (onlyTools) {
544
- const noTools = toolExecutors.length === 0;
545
- // 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.
546
- if (hitMax || noTools) {
547
- // got what we needed!
548
- lastError = undefined;
549
- break;
550
- }
551
- }
552
- else {
553
- // We don't only want tools, but if we had some successes and no new parse errors, remove tool requirement
554
- if (fixedToolChoice && toolSuccesses > 0 && !hadToolErrors) {
555
- delete request.toolChoice;
556
- }
557
- // If we met our max tool calls, remove the tools from the request
558
- if (hitMax) {
559
- // No more tools for you!
560
- disableTools = true;
700
+ // Accumulate text content from this iteration
701
+ if (content.length > 0) {
702
+ completeText += content;
561
703
  }
562
- }
563
- // Accumulate text content from this iteration
564
- if (content.length > 0) {
565
- completeText += content;
566
- }
567
- // Determine if we should stop
568
- const stop = finishReason === 'stop' && toolExecutors.length === 0 ||
569
- toolExecutors.some(toolExecutor => toolExecutor.status === 'interrupted');
570
- // If we are finished, parse the output
571
- if (stop) {
572
- if (!schema || (schema instanceof ZodString)) {
573
- result = content;
574
- break; // All good!
575
- }
576
- else {
577
- // Grab the JSON part from the content just in case...
578
- const potentialJSON = content.substring(content.indexOf('{'), content.lastIndexOf('}') + 1);
579
- let errorMessage = '';
580
- let resetReason = '';
581
- try {
582
- const parsedJSON = JSON.parse(potentialJSON);
583
- const parsedSafe = await schema.safeParseAsync(parsedJSON);
584
- if (!parsedSafe.success) {
585
- const issueSummary = parsedSafe.error.issues
586
- .map(i => `- ${i.path.join('.')}: ${i.message}${['string', 'boolean', 'number'].includes(typeof i.input) ? ` (input: ${i.input})` : ''}`)
587
- .join('\n');
588
- errorMessage = `The output was an invalid format:\n${issueSummary}\n\nPlease adhere to the output schema:\n${toJSONSchema(schema, this.input.strict ?? true)}`;
589
- resetReason = 'schema-parsing';
590
- }
591
- else {
592
- result = parsedSafe.data;
593
- try {
594
- await this.input.validate?.(result, ctx);
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
+ }
595
767
  }
596
- catch (validationError) {
597
- errorMessage = `The output failed validation:\n${validationError.message}`;
598
- resetReason = 'validation';
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
+ }
599
798
  }
600
799
  }
601
- }
602
- catch (parseError) {
603
- errorMessage = `The output was not valid JSON:\n${parseError.message}`;
604
- resetReason = 'json-parsing';
605
- }
606
- if (errorMessage) {
607
- if (outputRetries > 0) {
608
- outputRetries--;
609
- yield emit({ type: 'textReset', reason: resetReason, request });
610
- yield emitMessage({
611
- role: 'user',
612
- content: errorMessage,
613
- });
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
+ }
614
841
  }
615
842
  else {
616
- lastError = errorMessage;
617
- break; // No more retries left
843
+ // A result was successfully parsed and validated!
844
+ lastError = undefined;
845
+ break;
618
846
  }
619
847
  }
620
- else {
621
- // A result was successfully parsed and validated!
622
- lastError = undefined;
623
- break;
624
- }
625
848
  }
626
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
+ }
627
877
  // Call reconfig if provided
628
878
  if (this.input.reconfig) {
629
879
  const stats = {
@@ -706,8 +956,25 @@ export class Prompt {
706
956
  yield emit({ type: 'responseTokens', tokens: outputTokens, request });
707
957
  }
708
958
  yield emit({ type: 'usage', usage: accumulatedUsage, request });
959
+ // If the prompt was suspended by a tool, emit a suspend event.
960
+ // request.messages at this point ends with the assistant tool-call message and any
961
+ // completed tool results; the suspended tool's result is absent and must be supplied on resume.
962
+ // The caller can save request.messages, append the pending tool result, and re-run.
963
+ // Returns undefined (not TOutput) — the prompt has not produced a final result yet.
964
+ if (suspended) {
965
+ yield emit({ type: 'suspend', request });
966
+ return undefined;
967
+ }
709
968
  // We don't emit complete without a valid result unless toolsOnly is set
710
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
+ }
711
978
  if (!lastError && iterations === maxIterations) {
712
979
  lastError = `Maximum iterations (${maxIterations}) reached without a valid response.`;
713
980
  }
@@ -802,7 +1069,7 @@ export class Prompt {
802
1069
  const content = this.content(contentInput);
803
1070
  // Determine response format
804
1071
  const responseFormat = schema && !(schema instanceof ZodString)
805
- ? { type: schema, strict: this.input.strict ?? true }
1072
+ ? { type: schema, strict: this.input.strict ?? 1, schemaDelivery: this.input.schemaDelivery ?? 'auto' }
806
1073
  : 'text';
807
1074
  return { config, content, tools, toolObjects, responseFormat, schema };
808
1075
  }
@@ -822,6 +1089,65 @@ export class Prompt {
822
1089
  return response;
823
1090
  };
824
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
+ }
825
1151
  /**
826
1152
  * Trims messages from the request to fit within token limits.
827
1153
  *
@@ -958,11 +1284,27 @@ function emitter() {
958
1284
  };
959
1285
  return emitter;
960
1286
  }
961
- 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) {
962
1302
  const start = emitter();
963
1303
  const output = emitter();
964
1304
  const error = emitter();
965
1305
  const interrupt = emitter();
1306
+ const suspend = emitter();
1307
+ const repairAttempt = emitter();
966
1308
  if (!toolInfo) {
967
1309
  error.ready = true;
968
1310
  }
@@ -976,19 +1318,50 @@ function newToolExecution(ctx, toolCall, toolInfo) {
976
1318
  emitOutput: output.emit,
977
1319
  emitError: error.emit,
978
1320
  emitInterrupt: interrupt.emit,
1321
+ emitSuspend: suspend.emit,
1322
+ emitRepairAttempt: repairAttempt.emit,
979
1323
  parse: once(async () => {
980
1324
  // Already ran or failed earlier?
981
1325
  if (execution.status !== 'ready') {
982
1326
  return execution;
983
1327
  }
1328
+ const rawArgs = execution.toolCall.arguments;
1329
+ const isEmpty = !rawArgs || rawArgs.trim() === '';
1330
+ const args = isEmpty ? '{}' : rawArgs;
984
1331
  try {
985
- execution.args = await toolInfo.tool.parse(ctx, execution.toolCall.arguments, 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
+ });
986
1342
  execution.status = 'parsed';
987
1343
  start.ready = true;
988
1344
  }
989
1345
  catch (e) {
990
1346
  execution.status = 'invalid';
991
- execution.error = `Error parsing tool arguments: ${e.message}, args: ${execution.toolCall.arguments}`;
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;
992
1365
  error.ready = true;
993
1366
  }
994
1367
  return execution;
@@ -1000,12 +1373,33 @@ function newToolExecution(ctx, toolCall, toolInfo) {
1000
1373
  }
1001
1374
  try {
1002
1375
  execution.status = 'executing';
1003
- execution.result = await resolve(toolInfo.tool.run(execution.args, ctx));
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
+ }
1004
1394
  execution.status = 'success';
1005
1395
  output.ready = true;
1006
1396
  }
1007
1397
  catch (e) {
1008
- if (e instanceof ToolInterrupt) {
1398
+ if (e instanceof PromptSuspend) {
1399
+ execution.status = 'suspended';
1400
+ suspend.ready = true;
1401
+ }
1402
+ else if (e instanceof ToolInterrupt) {
1009
1403
  execution.status = 'interrupted';
1010
1404
  interrupt.ready = true;
1011
1405
  }