@falai/agent 2.4.1 → 2.4.3

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 (108) hide show
  1. package/dist/cjs/core/PersistenceManager.d.ts +13 -0
  2. package/dist/cjs/core/PersistenceManager.d.ts.map +1 -1
  3. package/dist/cjs/core/PersistenceManager.js +29 -6
  4. package/dist/cjs/core/PersistenceManager.js.map +1 -1
  5. package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
  6. package/dist/cjs/core/ResponseModal.js +15 -3
  7. package/dist/cjs/core/ResponseModal.js.map +1 -1
  8. package/dist/cjs/core/SessionManager.d.ts.map +1 -1
  9. package/dist/cjs/core/SessionManager.js +7 -0
  10. package/dist/cjs/core/SessionManager.js.map +1 -1
  11. package/dist/cjs/core/SignalProcessor.d.ts.map +1 -1
  12. package/dist/cjs/core/SignalProcessor.js +28 -11
  13. package/dist/cjs/core/SignalProcessor.js.map +1 -1
  14. package/dist/cjs/core/StreamingToolExecutor.d.ts.map +1 -1
  15. package/dist/cjs/core/StreamingToolExecutor.js +11 -0
  16. package/dist/cjs/core/StreamingToolExecutor.js.map +1 -1
  17. package/dist/cjs/core/ToolLoopExecutor.d.ts +16 -2
  18. package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
  19. package/dist/cjs/core/ToolLoopExecutor.js +115 -54
  20. package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
  21. package/dist/cjs/core/ToolManager.d.ts.map +1 -1
  22. package/dist/cjs/core/ToolManager.js +8 -31
  23. package/dist/cjs/core/ToolManager.js.map +1 -1
  24. package/dist/cjs/core/toolGates.d.ts +24 -0
  25. package/dist/cjs/core/toolGates.d.ts.map +1 -0
  26. package/dist/cjs/core/toolGates.js +52 -0
  27. package/dist/cjs/core/toolGates.js.map +1 -0
  28. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  29. package/dist/cjs/providers/AnthropicProvider.js +24 -3
  30. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  31. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  32. package/dist/cjs/providers/GeminiProvider.js +24 -3
  33. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  34. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  35. package/dist/cjs/providers/OpenAICompatibleProvider.js +25 -3
  36. package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
  37. package/dist/cjs/types/signals.d.ts +6 -0
  38. package/dist/cjs/types/signals.d.ts.map +1 -1
  39. package/dist/cjs/utils/index.d.ts +2 -2
  40. package/dist/cjs/utils/index.d.ts.map +1 -1
  41. package/dist/cjs/utils/index.js +2 -1
  42. package/dist/cjs/utils/index.js.map +1 -1
  43. package/dist/cjs/utils/retry.d.ts +21 -0
  44. package/dist/cjs/utils/retry.d.ts.map +1 -1
  45. package/dist/cjs/utils/retry.js +37 -0
  46. package/dist/cjs/utils/retry.js.map +1 -1
  47. package/dist/core/PersistenceManager.d.ts +13 -0
  48. package/dist/core/PersistenceManager.d.ts.map +1 -1
  49. package/dist/core/PersistenceManager.js +29 -6
  50. package/dist/core/PersistenceManager.js.map +1 -1
  51. package/dist/core/ResponseModal.d.ts.map +1 -1
  52. package/dist/core/ResponseModal.js +15 -3
  53. package/dist/core/ResponseModal.js.map +1 -1
  54. package/dist/core/SessionManager.d.ts.map +1 -1
  55. package/dist/core/SessionManager.js +7 -0
  56. package/dist/core/SessionManager.js.map +1 -1
  57. package/dist/core/SignalProcessor.d.ts.map +1 -1
  58. package/dist/core/SignalProcessor.js +28 -11
  59. package/dist/core/SignalProcessor.js.map +1 -1
  60. package/dist/core/StreamingToolExecutor.d.ts.map +1 -1
  61. package/dist/core/StreamingToolExecutor.js +11 -0
  62. package/dist/core/StreamingToolExecutor.js.map +1 -1
  63. package/dist/core/ToolLoopExecutor.d.ts +16 -2
  64. package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
  65. package/dist/core/ToolLoopExecutor.js +115 -54
  66. package/dist/core/ToolLoopExecutor.js.map +1 -1
  67. package/dist/core/ToolManager.d.ts.map +1 -1
  68. package/dist/core/ToolManager.js +8 -31
  69. package/dist/core/ToolManager.js.map +1 -1
  70. package/dist/core/toolGates.d.ts +24 -0
  71. package/dist/core/toolGates.d.ts.map +1 -0
  72. package/dist/core/toolGates.js +49 -0
  73. package/dist/core/toolGates.js.map +1 -0
  74. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  75. package/dist/providers/AnthropicProvider.js +25 -4
  76. package/dist/providers/AnthropicProvider.js.map +1 -1
  77. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  78. package/dist/providers/GeminiProvider.js +25 -4
  79. package/dist/providers/GeminiProvider.js.map +1 -1
  80. package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  81. package/dist/providers/OpenAICompatibleProvider.js +26 -4
  82. package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
  83. package/dist/types/signals.d.ts +6 -0
  84. package/dist/types/signals.d.ts.map +1 -1
  85. package/dist/utils/index.d.ts +2 -2
  86. package/dist/utils/index.d.ts.map +1 -1
  87. package/dist/utils/index.js +1 -1
  88. package/dist/utils/index.js.map +1 -1
  89. package/dist/utils/retry.d.ts +21 -0
  90. package/dist/utils/retry.d.ts.map +1 -1
  91. package/dist/utils/retry.js +36 -0
  92. package/dist/utils/retry.js.map +1 -1
  93. package/docs/reference/signals.md +3 -0
  94. package/package.json +1 -1
  95. package/src/core/PersistenceManager.ts +30 -6
  96. package/src/core/ResponseModal.ts +16 -3
  97. package/src/core/SessionManager.ts +7 -0
  98. package/src/core/SignalProcessor.ts +34 -11
  99. package/src/core/StreamingToolExecutor.ts +12 -0
  100. package/src/core/ToolLoopExecutor.ts +133 -58
  101. package/src/core/ToolManager.ts +8 -32
  102. package/src/core/toolGates.ts +72 -0
  103. package/src/providers/AnthropicProvider.ts +35 -8
  104. package/src/providers/GeminiProvider.ts +37 -4
  105. package/src/providers/OpenAICompatibleProvider.ts +36 -8
  106. package/src/types/signals.ts +6 -0
  107. package/src/utils/index.ts +2 -2
  108. package/src/utils/retry.ts +55 -0
@@ -24,6 +24,7 @@ import type { Agent } from "./Agent";
24
24
  import { Flow } from "./Flow";
25
25
  import { Step } from "./Step";
26
26
  import { StreamingToolExecutor } from "./StreamingToolExecutor";
27
+ import { evaluateToolGates } from "./toolGates";
27
28
 
28
29
  /**
29
30
  * Error thrown when tool creation fails
@@ -715,38 +716,13 @@ export class ToolManager<TContext = unknown, TData = unknown> {
715
716
 
716
717
  logger.debug(`[ToolManager] Executing tool: ${tool.id} with args:`, toolArguments);
717
718
 
718
- // Tool validation gate (Req 6.1, 6.7)
719
- if (typeof tool.validateInput === 'function' && toolArguments) {
720
- const validation = await tool.validateInput(toolArguments, toolContext);
721
- if (!validation.valid) {
722
- const executionTime = Date.now() - startTime;
723
- logger.warn(`[DataValidationError] Tool "${tool.id}" input validation failed: ${validation.error}. Fix the tool call arguments to match the expected schema.`);
724
- return {
725
- success: false,
726
- error: `Validation failed: ${validation.error || 'Invalid input'}`,
727
- metadata: { toolId: tool.id, executionTime, gate: 'validateInput' }
728
- };
729
- }
730
- }
731
-
732
- // Tool permission gate (Req 6.7, 6.8)
733
- // When denied: do not invoke handler, do not process directives, do not apply state writes
734
- if (typeof tool.checkPermissions === 'function' && toolArguments) {
735
- const permission = await tool.checkPermissions(toolArguments, toolContext);
736
- if (!permission.allowed) {
737
- const executionTime = Date.now() - startTime;
738
- logger.warn(`[ToolExecutionError] Tool "${tool.id}" permission denied: ${permission.reason}. The tool's checkPermissions hook rejected this call.`);
739
- return {
740
- success: false,
741
- error: `Permission denied: ${permission.reason || 'Not allowed'}`,
742
- metadata: {
743
- toolId: tool.id,
744
- executionTime,
745
- gate: 'checkPermissions',
746
- canOverride: permission.canOverride
747
- }
748
- };
749
- }
719
+ // Pre-execution gates (validateInput → checkPermissions). Shared with the
720
+ // streaming executor (StreamingToolExecutor) so authorization/validation
721
+ // behave identically on every transport. When a gate denies, the handler
722
+ // is NOT invoked.
723
+ const gateDenial = await evaluateToolGates(tool, toolArguments, toolContext, startTime);
724
+ if (gateDenial) {
725
+ return gateDenial;
750
726
  }
751
727
 
752
728
  // Execute tool with timeout protection
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared pre-execution gates for tool calls.
3
+ *
4
+ * A tool's `validateInput` and `checkPermissions` hooks must be applied
5
+ * identically regardless of how the call is executed. Previously the gates
6
+ * lived only in `ToolManager.executeTool` (the sequential `generate()`/
7
+ * `respond()` path), so the streaming path (`StreamingToolExecutor`, used by
8
+ * `stream()`) invoked handlers without them — a documented authorization gate
9
+ * ("when denied, handler is NOT invoked") was silently a no-op on one
10
+ * transport. Centralizing the logic here keeps both executors in lockstep.
11
+ */
12
+ import type { Tool, ToolContext, ToolExecutionResult } from "../types/tool";
13
+ import { logger } from "../utils";
14
+
15
+ /**
16
+ * Run a tool's pre-execution gates in order: `validateInput`, then
17
+ * `checkPermissions`. Returns a failed `ToolExecutionResult` when a gate
18
+ * blocks the call (the handler must NOT be invoked), or `null` when both
19
+ * gates pass.
20
+ *
21
+ * @param startTime - optional execution start time (ms from `Date.now()`).
22
+ * When provided, denial metadata includes `executionTime`, matching the
23
+ * sequential executor's result shape; omit it on the streaming path.
24
+ */
25
+ export async function evaluateToolGates<TContext, TData>(
26
+ tool: Tool<TContext, TData>,
27
+ toolArguments: Record<string, unknown> | undefined,
28
+ toolContext: ToolContext<TContext, TData>,
29
+ startTime?: number
30
+ ): Promise<ToolExecutionResult | null> {
31
+ // Tool validation gate (Req 6.1, 6.7)
32
+ if (typeof tool.validateInput === "function" && toolArguments) {
33
+ const validation = await tool.validateInput(toolArguments, toolContext);
34
+ if (!validation.valid) {
35
+ logger.warn(
36
+ `[DataValidationError] Tool "${tool.id}" input validation failed: ${validation.error}. Fix the tool call arguments to match the expected schema.`
37
+ );
38
+ return {
39
+ success: false,
40
+ error: `Validation failed: ${validation.error || "Invalid input"}`,
41
+ metadata: {
42
+ toolId: tool.id,
43
+ ...(startTime !== undefined ? { executionTime: Date.now() - startTime } : {}),
44
+ gate: "validateInput",
45
+ },
46
+ };
47
+ }
48
+ }
49
+
50
+ // Tool permission gate (Req 6.7, 6.8)
51
+ // When denied: do not invoke handler, do not process directives, do not apply state writes
52
+ if (typeof tool.checkPermissions === "function" && toolArguments) {
53
+ const permission = await tool.checkPermissions(toolArguments, toolContext);
54
+ if (!permission.allowed) {
55
+ logger.warn(
56
+ `[ToolExecutionError] Tool "${tool.id}" permission denied: ${permission.reason}. The tool's checkPermissions hook rejected this call.`
57
+ );
58
+ return {
59
+ success: false,
60
+ error: `Permission denied: ${permission.reason || "Not allowed"}`,
61
+ metadata: {
62
+ toolId: tool.id,
63
+ ...(startTime !== undefined ? { executionTime: Date.now() - startTime } : {}),
64
+ gate: "checkPermissions",
65
+ canOverride: permission.canOverride,
66
+ },
67
+ };
68
+ }
69
+ }
70
+
71
+ return null;
72
+ }
@@ -17,7 +17,7 @@ import type {
17
17
  } from "../types";
18
18
  import type { ProviderCapabilities } from "../types/ai";
19
19
  import type { HistoryItem } from "../types/history";
20
- import { withTimeoutAndRetry, logger } from "../utils";
20
+ import { withTimeoutAndRetry, withStreamRetry, logger } from "../utils";
21
21
  import {
22
22
  classifyProviderError,
23
23
  getErrorMessage,
@@ -106,8 +106,12 @@ export class AnthropicProvider implements AiProvider {
106
106
  this.backupModels = backupModels;
107
107
  this.config = config;
108
108
  this.retryConfig = {
109
+ // `||` is intentional: a 0ms timeout is degenerate (aborts every call
110
+ // immediately), so fall back to the default.
109
111
  timeout: retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
110
- retries: retryConfig?.retries || DEFAULT_RETRY_CONFIG.retries,
112
+ // `??` so an explicit `retries: 0` (disable retries) is honored instead of
113
+ // being clobbered to the default by a falsy-zero check.
114
+ retries: retryConfig?.retries ?? DEFAULT_RETRY_CONFIG.retries,
111
115
  };
112
116
  }
113
117
 
@@ -377,6 +381,17 @@ export class AnthropicProvider implements AiProvider {
377
381
  } as AgentStructuredResponse;
378
382
  }
379
383
 
384
+ // A parsed-but-blank structured message with no tool calls is just as
385
+ // empty as no text at all — throw so withTimeoutAndRetry retries instead
386
+ // of returning {"message":""}.
387
+ if (
388
+ toolCalls.length === 0 &&
389
+ typeof structured?.message === "string" &&
390
+ !structured.message.trim()
391
+ ) {
392
+ throw new Error("No response from Anthropic");
393
+ }
394
+
380
395
  return {
381
396
  message,
382
397
  metadata: {
@@ -407,9 +422,9 @@ export class AnthropicProvider implements AiProvider {
407
422
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
408
423
  // Try primary model first
409
424
  try {
410
- yield* this.generateStreamWithModel<TContext, TStructured>(
411
- this.primaryModel,
412
- input
425
+ yield* withStreamRetry(
426
+ () => this.generateStreamWithModel<TContext, TStructured>(this.primaryModel, input),
427
+ { maxRetries: this.retryConfig.retries, operationName: `Anthropic ${this.primaryModel} stream` }
413
428
  );
414
429
  } catch (primaryError: unknown) {
415
430
  const primaryErrMsg = getErrorMessage(primaryError);
@@ -433,9 +448,9 @@ export class AnthropicProvider implements AiProvider {
433
448
  );
434
449
 
435
450
  try {
436
- yield* this.generateStreamWithModel<TContext, TStructured>(
437
- backupModel,
438
- input
451
+ yield* withStreamRetry(
452
+ () => this.generateStreamWithModel<TContext, TStructured>(backupModel, input),
453
+ { maxRetries: this.retryConfig.retries, operationName: `Anthropic ${backupModel} stream` }
439
454
  );
440
455
  logger.debug(`[ANTHROPIC] Backup model ${backupModel} succeeded`);
441
456
  return;
@@ -597,6 +612,18 @@ export class AnthropicProvider implements AiProvider {
597
612
  } as AgentStructuredResponse;
598
613
  }
599
614
 
615
+ // Empty-completion guard — mirror of the non-streaming path. A blank
616
+ // effective message (structured message under a schema, else accumulated
617
+ // text) with no tool calls means the model produced nothing usable; throw
618
+ // so withStreamRetry/generateStreamWithBackup retry instead of silently
619
+ // emitting an empty message.
620
+ const messageText = (
621
+ typeof structured?.message === "string" ? structured.message : accumulated
622
+ ).trim();
623
+ if (!messageText && toolCalls.length === 0) {
624
+ throw new Error("No response from Anthropic");
625
+ }
626
+
600
627
  // Yield final chunk
601
628
  yield {
602
629
  delta: "",
@@ -21,7 +21,7 @@ import type {
21
21
  } from "../types";
22
22
  import type { ProviderCapabilities } from "../types/ai";
23
23
  import type { HistoryItem } from "../types/history";
24
- import { withTimeoutAndRetry } from "../utils/retry";
24
+ import { withTimeoutAndRetry, withStreamRetry } from "../utils/retry";
25
25
  import { tryParseJSONResponse } from "../utils/json";
26
26
  import { logger } from "../utils/logger";
27
27
  import {
@@ -107,8 +107,12 @@ export class GeminiProvider implements AiProvider {
107
107
  this.backupModels = backupModels;
108
108
  this.config = config;
109
109
  this.retryConfig = {
110
+ // `||` is intentional: a 0ms timeout is degenerate (aborts every call
111
+ // immediately), so fall back to the default.
110
112
  timeout: retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
111
- retries: retryConfig?.retries || DEFAULT_RETRY_CONFIG.retries,
113
+ // `??` so an explicit `retries: 0` (disable retries) is honored instead of
114
+ // being clobbered to the default by a falsy-zero check.
115
+ retries: retryConfig?.retries ?? DEFAULT_RETRY_CONFIG.retries,
112
116
  };
113
117
  }
114
118
 
@@ -538,6 +542,17 @@ export class GeminiProvider implements AiProvider {
538
542
  } as AgentStructuredResponse;
539
543
  }
540
544
 
545
+ // A parsed-but-blank structured message with no tool calls is just as
546
+ // empty as no text at all — throw so withTimeoutAndRetry retries instead
547
+ // of returning {"message":""}.
548
+ if (
549
+ toolCalls.length === 0 &&
550
+ typeof structured?.message === "string" &&
551
+ !structured.message.trim()
552
+ ) {
553
+ throw new Error("No response from Gemini");
554
+ }
555
+
541
556
  return {
542
557
  message,
543
558
  metadata: {
@@ -566,7 +581,10 @@ export class GeminiProvider implements AiProvider {
566
581
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
567
582
  // Try primary model first
568
583
  try {
569
- yield* this.generateStreamWithModel(this.primaryModel, input);
584
+ yield* withStreamRetry(
585
+ () => this.generateStreamWithModel(this.primaryModel, input),
586
+ { maxRetries: this.retryConfig.retries, operationName: `Gemini ${this.primaryModel} stream` }
587
+ );
570
588
  } catch (primaryError: unknown) {
571
589
  const primaryErrMsg = getErrorMessage(primaryError);
572
590
  logger.warn(
@@ -589,7 +607,10 @@ export class GeminiProvider implements AiProvider {
589
607
  );
590
608
 
591
609
  try {
592
- yield* this.generateStreamWithModel(backupModel, input);
610
+ yield* withStreamRetry(
611
+ () => this.generateStreamWithModel(backupModel, input),
612
+ { maxRetries: this.retryConfig.retries, operationName: `Gemini ${backupModel} stream` }
613
+ );
593
614
  logger.debug(`[GEMINI] Backup model ${backupModel} succeeded`);
594
615
  return;
595
616
  } catch (backupError: unknown) {
@@ -747,6 +768,18 @@ export class GeminiProvider implements AiProvider {
747
768
  } as AgentStructuredResponse;
748
769
  }
749
770
 
771
+ // Empty-completion guard — mirror of the non-streaming path. The effective
772
+ // message is the parsed structured message when a schema is used, else the
773
+ // accumulated text. A blank message with no tool calls means the model
774
+ // produced nothing usable; throw so withStreamRetry/generateStreamWithBackup
775
+ // retry instead of silently emitting an empty message.
776
+ const messageText = (
777
+ typeof structured?.message === "string" ? structured.message : accumulated
778
+ ).trim();
779
+ if (!messageText && toolCalls.length === 0) {
780
+ throw new Error("No response from Gemini");
781
+ }
782
+
750
783
  // Yield final chunk
751
784
  yield {
752
785
  delta: "",
@@ -27,7 +27,7 @@ import type {
27
27
  } from "../types";
28
28
  import type { ProviderCapabilities } from "../types/ai";
29
29
  import type { HistoryItem } from "../types/history";
30
- import { withTimeoutAndRetry, logger } from "../utils";
30
+ import { withTimeoutAndRetry, withStreamRetry, logger } from "../utils";
31
31
  import {
32
32
  classifyProviderError,
33
33
  getErrorMessage,
@@ -100,8 +100,12 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
100
100
  this.backupModels = init.backupModels ?? [];
101
101
  this.config = init.config;
102
102
  this.retryConfig = {
103
+ // `||` is intentional: a 0ms timeout is degenerate (aborts every call
104
+ // immediately), so fall back to the default.
103
105
  timeout: init.retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
104
- retries: init.retryConfig?.retries || DEFAULT_RETRY_CONFIG.retries,
106
+ // `??` so an explicit `retries: 0` (disable retries) is honored instead of
107
+ // being clobbered to the default by a falsy-zero check.
108
+ retries: init.retryConfig?.retries ?? DEFAULT_RETRY_CONFIG.retries,
105
109
  };
106
110
  }
107
111
 
@@ -408,9 +412,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
408
412
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
409
413
  // Try primary model first
410
414
  try {
411
- yield* this.generateStreamWithModel<TContext, TStructured>(
412
- this.primaryModel,
413
- input
415
+ yield* withStreamRetry(
416
+ () => this.generateStreamWithModel<TContext, TStructured>(this.primaryModel, input),
417
+ { maxRetries: this.retryConfig.retries, operationName: `${this.logLabel} ${this.primaryModel} stream` }
414
418
  );
415
419
  } catch (primaryError: unknown) {
416
420
  const primaryErrMsg = getErrorMessage(primaryError);
@@ -435,9 +439,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
435
439
  );
436
440
 
437
441
  try {
438
- yield* this.generateStreamWithModel<TContext, TStructured>(
439
- backupModel,
440
- input
442
+ yield* withStreamRetry(
443
+ () => this.generateStreamWithModel<TContext, TStructured>(backupModel, input),
444
+ { maxRetries: this.retryConfig.retries, operationName: `${this.logLabel} ${backupModel} stream` }
441
445
  );
442
446
  logger.debug(`[${this.logLabel}] Backup model ${backupModel} succeeded`);
443
447
  return;
@@ -580,6 +584,17 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
580
584
  } as AgentStructuredResponse;
581
585
  }
582
586
 
587
+ // A parsed-but-blank structured message with no tool calls is just as empty
588
+ // as no text at all — throw so withTimeoutAndRetry retries instead of
589
+ // returning {"message":""}.
590
+ if (
591
+ toolCalls.length === 0 &&
592
+ typeof structured?.message === "string" &&
593
+ !structured.message.trim()
594
+ ) {
595
+ throw new Error(`No response from ${this.displayName}`);
596
+ }
597
+
583
598
  return {
584
599
  message,
585
600
  metadata: {
@@ -720,6 +735,19 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
720
735
  } as TStructured;
721
736
  }
722
737
 
738
+ // Empty-completion guard — mirror of the non-streaming path. A blank
739
+ // effective message (structured message under a schema, else accumulated
740
+ // text) with no tool calls means the model produced nothing usable; throw
741
+ // so withStreamRetry/generateStreamWithBackup retry instead of silently
742
+ // emitting an empty message.
743
+ const structuredMessage = (structured as AgentStructuredResponse | undefined)?.message;
744
+ const messageText = (
745
+ typeof structuredMessage === "string" ? structuredMessage : accumulated
746
+ ).trim();
747
+ if (!messageText && toolCalls.length === 0) {
748
+ throw new Error(`No response from ${this.displayName}`);
749
+ }
750
+
723
751
  // Yield final chunk
724
752
  yield {
725
753
  delta: "",
@@ -327,6 +327,12 @@ export interface SignalFiring<TContext = unknown, TData = unknown> {
327
327
  directive?: ResolvedSignalDirective<TContext, TData>;
328
328
  /** Error message if the handler threw. */
329
329
  handlerError?: string;
330
+ /**
331
+ * Set when the signal matched in extraction mode (`extract` defined) but the
332
+ * classifier returned no extracted payload — the handler ran with
333
+ * `extracted: undefined`. Independent of `handlerError`.
334
+ */
335
+ extractionError?: string;
330
336
  /** Wall-clock duration of the handler invocation in milliseconds. */
331
337
  durationMs?: number;
332
338
  }
@@ -55,8 +55,8 @@ export {
55
55
  export { LoggerLevel, logger } from "./logger";
56
56
 
57
57
  // Retry utilities
58
- export type { RetryOptions } from "./retry";
59
- export { retry, withTimeoutAndRetry } from "./retry";
58
+ export type { RetryOptions, StreamRetryOptions } from "./retry";
59
+ export { retry, withTimeoutAndRetry, withStreamRetry } from "./retry";
60
60
 
61
61
  // Condition utilities
62
62
  export {
@@ -95,3 +95,58 @@ export const withTimeoutAndRetry = async <T>(
95
95
  },
96
96
  });
97
97
  };
98
+
99
+ export interface StreamRetryOptions {
100
+ /** Maximum number of retries after the first attempt. Defaults to 3. */
101
+ maxRetries?: number;
102
+ /** Backoff before the next attempt, in ms. Defaults to capped exponential. */
103
+ delay?: (attempt: number) => number;
104
+ /** Label used in retry logs. */
105
+ operationName?: string;
106
+ }
107
+
108
+ /**
109
+ * Streaming analog of {@link withTimeoutAndRetry}. Re-runs an async-generator
110
+ * factory as long as it fails *before yielding its first chunk* — e.g. an
111
+ * empty completion that throws "No response", or an error while establishing
112
+ * the stream. Once any chunk has been yielded the stream is committed and
113
+ * further errors propagate, so a retry can never double-emit deltas that the
114
+ * consumer has already received.
115
+ *
116
+ * This mirrors the non-streaming path, where the provider throws on an empty
117
+ * completion inside `withTimeoutAndRetry` and is retried on the same model
118
+ * before the caller falls through to backup models.
119
+ */
120
+ export async function* withStreamRetry<T>(
121
+ factory: () => AsyncGenerator<T>,
122
+ options: StreamRetryOptions = {}
123
+ ): AsyncGenerator<T> {
124
+ const {
125
+ maxRetries = 3,
126
+ delay = (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 5000),
127
+ operationName = "AI stream",
128
+ } = options;
129
+
130
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
131
+ let yielded = false;
132
+ try {
133
+ for await (const chunk of factory()) {
134
+ yielded = true;
135
+ yield chunk;
136
+ }
137
+ return;
138
+ } catch (error: unknown) {
139
+ // Can't retry once deltas are out, and don't retry past the budget.
140
+ if (yielded || attempt === maxRetries) {
141
+ throw error;
142
+ }
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ logger.error(`[${operationName}] Failed attempt ${attempt + 1}:`, message);
145
+ const delayMs = delay(attempt);
146
+ logger.debug(
147
+ `[${operationName}] Retrying in ${delayMs}ms... (attempt ${attempt + 2}/${maxRetries + 1})`
148
+ );
149
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
150
+ }
151
+ }
152
+ }