@aeye/core 0.5.4 → 0.7.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.
Files changed (124) hide show
  1. package/README.md +790 -724
  2. package/aeye-core-prompts.md +179 -178
  3. package/aeye-core-schema.md +43 -16
  4. package/aeye-core-tools.md +179 -177
  5. package/aeye-core-types.md +1 -0
  6. package/dist/common.d.ts +30 -5
  7. package/dist/common.d.ts.map +1 -1
  8. package/dist/common.js +134 -129
  9. package/dist/common.js.map +1 -1
  10. package/dist/delivery.d.ts +39 -0
  11. package/dist/delivery.d.ts.map +1 -0
  12. package/dist/delivery.js +127 -0
  13. package/dist/delivery.js.map +1 -0
  14. package/dist/errors.d.ts +60 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +84 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/prompt.d.ts +134 -15
  23. package/dist/prompt.d.ts.map +1 -1
  24. package/dist/prompt.js +220 -164
  25. package/dist/prompt.js.map +1 -1
  26. package/dist/schema/analyze.d.ts +94 -0
  27. package/dist/schema/analyze.d.ts.map +1 -0
  28. package/dist/schema/analyze.js +221 -0
  29. package/dist/schema/analyze.js.map +1 -0
  30. package/dist/schema/any-value.d.ts +32 -0
  31. package/dist/schema/any-value.d.ts.map +1 -0
  32. package/dist/schema/any-value.js +77 -0
  33. package/dist/schema/any-value.js.map +1 -0
  34. package/dist/schema/budget.d.ts +114 -0
  35. package/dist/schema/budget.d.ts.map +1 -0
  36. package/dist/schema/budget.js +223 -0
  37. package/dist/schema/budget.js.map +1 -0
  38. package/dist/schema/check-bag.d.ts +28 -0
  39. package/dist/schema/check-bag.d.ts.map +1 -0
  40. package/dist/schema/check-bag.js +129 -0
  41. package/dist/schema/check-bag.js.map +1 -0
  42. package/dist/schema/consistency.d.ts +27 -0
  43. package/dist/schema/consistency.d.ts.map +1 -0
  44. package/dist/schema/consistency.js +110 -0
  45. package/dist/schema/consistency.js.map +1 -0
  46. package/dist/schema/cycle-breaker.d.ts +14 -0
  47. package/dist/schema/cycle-breaker.d.ts.map +1 -0
  48. package/dist/schema/cycle-breaker.js +157 -0
  49. package/dist/schema/cycle-breaker.js.map +1 -0
  50. package/dist/schema/decode.d.ts +31 -0
  51. package/dist/schema/decode.d.ts.map +1 -0
  52. package/dist/schema/decode.js +207 -0
  53. package/dist/schema/decode.js.map +1 -0
  54. package/dist/schema/descriptor.d.ts +300 -0
  55. package/dist/schema/descriptor.d.ts.map +1 -0
  56. package/dist/schema/descriptor.js +15 -0
  57. package/dist/schema/descriptor.js.map +1 -0
  58. package/dist/schema/dialects/anthropic.d.ts +34 -0
  59. package/dist/schema/dialects/anthropic.d.ts.map +1 -0
  60. package/dist/schema/dialects/anthropic.js +77 -0
  61. package/dist/schema/dialects/anthropic.js.map +1 -0
  62. package/dist/schema/dialects/google.d.ts +47 -0
  63. package/dist/schema/dialects/google.d.ts.map +1 -0
  64. package/dist/schema/dialects/google.js +91 -0
  65. package/dist/schema/dialects/google.js.map +1 -0
  66. package/dist/schema/dialects/lenient.d.ts +4 -0
  67. package/dist/schema/dialects/lenient.d.ts.map +1 -0
  68. package/dist/schema/dialects/lenient.js +26 -0
  69. package/dist/schema/dialects/lenient.js.map +1 -0
  70. package/dist/schema/dialects/openai.d.ts +40 -0
  71. package/dist/schema/dialects/openai.d.ts.map +1 -0
  72. package/dist/schema/dialects/openai.js +81 -0
  73. package/dist/schema/dialects/openai.js.map +1 -0
  74. package/dist/schema/dialects/xai.d.ts +23 -0
  75. package/dist/schema/dialects/xai.d.ts.map +1 -0
  76. package/dist/schema/dialects/xai.js +53 -0
  77. package/dist/schema/dialects/xai.js.map +1 -0
  78. package/dist/schema/enum-values.d.ts +44 -0
  79. package/dist/schema/enum-values.d.ts.map +1 -0
  80. package/dist/schema/enum-values.js +56 -0
  81. package/dist/schema/enum-values.js.map +1 -0
  82. package/dist/schema/expressible.d.ts +59 -0
  83. package/dist/schema/expressible.d.ts.map +1 -0
  84. package/dist/schema/expressible.js +161 -0
  85. package/dist/schema/expressible.js.map +1 -0
  86. package/dist/schema/index.d.ts +20 -0
  87. package/dist/schema/index.d.ts.map +1 -0
  88. package/dist/schema/index.js +20 -0
  89. package/dist/schema/index.js.map +1 -0
  90. package/dist/schema/json-schema.d.ts +64 -0
  91. package/dist/schema/json-schema.d.ts.map +1 -0
  92. package/dist/schema/json-schema.js +2 -0
  93. package/dist/schema/json-schema.js.map +1 -0
  94. package/dist/schema/registry.d.ts +73 -0
  95. package/dist/schema/registry.d.ts.map +1 -0
  96. package/dist/schema/registry.js +135 -0
  97. package/dist/schema/registry.js.map +1 -0
  98. package/dist/schema/strictify.d.ts +19 -0
  99. package/dist/schema/strictify.d.ts.map +1 -0
  100. package/dist/schema/strictify.js +237 -0
  101. package/dist/schema/strictify.js.map +1 -0
  102. package/dist/schema/to-json-schema.d.ts +29 -0
  103. package/dist/schema/to-json-schema.d.ts.map +1 -0
  104. package/dist/schema/to-json-schema.js +692 -0
  105. package/dist/schema/to-json-schema.js.map +1 -0
  106. package/dist/schema/walk-key.d.ts +24 -0
  107. package/dist/schema/walk-key.d.ts.map +1 -0
  108. package/dist/schema/walk-key.js +33 -0
  109. package/dist/schema/walk-key.js.map +1 -0
  110. package/dist/to.js +1 -1
  111. package/dist/to.js.map +1 -1
  112. package/dist/tool.d.ts +38 -4
  113. package/dist/tool.d.ts.map +1 -1
  114. package/dist/tool.js +52 -8
  115. package/dist/tool.js.map +1 -1
  116. package/dist/types.d.ts +265 -8
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/types.js +4 -1
  119. package/dist/types.js.map +1 -1
  120. package/package.json +2 -2
  121. package/dist/schema.d.ts +0 -840
  122. package/dist/schema.d.ts.map +0 -1
  123. package/dist/schema.js +0 -2478
  124. package/dist/schema.js.map +0 -1
package/dist/prompt.js CHANGED
@@ -1,13 +1,20 @@
1
1
  import Handlebars from "handlebars";
2
2
  import { ZodString } from 'zod';
3
3
  import { accumulateReasoning, accumulateUsage, extractJSONObject, getChunksFromResponse, getInputTokens, getModel, getOutputTokens, getTotalTokens, resolve, resolveFn, yieldAll } from "./common";
4
- import { ToolInterrupt, PromptSuspend } from "./tool";
4
+ import { forcesTool, ToolInterrupt, PromptSuspend } from "./tool";
5
5
  import { getDescriptorById, strictify, decodeWire } from "./schema";
6
+ import { OutputValidationError, PromptError, ReviewRejectedError, SchemaRejectedError, ToolExecutionError, ToolInputError } from "./errors";
7
+ import { SCHEMA_FALLBACKS, structuredOutput } from "./delivery";
6
8
  /** Default cap (chars) for validation error messages surfaced back to the
7
9
  * LLM. Read by `Prompt.truncateValidationError`; subclasses may override
8
10
  * the method to ignore this. Anything past `max` is replaced with a
9
11
  * `… (N more characters)` marker. */
10
12
  const DEFAULT_VALIDATION_ERROR_MAX_LENGTH = 4096;
13
+ /**
14
+ * The user message a prompt adds when it runs without a conversation.
15
+ * Override it with `userTurn` on the prompt or the context.
16
+ */
17
+ export const DEFAULT_USER_TURN = 'Begin.';
11
18
  /**
12
19
  * A Prompt component that generates AI responses based on input, context, and available tools.
13
20
  * Prompts orchestrate interactions with AI models, handle tool calls, and manage streaming responses.
@@ -262,7 +269,7 @@ export class Prompt {
262
269
  tools,
263
270
  responseFormat,
264
271
  };
265
- const fixedToolChoice = request.toolChoice && (request.toolChoice === 'required' || typeof request.toolChoice === 'object');
272
+ const fixedToolChoice = forcesTool(request.toolChoice);
266
273
  if (fixedToolChoice && (!tools || tools.length === 0)) {
267
274
  throw new Error(`Prompt ${this.input.name} is configured to require tools, but no tools are available.`);
268
275
  }
@@ -271,13 +278,26 @@ export class Prompt {
271
278
  // Pre-emptively trim context messages if we have a context window limit
272
279
  request.messages = this.forget(request, ctx);
273
280
  }
281
+ // Without a conversation the request still needs a user turn: APIs such
282
+ // as Anthropic's and Bedrock's reject a request that has none
283
+ if (request.messages.length === 1) {
284
+ request.messages.push({ role: 'user', content: this.input.userTurn ?? ctx.userTurn ?? DEFAULT_USER_TURN });
285
+ }
274
286
  let outputRetries = this.input.outputRetries ?? ctx.outputRetries ?? 2;
275
287
  let forgetRetries = this.input.forgetRetries ?? ctx.forgetRetries ?? 1;
276
288
  let toolIterations = this.input.toolIterations ?? 3;
277
289
  let toolRetries = this.input.toolRetries ?? ctx.toolRetries ?? 2;
290
+ let reviewRetries = this.input.reviewRetries ?? 1;
291
+ let reviews = 0;
278
292
  const toolsComplete = this.input.toolsComplete ?? true;
293
+ const schemaFallbacks = this.input.schemaFallbacks ?? ctx.schemaFallbacks ?? SCHEMA_FALLBACKS;
294
+ // Schema fallbacks taken this run, re-applied when a dynamic prompt
295
+ // re-resolves its request.
296
+ const fallbacksTaken = [];
279
297
  let result = undefined;
280
298
  let lastError = undefined;
299
+ // The last tool failure, the cause when the iterations run out.
300
+ let toolFailure = undefined;
281
301
  let completeText = '';
282
302
  // Runtime schema-delivery fallback guard. A model whose descriptor ALLOWS
283
303
  // the schema (so the static `canExpress` check passes and structured output
@@ -290,7 +310,7 @@ export class Prompt {
290
310
  // idempotent within a single prompt run — we switch at most once and never
291
311
  // loop. Per-run only; nothing is persisted across requests.
292
312
  let promotedToPromptDelivery = false;
293
- let maxIterations = outputRetries + forgetRetries + toolIterations + toolRetries + 1;
313
+ let maxIterations = outputRetries + forgetRetries + toolIterations + toolRetries + reviewRetries + 1;
294
314
  let requestUsageSent = false;
295
315
  let usage = undefined;
296
316
  let iterations = 0;
@@ -314,6 +334,30 @@ export class Prompt {
314
334
  request.messages.push(message);
315
335
  return emit({ type: 'message', message, request });
316
336
  };
337
+ // The events a tool execution is ready to emit, each emitted once.
338
+ const toolEvents = function* (te) {
339
+ const { tool, args } = te;
340
+ const toolCallId = te.toolCall.id;
341
+ if (te.emitRepairAttempt()) {
342
+ yield emit({ type: 'toolArgRepairAttempt', tool: tool, fields: te.repairAttempt.fields, success: te.repairAttempt.success, request });
343
+ }
344
+ if (te.emitStart()) {
345
+ yield emitTool({ type: 'toolStart', tool: tool, toolCallId, args, request });
346
+ }
347
+ if (te.emitOutput()) {
348
+ yield emitTool({ type: 'toolOutput', tool: tool, toolCallId, args, result: te.result, toModel: te.toModel, request });
349
+ }
350
+ if (te.emitInterrupt()) {
351
+ yield emitTool({ type: 'toolInterrupt', tool: tool, toolCallId, args, request });
352
+ }
353
+ if (te.emitSuspend()) {
354
+ yield emitTool({ type: 'toolSuspend', tool: tool, toolCallId, args, request });
355
+ }
356
+ if (te.emitError()) {
357
+ const failure = te.error;
358
+ yield emitTool({ type: 'toolError', tool: tool, toolCallId, args, error: failure.message, failure, rawArgs: failure instanceof ToolInputError ? failure.rawArgs : undefined, request });
359
+ }
360
+ };
317
361
  // Main execution loop!
318
362
  while (iterations < maxIterations) {
319
363
  // Honor the caller's abort signal at iteration boundaries — if a
@@ -377,6 +421,18 @@ export class Prompt {
377
421
  reasoning = accumulateReasoning(reasoning, chunk.reasoning);
378
422
  yield emit({ type: 'reasonPartial', reasoning, request });
379
423
  }
424
+ if (chunk.citations) {
425
+ yield emit({ type: 'citations', citations: chunk.citations, request });
426
+ }
427
+ if (chunk.images) {
428
+ yield emit({ type: 'images', images: chunk.images, request });
429
+ }
430
+ if (chunk.audio) {
431
+ yield emit({ type: 'audioPartial', audio: chunk.audio, request });
432
+ }
433
+ if (chunk.moderation) {
434
+ yield emit({ type: 'moderation', moderation: chunk.moderation, request });
435
+ }
380
436
  // Handle tool calls
381
437
  if (chunk.toolCallNamed) {
382
438
  const toolName = chunk.toolCallNamed.name;
@@ -405,7 +461,7 @@ export class Prompt {
405
461
  // value short-circuits, false/undefined proceeds, a throw
406
462
  // propagates through run()'s existing catch (interrupt/suspend/error).
407
463
  onToolIntercept && toolInfo
408
- ? (args) => onToolIntercept(toolInfo.tool, args, ctx)
464
+ ? (args, call) => onToolIntercept(toolInfo.tool, args, ctx, call)
409
465
  : undefined);
410
466
  toolExecutors.push(toolExecutor);
411
467
  toolExecutorMap.set(chunk.toolCallNamed.id, toolExecutor);
@@ -446,21 +502,7 @@ export class Prompt {
446
502
  // In immediate mode we might be getting more chunks while executing, emit events as soon as possible.
447
503
  if (toolMode === 'immediate') {
448
504
  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
- }
505
+ yield* toolEvents(toolCall);
464
506
  }
465
507
  }
466
508
  }
@@ -472,13 +514,13 @@ export class Prompt {
472
514
  // If the model refused to answer and stop
473
515
  if (finishReason === 'refusal' || refusal) {
474
516
  yield emit({ type: 'refusal', content: refusal || 'unspecified', request });
475
- lastError = refusal || 'Model refused to answer.';
517
+ lastError = new PromptError(refusal || 'Model refused to answer.', 'model');
476
518
  break;
477
519
  }
478
520
  // If the model was stopped due to content filtering
479
521
  if (finishReason === 'content_filter') {
480
522
  yield emit({ type: 'refusal', content: 'Content filtered by AI model', request });
481
- lastError = 'Model response was filtered due to content policy.';
523
+ lastError = new PromptError('Model response was filtered due to content policy.', 'model');
482
524
  break;
483
525
  }
484
526
  // If we sent too much, forget the past homie
@@ -492,7 +534,7 @@ export class Prompt {
492
534
  }
493
535
  else {
494
536
  // 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.';
537
+ lastError = new PromptError('Model indicated length finish reason but no token usage was provided so context cannot be trimmed.', 'model');
496
538
  break;
497
539
  }
498
540
  }
@@ -520,18 +562,19 @@ export class Prompt {
520
562
  // 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
563
  toolExecutor.parse();
522
564
  }
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
- }
565
+ yield* toolEvents(toolExecutor);
532
566
  }
533
- // The execution mode for this iteration.
567
+ // The execution mode for this iteration. Skipping leaves every valid
568
+ // sibling unrun, and its reply says so.
534
569
  const iterationMode = skip ? 'skip' : toolMode;
570
+ if (skip) {
571
+ for (const toolExecutor of toolExecutors) {
572
+ // Only a call that has not started: under 'immediate' a valid call may
573
+ // already be running, and its reply is its real outcome.
574
+ if (!toolExecutor.error && (toolExecutor.status === 'ready' || toolExecutor.status === 'parsed'))
575
+ toolExecutor.skipped = true;
576
+ }
577
+ }
535
578
  // All tool calls are valid, lets start this!
536
579
  switch (iterationMode) {
537
580
  case 'sequential':
@@ -547,25 +590,9 @@ export class Prompt {
547
590
  if (ctx.signal?.aborted)
548
591
  break;
549
592
  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
- }
593
+ yield* toolEvents(toolExecutor);
556
594
  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
- }
595
+ yield* toolEvents(toolExecutor);
569
596
  }
570
597
  break;
571
598
  case 'parallel':
@@ -582,25 +609,7 @@ export class Prompt {
582
609
  // listener was already removed before tool dispatch.)
583
610
  if (ctx.signal?.aborted)
584
611
  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
- }
612
+ yield* toolEvents(await toolCallPromise);
604
613
  }
605
614
  break;
606
615
  }
@@ -644,7 +653,7 @@ export class Prompt {
644
653
  // exactly as before: strings verbatim, everything else JSON.
645
654
  const modelValue = toolExecutor.status === 'success' ? toolExecutor.toModel : toolExecutor.result;
646
655
  const content = hasError
647
- ? toolExecutor.error
656
+ ? toolExecutor.error.message
648
657
  : hasResult
649
658
  ? typeof modelValue === 'string'
650
659
  ? modelValue
@@ -654,7 +663,9 @@ export class Prompt {
654
663
  role: 'tool',
655
664
  content,
656
665
  toolCallId: toolExecutor.toolCall.id,
666
+ failure: toolExecutor.error?.kind,
657
667
  });
668
+ toolFailure = toolExecutor.error ?? toolFailure;
658
669
  if (toolExecutor.status === 'invalid') {
659
670
  toolParseErrors++;
660
671
  }
@@ -718,9 +729,9 @@ export class Prompt {
718
729
  toolExecutors.some(toolExecutor => toolExecutor.status === 'interrupted');
719
730
  // If we are finished, parse the output
720
731
  if (stop) {
732
+ let failure;
721
733
  if (!schema || (schema instanceof ZodString)) {
722
734
  result = content;
723
- break; // All good!
724
735
  }
725
736
  else {
726
737
  // Grab the JSON part from the content just in case. In the
@@ -728,9 +739,10 @@ export class Prompt {
728
739
  // freeform text (possibly fenced / prose-wrapped), so extract the
729
740
  // outermost balanced `{…}` (see `extractJSONObject`).
730
741
  const potentialJSON = extractJSONObject(content);
731
- let errorMessage = '';
732
- let resetReason = '';
733
- const errMax = this.input.validationErrorMaxLength;
742
+ const invalid = (message, reason) => new OutputValidationError(this.truncateValidationError(message, this.input.validationErrorMaxLength), reason, content);
743
+ // The descriptor the provider shaped the output with, when the
744
+ // schema went on the wire.
745
+ const descriptorId = structuredOutput(request.responseFormat)?.descriptor;
734
746
  try {
735
747
  const parsedJSON = JSON.parse(potentialJSON);
736
748
  if (this.input.parse) {
@@ -749,11 +761,8 @@ export class Prompt {
749
761
  // (no Zod vocabulary — Zod never ran). Absent ⇒ unchanged.
750
762
  // Decode only when a descriptor was pinned; otherwise the wire
751
763
  // 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))
764
+ const decoded = descriptorId
765
+ ? decodeWire(schema, parsedJSON, getDescriptorById(descriptorId))
757
766
  : parsedJSON;
758
767
  let customResult;
759
768
  try {
@@ -763,8 +772,7 @@ export class Prompt {
763
772
  customResult = customError instanceof Error ? customError : new Error(String(customError));
764
773
  }
765
774
  if (customResult instanceof Error) {
766
- errorMessage = this.truncateValidationError(customResult.message, errMax);
767
- resetReason = 'schema-parsing';
775
+ failure = invalid(customResult.message, 'schema-parsing');
768
776
  }
769
777
  else {
770
778
  result = customResult;
@@ -772,8 +780,7 @@ export class Prompt {
772
780
  await this.input.validate?.(result, ctx);
773
781
  }
774
782
  catch (validationError) {
775
- errorMessage = this.truncateValidationError(`The output failed validation:\n${validationError.message}`, errMax);
776
- resetReason = 'validation';
783
+ failure = invalid(`The output failed validation:\n${validationError.message}`, 'validation');
777
784
  }
778
785
  }
779
786
  }
@@ -783,19 +790,15 @@ export class Prompt {
783
790
  // normalize back into the natural Zod shape before validation.
784
791
  // The descriptor was pinned on the response format when the
785
792
  // 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))
793
+ const validationSchema = descriptorId
794
+ ? strictify(schema, getDescriptorById(descriptorId))
791
795
  : schema;
792
796
  const parsedSafe = await validationSchema.safeParseAsync(parsedJSON);
793
797
  if (!parsedSafe.success) {
794
798
  const issueSummary = parsedSafe.error.issues
795
799
  .map(i => `- ${i.path.join('.')}: ${i.message}${['string', 'boolean', 'number'].includes(typeof i.input) ? ` (input: ${i.input})` : ''}`)
796
800
  .join('\n');
797
- errorMessage = this.truncateValidationError(`The output was an invalid format:\n${issueSummary}`, errMax);
798
- resetReason = 'schema-parsing';
801
+ failure = invalid(`The output was an invalid format:\n${issueSummary}`, 'schema-parsing');
799
802
  }
800
803
  else {
801
804
  result = parsedSafe.data;
@@ -803,15 +806,13 @@ export class Prompt {
803
806
  await this.input.validate?.(result, ctx);
804
807
  }
805
808
  catch (validationError) {
806
- errorMessage = this.truncateValidationError(`The output failed validation:\n${validationError.message}`, errMax);
807
- resetReason = 'validation';
809
+ failure = invalid(`The output failed validation:\n${validationError.message}`, 'validation');
808
810
  }
809
811
  }
810
812
  }
811
813
  }
812
814
  catch (parseError) {
813
- errorMessage = this.truncateValidationError(`The output was not valid JSON:\n${parseError.message}`, errMax);
814
- resetReason = 'json-parsing';
815
+ failure = invalid(`The output was not valid JSON:\n${parseError.message}`, 'json-parsing');
815
816
  }
816
817
  // Runtime schema-delivery fallback. The structured response was EMPTY
817
818
  // or its JSON was UNPARSEABLE (`json-parsing` reset — either the
@@ -830,39 +831,73 @@ export class Prompt {
830
831
  // The switch is not remembered beyond this run. This reuses the
831
832
  // existing outputRetries re-prompt mechanism below to re-issue.
832
833
  if (this.input.runtimeSchemaFallback !== false &&
833
- resetReason === 'json-parsing' &&
834
+ failure?.reason === 'json-parsing' &&
834
835
  !promotedToPromptDelivery &&
835
836
  typeof request.responseFormat === 'object' &&
836
837
  (request.responseFormat.schemaDelivery ?? 'auto') === 'auto') {
837
838
  request.responseFormat.schemaDelivery = 'prompt';
838
839
  promotedToPromptDelivery = true;
839
840
  }
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
- }
841
+ }
842
+ if (failure) {
843
+ result = undefined;
844
+ if (outputRetries > 0) {
845
+ outputRetries--;
846
+ yield emit({ type: 'textReset', reason: failure.reason, error: failure, request });
847
+ yield emitMessage({
848
+ role: 'user',
849
+ content: failure.message,
850
+ });
853
851
  }
854
852
  else {
855
- // A result was successfully parsed and validated!
853
+ lastError = failure;
854
+ break; // No more retries left
855
+ }
856
+ }
857
+ else if (this.input.review) {
858
+ // A valid result goes to the review, whose feedback asks the model
859
+ // to answer again.
860
+ const output = result;
861
+ const attempt = ++reviews;
862
+ yield emit({ type: 'reviewStart', output, attempt, request });
863
+ const verdict = await this.input.review(output, { input, request, ctx, attempt });
864
+ if ('pass' in verdict) {
865
+ yield emit({ type: 'reviewPass', output, attempt, request });
856
866
  lastError = undefined;
857
867
  break;
858
868
  }
869
+ yield emit({ type: 'reviewFeedback', output, feedback: verdict.feedback, attempt, request });
870
+ result = undefined;
871
+ if (reviewRetries <= 0) {
872
+ lastError = new ReviewRejectedError(output, verdict.feedback);
873
+ break;
874
+ }
875
+ reviewRetries--;
876
+ yield emitMessage({ role: 'user', content: verdict.feedback });
877
+ }
878
+ else {
879
+ // A result was successfully parsed and validated!
880
+ lastError = undefined;
881
+ break;
859
882
  }
860
883
  }
861
884
  }
862
885
  catch (requestError) {
863
- if (!ctx.signal?.aborted &&
864
- !streamController.signal.aborted &&
865
- this.input.runtimeSchemaFallback !== false &&
886
+ const recoverable = !ctx.signal?.aborted && !streamController.signal.aborted && this.input.runtimeSchemaFallback !== false;
887
+ // A classified schema rejection takes the first schema fallback not
888
+ // yet taken for its schema that applies, and the request goes again.
889
+ // The step stays on the request, so later iterations start there.
890
+ const rejected = requestError instanceof SchemaRejectedError ? requestError : undefined;
891
+ const fallback = rejected && recoverable
892
+ ? schemaFallbacks.find(step => !fallbacksTaken.some(([taken, target]) => taken === step && sameTarget(target, rejected.target)) && step.apply(request, rejected.target))
893
+ : undefined;
894
+ if (rejected && fallback) {
895
+ fallbacksTaken.push([fallback, rejected.target]);
896
+ yield emit({ type: 'schemaFallback', fallback: fallback.name, error: rejected, request });
897
+ continue;
898
+ }
899
+ if (!rejected &&
900
+ recoverable &&
866
901
  !promotedToPromptDelivery &&
867
902
  typeof request.responseFormat === 'object' &&
868
903
  (request.responseFormat.schemaDelivery ?? 'auto') === 'auto' &&
@@ -943,6 +978,7 @@ export class Prompt {
943
978
  systemMessage.content = dynamicContent;
944
979
  request.tools = dynamicTools;
945
980
  request.responseFormat = dynamicResponseFormat;
981
+ fallbacksTaken.forEach(([step, target]) => step.apply(request, target));
946
982
  // Update toolMap with new tool objects
947
983
  toolMap.clear();
948
984
  if (dynamicToolObjects) {
@@ -987,13 +1023,11 @@ export class Prompt {
987
1023
  if (ctx.signal?.aborted) {
988
1024
  return undefined;
989
1025
  }
990
- if (!lastError && iterations === maxIterations) {
991
- lastError = `Maximum iterations (${maxIterations}) reached without a valid response.`;
992
- }
993
- if (!lastError) {
994
- lastError = `Prompt ${this.input.name} failed without a specified error.`;
995
- }
996
- throw new Error(`Prompt ${this.input.name} failed: ${lastError}`);
1026
+ const reason = lastError?.message ?? (iterations === maxIterations
1027
+ ? `Maximum iterations (${maxIterations}) reached without a valid response.`
1028
+ : `Prompt ${this.input.name} failed without a specified error.`);
1029
+ const cause = lastError ?? toolFailure;
1030
+ throw new PromptError(`Prompt ${this.input.name} failed: ${reason}`, cause?.kind ?? 'model', cause);
997
1031
  }
998
1032
  yield emit({ type: 'complete', output: result, request });
999
1033
  return result;
@@ -1057,19 +1091,16 @@ export class Prompt {
1057
1091
  .filter(r => r.applicable)
1058
1092
  .map(r => r.tool);
1059
1093
  }
1060
- const toolInstructions = selectedTools && selectedTools.length > 0
1061
- ? (await Promise.all(selectedTools.map(t => t.compile(ctx)))).filter(t => !!t)
1062
- : undefined;
1063
- const instructions = toolInstructions
1064
- ? toolInstructions.map(t => t[0]).join("\n\n")
1094
+ // Each tool with its compiled instructions and definition; a tool that
1095
+ // doesn't compile (no schema) is left out, and one without instructions
1096
+ // adds nothing to the tools section
1097
+ const compiled = selectedTools && selectedTools.length > 0
1098
+ ? (await Promise.all(selectedTools.map(async (tool) => ({ tool, compiled: await tool.compile(ctx) }))))
1099
+ .flatMap(({ tool, compiled }) => compiled ? [{ tool, instructions: compiled[0], definition: compiled[1] }] : [])
1065
1100
  : undefined;
1066
- const tools = toolInstructions
1067
- ? toolInstructions.map(t => t[1])
1068
- : undefined;
1069
- // Create toolObjects as array of { tool, definition } pairs
1070
- const toolObjects = selectedTools && toolInstructions
1071
- ? selectedTools.map((tool, i) => ({ tool, definition: toolInstructions[i][1] }))
1072
- : [];
1101
+ const instructions = compiled?.map(c => c.instructions).filter(Boolean).join("\n\n");
1102
+ const tools = compiled?.map(c => c.definition);
1103
+ const toolObjects = compiled?.map(({ tool, definition }) => ({ tool, definition })) ?? [];
1073
1104
  // Compute the input that is fed to the prompt's prompt content
1074
1105
  let contentInput = input;
1075
1106
  const translated = await this.translate(input, ctx);
@@ -1128,7 +1159,8 @@ export class Prompt {
1128
1159
  * `ToolInterrupt` cutting a parallel batch short) or a tool errored
1129
1160
  * before its content could be accumulated.
1130
1161
  *
1131
- * The marker prefix (`[aborted: …]`, `[interrupted]`, `[error: …]`)
1162
+ * The marker prefix (`[aborted: …]`, `[interrupted]`, `[error: …]`,
1163
+ * `[not run: …]` for a valid call skipped because a sibling was invalid)
1132
1164
  * gives the model a clear cue and lets callers detect synthesized
1133
1165
  * replies by inspecting the message content if they need to
1134
1166
  * differentiate. Suspended tools are intentionally NOT routed
@@ -1140,15 +1172,18 @@ export class Prompt {
1140
1172
  * result, or fall back to a model-specific instruction string.
1141
1173
  */
1142
1174
  synthesizeUnpairedResult(te) {
1175
+ if (te.skipped) {
1176
+ return '[not run: another call in this batch was invalid; call it again]';
1177
+ }
1143
1178
  switch (te.status) {
1144
1179
  case 'interrupted':
1145
1180
  return te.error
1146
- ? `[interrupted: ${te.error}]`
1181
+ ? `[interrupted: ${te.error.message}]`
1147
1182
  : '[interrupted]';
1148
1183
  case 'error':
1149
1184
  case 'invalid':
1150
1185
  return te.error
1151
- ? `[error: ${te.error}]`
1186
+ ? `[error: ${te.error.message}]`
1152
1187
  : '[error]';
1153
1188
  case 'ready':
1154
1189
  case 'parsed':
@@ -1173,6 +1208,16 @@ export class Prompt {
1173
1208
  * 2. A token estimation function is provided in the context (we can estimate token counts)
1174
1209
  * 3. Messages already have token counts assigned (we can use these directly)
1175
1210
  *
1211
+ * What is removed, oldest first, is whole groups: a message together with
1212
+ * the `role: 'tool'` replies that follow it, so an assistant's
1213
+ * `tool_calls` never lose their replies (providers reject that history).
1214
+ * System messages and the last user message are always kept. Groups after
1215
+ * the last user message (an agent's tool loop) are removed too when the
1216
+ * earlier history is not enough, except the most recent group.
1217
+ *
1218
+ * `protected` so a subclass can replace it, for example to summarize the
1219
+ * removed history instead of dropping it.
1220
+ *
1176
1221
  * @param request - The original request with messages.
1177
1222
  * @param ctx - The context containing message history and token estimation.
1178
1223
  * @param usage - The current token usage.
@@ -1249,30 +1294,45 @@ export class Prompt {
1249
1294
  return request.messages;
1250
1295
  }
1251
1296
  const removeTokens = totalMessageTokensFinal - maxInput;
1252
- // Calculate where to start trimming and where to stop
1253
- const messageMinIndex = request.messages.findIndex(m => m.role === 'system') + 1; // inclusive
1254
- let messageMaxIndex = request.messages.findLastIndex(m => m.role === 'user'); // exclusive
1255
- if (messageMaxIndex === -1) {
1256
- messageMaxIndex = request.messages.length;
1257
- }
1258
- const trimmedMessages = request.messages.slice(0, messageMinIndex);
1259
- let removesRemaining = removeTokens;
1260
- let messageIndex = messageMinIndex;
1261
- while (removesRemaining > 0 && messageIndex < messageMaxIndex) {
1262
- const message = request.messages[messageIndex];
1263
- if (message.role === 'system') {
1264
- trimmedMessages.push(message);
1265
- messageIndex++;
1297
+ // Group the history: each message with the tool replies that follow it.
1298
+ const messages = request.messages;
1299
+ const groups = [];
1300
+ for (let i = 0; i < messages.length; i++) {
1301
+ const last = groups[groups.length - 1];
1302
+ if (messages[i].role === 'tool' && last) {
1303
+ last.end = i + 1;
1266
1304
  }
1267
1305
  else {
1268
- removesRemaining -= messageTokens[messageIndex] || 0;
1269
- messageIndex++;
1306
+ groups.push({ start: i, end: i + 1 });
1270
1307
  }
1271
1308
  }
1272
- trimmedMessages.push(...request.messages.slice(messageIndex));
1273
- return trimmedMessages;
1309
+ const lastUser = messages.findLastIndex(m => m.role === 'user');
1310
+ const finalGroup = groups[groups.length - 1];
1311
+ const removable = (group) => {
1312
+ const first = messages[group.start];
1313
+ return first.role !== 'system' && group.start !== lastUser && group !== finalGroup;
1314
+ };
1315
+ // Oldest first: the history before the last user message, then the tool loop after it.
1316
+ const order = groups.filter(removable);
1317
+ const removed = new Set();
1318
+ let removesRemaining = removeTokens;
1319
+ for (const group of order) {
1320
+ if (removesRemaining <= 0)
1321
+ break;
1322
+ for (let i = group.start; i < group.end; i++) {
1323
+ removed.add(i);
1324
+ removesRemaining -= messageTokens[i] || 0;
1325
+ }
1326
+ }
1327
+ return messages.filter((_, i) => !removed.has(i));
1274
1328
  }
1275
1329
  }
1330
+ /**
1331
+ * Whether two schema targets name the same schema.
1332
+ */
1333
+ function sameTarget(a, b) {
1334
+ return a.kind === 'tool' && b.kind === 'tool' ? a.name === b.name : a.kind === b.kind;
1335
+ }
1276
1336
  function once(fn) {
1277
1337
  let promise;
1278
1338
  return () => {
@@ -1313,7 +1373,7 @@ truncate,
1313
1373
  present,
1314
1374
  // Optional per-prompt tool interceptor (PromptInput.onToolIntercept),
1315
1375
  // 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
1376
+ // function only needs the parsed args and the tool call. Runs INSIDE run() BEFORE the tool's
1317
1377
  // own handler. A returned VALUE short-circuits and becomes `result` (the
1318
1378
  // handler is skipped, then the normal present/success path runs). Returning
1319
1379
  // `false`/`undefined` means "not intercepted" — the real handler runs. A
@@ -1336,7 +1396,7 @@ intercept) {
1336
1396
  tool: toolInfo?.tool,
1337
1397
  definition: toolInfo?.definition,
1338
1398
  status: toolInfo ? 'ready' : 'error',
1339
- error: toolInfo ? undefined : `Tool not found: ${toolCall.name}`,
1399
+ error: toolInfo ? undefined : new ToolInputError(toolCall.name, `Tool not found: ${toolCall.name}`),
1340
1400
  emitStart: start.emit,
1341
1401
  emitOutput: output.emit,
1342
1402
  emitError: error.emit,
@@ -1367,10 +1427,6 @@ intercept) {
1367
1427
  }
1368
1428
  catch (e) {
1369
1429
  execution.status = 'invalid';
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
1430
  // Distinguish "model sent no arguments at all" from "model sent
1375
1431
  // bad arguments". The former is usually a streaming-relay issue
1376
1432
  // (OpenRouter/Anthropic) or a genuine model gaffe — calling it
@@ -1382,9 +1438,9 @@ intercept) {
1382
1438
  ? `the tool was called with NO arguments. The schema requires arguments — re-call this tool with the required fields populated. Validation: ${e.message}`
1383
1439
  : `${e.message}, args: ${args}`;
1384
1440
  const formatted = `Error parsing tool arguments: ${reason}`;
1385
- execution.error = truncate
1386
- ? truncate(formatted, validationErrorMaxLength)
1387
- : formatted;
1441
+ // The untruncated raw args go alongside the truncated model-facing
1442
+ // message so the diagnostic log gets the full payload.
1443
+ execution.error = new ToolInputError(toolCall.name, truncate ? truncate(formatted, validationErrorMaxLength) : formatted, args);
1388
1444
  error.ready = true;
1389
1445
  }
1390
1446
  return execution;
@@ -1403,7 +1459,7 @@ intercept) {
1403
1459
  // is intentionally NOT caught here — it flows to the catch below and
1404
1460
  // is routed exactly like a throw from the handler (ToolInterrupt ⇒
1405
1461
  // interrupted, PromptSuspend ⇒ suspended, else ⇒ tool error).
1406
- const intercepted = intercept ? await intercept(execution.args) : false;
1462
+ const intercepted = intercept ? await intercept(execution.args, execution.toolCall) : false;
1407
1463
  execution.result = intercepted === false || intercepted === undefined
1408
1464
  ? await resolve(toolInfo.tool.run(execution.args, { ...ctx, toolCallId: toolCall.id }))
1409
1465
  : intercepted;
@@ -1420,7 +1476,7 @@ intercept) {
1420
1476
  }
1421
1477
  catch (e) {
1422
1478
  execution.status = 'error';
1423
- execution.error = `Error transforming tool result: ${e.message}`;
1479
+ execution.error = new ToolExecutionError(toolCall.name, `Error transforming tool result: ${e.message}`, e);
1424
1480
  error.ready = true;
1425
1481
  return execution;
1426
1482
  }
@@ -1438,7 +1494,7 @@ intercept) {
1438
1494
  }
1439
1495
  else {
1440
1496
  execution.status = 'error';
1441
- execution.error = `Error executing tool: ${e.message}, args: ${JSON.stringify(execution.args)}`;
1497
+ execution.error = new ToolExecutionError(toolCall.name, `Error executing tool: ${e.message}, args: ${JSON.stringify(execution.args)}`, e);
1442
1498
  error.ready = true;
1443
1499
  }
1444
1500
  }