@falai/agent 2.4.2 → 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 (95) 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/StreamingToolExecutor.d.ts.map +1 -1
  12. package/dist/cjs/core/StreamingToolExecutor.js +11 -0
  13. package/dist/cjs/core/StreamingToolExecutor.js.map +1 -1
  14. package/dist/cjs/core/ToolLoopExecutor.d.ts +16 -2
  15. package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
  16. package/dist/cjs/core/ToolLoopExecutor.js +115 -54
  17. package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
  18. package/dist/cjs/core/ToolManager.d.ts.map +1 -1
  19. package/dist/cjs/core/ToolManager.js +8 -31
  20. package/dist/cjs/core/ToolManager.js.map +1 -1
  21. package/dist/cjs/core/toolGates.d.ts +24 -0
  22. package/dist/cjs/core/toolGates.d.ts.map +1 -0
  23. package/dist/cjs/core/toolGates.js +52 -0
  24. package/dist/cjs/core/toolGates.js.map +1 -0
  25. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  26. package/dist/cjs/providers/AnthropicProvider.js +24 -3
  27. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  28. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  29. package/dist/cjs/providers/GeminiProvider.js +24 -3
  30. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  31. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  32. package/dist/cjs/providers/OpenAICompatibleProvider.js +25 -3
  33. package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
  34. package/dist/cjs/utils/index.d.ts +2 -2
  35. package/dist/cjs/utils/index.d.ts.map +1 -1
  36. package/dist/cjs/utils/index.js +2 -1
  37. package/dist/cjs/utils/index.js.map +1 -1
  38. package/dist/cjs/utils/retry.d.ts +21 -0
  39. package/dist/cjs/utils/retry.d.ts.map +1 -1
  40. package/dist/cjs/utils/retry.js +37 -0
  41. package/dist/cjs/utils/retry.js.map +1 -1
  42. package/dist/core/PersistenceManager.d.ts +13 -0
  43. package/dist/core/PersistenceManager.d.ts.map +1 -1
  44. package/dist/core/PersistenceManager.js +29 -6
  45. package/dist/core/PersistenceManager.js.map +1 -1
  46. package/dist/core/ResponseModal.d.ts.map +1 -1
  47. package/dist/core/ResponseModal.js +15 -3
  48. package/dist/core/ResponseModal.js.map +1 -1
  49. package/dist/core/SessionManager.d.ts.map +1 -1
  50. package/dist/core/SessionManager.js +7 -0
  51. package/dist/core/SessionManager.js.map +1 -1
  52. package/dist/core/StreamingToolExecutor.d.ts.map +1 -1
  53. package/dist/core/StreamingToolExecutor.js +11 -0
  54. package/dist/core/StreamingToolExecutor.js.map +1 -1
  55. package/dist/core/ToolLoopExecutor.d.ts +16 -2
  56. package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
  57. package/dist/core/ToolLoopExecutor.js +115 -54
  58. package/dist/core/ToolLoopExecutor.js.map +1 -1
  59. package/dist/core/ToolManager.d.ts.map +1 -1
  60. package/dist/core/ToolManager.js +8 -31
  61. package/dist/core/ToolManager.js.map +1 -1
  62. package/dist/core/toolGates.d.ts +24 -0
  63. package/dist/core/toolGates.d.ts.map +1 -0
  64. package/dist/core/toolGates.js +49 -0
  65. package/dist/core/toolGates.js.map +1 -0
  66. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  67. package/dist/providers/AnthropicProvider.js +25 -4
  68. package/dist/providers/AnthropicProvider.js.map +1 -1
  69. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  70. package/dist/providers/GeminiProvider.js +25 -4
  71. package/dist/providers/GeminiProvider.js.map +1 -1
  72. package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  73. package/dist/providers/OpenAICompatibleProvider.js +26 -4
  74. package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
  75. package/dist/utils/index.d.ts +2 -2
  76. package/dist/utils/index.d.ts.map +1 -1
  77. package/dist/utils/index.js +1 -1
  78. package/dist/utils/index.js.map +1 -1
  79. package/dist/utils/retry.d.ts +21 -0
  80. package/dist/utils/retry.d.ts.map +1 -1
  81. package/dist/utils/retry.js +36 -0
  82. package/dist/utils/retry.js.map +1 -1
  83. package/package.json +1 -1
  84. package/src/core/PersistenceManager.ts +30 -6
  85. package/src/core/ResponseModal.ts +16 -3
  86. package/src/core/SessionManager.ts +7 -0
  87. package/src/core/StreamingToolExecutor.ts +12 -0
  88. package/src/core/ToolLoopExecutor.ts +133 -58
  89. package/src/core/ToolManager.ts +8 -32
  90. package/src/core/toolGates.ts +72 -0
  91. package/src/providers/AnthropicProvider.ts +35 -8
  92. package/src/providers/GeminiProvider.ts +37 -4
  93. package/src/providers/OpenAICompatibleProvider.ts +36 -8
  94. package/src/utils/index.ts +2 -2
  95. package/src/utils/retry.ts +55 -0
@@ -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: "",
@@ -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
+ }