@falai/agent 2.4.3 → 2.6.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 (121) hide show
  1. package/dist/cjs/core/FlowRouter.d.ts.map +1 -1
  2. package/dist/cjs/core/FlowRouter.js +0 -11
  3. package/dist/cjs/core/FlowRouter.js.map +1 -1
  4. package/dist/cjs/core/ResponseModal.d.ts +51 -2
  5. package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
  6. package/dist/cjs/core/ResponseModal.js +272 -269
  7. package/dist/cjs/core/ResponseModal.js.map +1 -1
  8. package/dist/cjs/core/ToolLoopExecutor.d.ts +11 -0
  9. package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
  10. package/dist/cjs/core/ToolLoopExecutor.js +75 -51
  11. package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
  12. package/dist/cjs/index.d.ts +3 -0
  13. package/dist/cjs/index.d.ts.map +1 -1
  14. package/dist/cjs/index.js +4 -2
  15. package/dist/cjs/index.js.map +1 -1
  16. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  17. package/dist/cjs/providers/AnthropicProvider.js +18 -35
  18. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  19. package/dist/cjs/providers/DeepSeekProvider.d.ts +1 -12
  20. package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
  21. package/dist/cjs/providers/DeepSeekProvider.js +3 -20
  22. package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
  23. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  24. package/dist/cjs/providers/GeminiProvider.js +22 -37
  25. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  26. package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
  27. package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
  28. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +93 -0
  29. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -0
  30. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts +23 -5
  31. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  32. package/dist/cjs/providers/OpenAICompatibleProvider.js +46 -44
  33. package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
  34. package/dist/cjs/providers/index.d.ts +3 -1
  35. package/dist/cjs/providers/index.d.ts.map +1 -1
  36. package/dist/cjs/providers/index.js +3 -1
  37. package/dist/cjs/providers/index.js.map +1 -1
  38. package/dist/cjs/utils/completion.d.ts +20 -0
  39. package/dist/cjs/utils/completion.d.ts.map +1 -0
  40. package/dist/cjs/utils/completion.js +33 -0
  41. package/dist/cjs/utils/completion.js.map +1 -0
  42. package/dist/cjs/utils/index.d.ts +3 -2
  43. package/dist/cjs/utils/index.d.ts.map +1 -1
  44. package/dist/cjs/utils/index.js +7 -1
  45. package/dist/cjs/utils/index.js.map +1 -1
  46. package/dist/cjs/utils/retry.d.ts +43 -7
  47. package/dist/cjs/utils/retry.d.ts.map +1 -1
  48. package/dist/cjs/utils/retry.js +100 -12
  49. package/dist/cjs/utils/retry.js.map +1 -1
  50. package/dist/cjs/utils/streamingMessage.d.ts +48 -0
  51. package/dist/cjs/utils/streamingMessage.d.ts.map +1 -0
  52. package/dist/cjs/utils/streamingMessage.js +210 -0
  53. package/dist/cjs/utils/streamingMessage.js.map +1 -0
  54. package/dist/core/FlowRouter.d.ts.map +1 -1
  55. package/dist/core/FlowRouter.js +0 -11
  56. package/dist/core/FlowRouter.js.map +1 -1
  57. package/dist/core/ResponseModal.d.ts +51 -2
  58. package/dist/core/ResponseModal.d.ts.map +1 -1
  59. package/dist/core/ResponseModal.js +272 -269
  60. package/dist/core/ResponseModal.js.map +1 -1
  61. package/dist/core/ToolLoopExecutor.d.ts +11 -0
  62. package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
  63. package/dist/core/ToolLoopExecutor.js +76 -52
  64. package/dist/core/ToolLoopExecutor.js.map +1 -1
  65. package/dist/index.d.ts +3 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +1 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  70. package/dist/providers/AnthropicProvider.js +19 -36
  71. package/dist/providers/AnthropicProvider.js.map +1 -1
  72. package/dist/providers/DeepSeekProvider.d.ts +1 -12
  73. package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
  74. package/dist/providers/DeepSeekProvider.js +3 -20
  75. package/dist/providers/DeepSeekProvider.js.map +1 -1
  76. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  77. package/dist/providers/GeminiProvider.js +23 -38
  78. package/dist/providers/GeminiProvider.js.map +1 -1
  79. package/dist/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
  80. package/dist/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
  81. package/dist/providers/GenericOpenAICompatibleProvider.js +87 -0
  82. package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -0
  83. package/dist/providers/OpenAICompatibleProvider.d.ts +23 -5
  84. package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  85. package/dist/providers/OpenAICompatibleProvider.js +47 -45
  86. package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
  87. package/dist/providers/index.d.ts +3 -1
  88. package/dist/providers/index.d.ts.map +1 -1
  89. package/dist/providers/index.js +1 -0
  90. package/dist/providers/index.js.map +1 -1
  91. package/dist/utils/completion.d.ts +20 -0
  92. package/dist/utils/completion.d.ts.map +1 -0
  93. package/dist/utils/completion.js +29 -0
  94. package/dist/utils/completion.js.map +1 -0
  95. package/dist/utils/index.d.ts +3 -2
  96. package/dist/utils/index.d.ts.map +1 -1
  97. package/dist/utils/index.js +3 -1
  98. package/dist/utils/index.js.map +1 -1
  99. package/dist/utils/retry.d.ts +43 -7
  100. package/dist/utils/retry.d.ts.map +1 -1
  101. package/dist/utils/retry.js +98 -12
  102. package/dist/utils/retry.js.map +1 -1
  103. package/dist/utils/streamingMessage.d.ts +48 -0
  104. package/dist/utils/streamingMessage.d.ts.map +1 -0
  105. package/dist/utils/streamingMessage.js +205 -0
  106. package/dist/utils/streamingMessage.js.map +1 -0
  107. package/package.json +1 -1
  108. package/src/core/FlowRouter.ts +0 -14
  109. package/src/core/ResponseModal.ts +332 -299
  110. package/src/core/ToolLoopExecutor.ts +108 -52
  111. package/src/index.ts +3 -0
  112. package/src/providers/AnthropicProvider.ts +22 -43
  113. package/src/providers/DeepSeekProvider.ts +3 -35
  114. package/src/providers/GeminiProvider.ts +27 -46
  115. package/src/providers/GenericOpenAICompatibleProvider.ts +138 -0
  116. package/src/providers/OpenAICompatibleProvider.ts +87 -67
  117. package/src/providers/index.ts +4 -0
  118. package/src/utils/completion.ts +38 -0
  119. package/src/utils/index.ts +5 -2
  120. package/src/utils/retry.ts +136 -14
  121. package/src/utils/streamingMessage.ts +220 -0
@@ -26,7 +26,7 @@ import type { Flow } from "./Flow";
26
26
  import type { Step } from "./Step";
27
27
  import type { ToolManager } from "./ToolManager";
28
28
  import { ResponseGenerationError } from "./ResponseGenerationError";
29
- import { historyToEvents, logger, serializeToolResult } from "../utils";
29
+ import { historyToEvents, logger, serializeToolResult, assistantMessage, toolMessage } from "../utils";
30
30
 
31
31
  export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
32
32
  constructor(
@@ -72,7 +72,10 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
72
72
  }> {
73
73
  try {
74
74
  const { context, history, selectedFlow, responsePrompt, availableTools, responseSchema, signal } = params;
75
- let { toolCalls, session } = params;
75
+ // The follow-up loop (which reassigned toolCalls) now lives in
76
+ // runFollowUpLoop; here toolCalls is only read.
77
+ const { toolCalls } = params;
78
+ let { session } = params;
76
79
 
77
80
  // Convert HistoryItem[] to Event[] for internal processing
78
81
  const historyEvents = historyToEvents(history);
@@ -146,6 +149,66 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
146
149
  }
147
150
  }
148
151
 
152
+ // Hand off to the multi-round follow-up loop shared with the
153
+ // streaming path. The initial batch above already executed
154
+ // sequentially and populated the result maps.
155
+ return await this.runFollowUpLoop({
156
+ toolCalls,
157
+ session,
158
+ toolResultsMap,
159
+ toolArgsMap,
160
+ context,
161
+ history,
162
+ selectedFlow,
163
+ responsePrompt,
164
+ availableTools,
165
+ responseSchema,
166
+ signal,
167
+ });
168
+ } catch (error) {
169
+ throw ResponseGenerationError.fromError(error, 'tool_execution', params, {
170
+ toolCallsCount: params.toolCalls?.length || 0,
171
+ availableToolsCount: params.availableTools.length
172
+ });
173
+ }
174
+ }
175
+
176
+ /**
177
+ * The multi-round follow-up loop shared by the non-streaming (`runLoop`) and
178
+ * streaming (`runStreamingBatch`) tool paths: re-prompt the model with the
179
+ * tool results (tools available on the first round so it can chain further
180
+ * calls), execute any further tool calls, repeat up to `maxToolLoops`, then
181
+ * force a result-aware closing message if the model never produced one.
182
+ * Callers run the *initial* batch — sequentially for `runLoop`, concurrently
183
+ * (with progress) for `runStreamingBatch` — and pass the populated result
184
+ * maps; from here both paths behave identically.
185
+ */
186
+ private async runFollowUpLoop(params: {
187
+ toolCalls?: Array<{ toolName: string; arguments: Record<string, unknown> }>;
188
+ session: SessionState<TData>;
189
+ toolResultsMap: Map<string, string>;
190
+ toolArgsMap: Map<string, Record<string, unknown>>;
191
+ context: TContext;
192
+ history: HistoryItem[];
193
+ selectedFlow?: Flow<TContext, TData>;
194
+ responsePrompt: string;
195
+ availableTools: Array<{
196
+ id: string;
197
+ name: string;
198
+ description?: string;
199
+ parameters?: unknown;
200
+ }>;
201
+ responseSchema?: Record<string, unknown>;
202
+ signal?: AbortSignal;
203
+ }): Promise<{
204
+ session: SessionState<TData>;
205
+ finalToolCalls?: Array<{ toolName: string; arguments: Record<string, unknown> }>;
206
+ finalMessage?: string;
207
+ structured?: AgentStructuredResponse;
208
+ }> {
209
+ const { context, history, selectedFlow, responsePrompt, availableTools, responseSchema, signal, toolResultsMap, toolArgsMap } = params;
210
+ let { toolCalls, session } = params;
211
+ try {
149
212
  // TOOL LOOP: Allow AI to make follow-up tool calls after initial tool execution
150
213
  // `??` so an explicit `maxToolLoops: 0` is honored instead of being
151
214
  // clobbered to the default by a falsy-zero check.
@@ -164,24 +227,18 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
164
227
  for (const toolCall of toolCalls || []) {
165
228
  const tool = this.findAvailableTool(toolCall.toolName, selectedFlow);
166
229
  if (tool) {
167
- // Create HistoryItem format for tool results
168
- // Add assistant message with tool_calls
169
- toolResultHistoryItems.push({
170
- role: "assistant" as const,
171
- content: null,
172
- tool_calls: [{
173
- id: toolCall.toolName,
174
- name: toolCall.toolName,
175
- arguments: toolCall.arguments,
176
- }],
177
- });
178
- // Add tool result
179
- toolResultHistoryItems.push({
180
- role: "tool" as const,
181
- tool_call_id: toolCall.toolName,
182
- name: toolCall.toolName,
183
- content: toolResultsMap.get(toolCall.toolName) || "Tool executed successfully",
184
- });
230
+ // assistant tool_call + tool result pair, via the shared
231
+ // history factories (same as forceFinalTextFromTools).
232
+ toolResultHistoryItems.push(
233
+ assistantMessage(null, [
234
+ { id: toolCall.toolName, name: toolCall.toolName, arguments: toolCall.arguments },
235
+ ]),
236
+ toolMessage(
237
+ toolCall.toolName,
238
+ toolCall.toolName,
239
+ toolResultsMap.get(toolCall.toolName) || "Tool executed successfully",
240
+ ),
241
+ );
185
242
  }
186
243
  }
187
244
 
@@ -364,21 +421,12 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
364
421
  // call can see what the tools returned.
365
422
  const finalToolResultHistoryItems: HistoryItem[] = [];
366
423
  for (const [toolName, toolResult] of toolResultsMap) {
367
- finalToolResultHistoryItems.push({
368
- role: "assistant" as const,
369
- content: null,
370
- tool_calls: [{
371
- id: toolName,
372
- name: toolName,
373
- arguments: toolArgsMap.get(toolName) || {},
374
- }],
375
- });
376
- finalToolResultHistoryItems.push({
377
- role: "tool" as const,
378
- tool_call_id: toolName,
379
- name: toolName,
380
- content: toolResult,
381
- });
424
+ finalToolResultHistoryItems.push(
425
+ assistantMessage(null, [
426
+ { id: toolName, name: toolName, arguments: toolArgsMap.get(toolName) || {} },
427
+ ]),
428
+ toolMessage(toolName, toolName, toolResult),
429
+ );
382
430
  }
383
431
 
384
432
  const finalHistory = [...history, ...finalToolResultHistoryItems];
@@ -522,25 +570,33 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
522
570
 
523
571
  logger.debug(`[ToolLoopExecutor] Concurrent tool execution completed for ${toolCallRequests.length} tools`);
524
572
 
525
- // Tools ran but streaming produced no post-tool message — force one
526
- // from the results, mirroring runLoop. (The streaming chunk only ever
527
- // carries the pre-tool preamble, never a result-aware response.)
528
- if (toolResultsMap.size > 0) {
529
- const forced = await this.forceFinalTextFromTools({
530
- history,
531
- toolResultsMap,
532
- toolArgsMap,
533
- responsePrompt,
534
- responseSchema,
535
- context,
536
- signal,
537
- });
538
- finalMessage = forced.finalMessage;
539
- structured = forced.structured;
540
- }
573
+ // Multi-round follow-up shared with the non-streaming path: re-prompt
574
+ // with the tool results so the model can chain further tool calls,
575
+ // then produce result-aware closing text (forced if it never does).
576
+ // Previously a single forced-text call — streaming now loops to parity
577
+ // with runLoop, so a streamed turn can chain tools across rounds.
578
+ const followUp = await this.runFollowUpLoop({
579
+ toolCalls,
580
+ session,
581
+ toolResultsMap,
582
+ toolArgsMap,
583
+ context,
584
+ history,
585
+ selectedFlow,
586
+ responsePrompt,
587
+ availableTools,
588
+ responseSchema,
589
+ signal,
590
+ });
591
+ session = followUp.session;
592
+ toolCalls = followUp.finalToolCalls;
593
+ finalMessage = followUp.finalMessage;
594
+ structured = followUp.structured;
541
595
  } catch (error) {
542
596
  logger.error(`[ToolLoopExecutor] Concurrent tool execution failed, falling back to sequential:`, error);
543
- // Fall back to the unified tool loop on failure
597
+ // Fall back to the unified tool loop on failure. runLoop re-executes
598
+ // the tools from scratch, so any partial results collected above are
599
+ // intentionally discarded (it builds and forces its own).
544
600
  const toolResult = await this.runLoop({
545
601
  toolCalls, context, session, history, selectedFlow,
546
602
  responsePrompt, availableTools, responseSchema, signal,
package/src/index.ts CHANGED
@@ -32,6 +32,9 @@ export { DeepSeekProvider } from "./providers/DeepSeekProvider";
32
32
  export type { DeepSeekProviderOptions } from "./providers/DeepSeekProvider";
33
33
  // Base class for building OpenAI-compatible providers (Groq, Together, etc.)
34
34
  export { OpenAICompatibleProvider } from "./providers/OpenAICompatibleProvider";
35
+ export type { StructuredOutputMode } from "./providers/OpenAICompatibleProvider";
36
+ export { createOpenAICompatibleProvider } from "./providers/GenericOpenAICompatibleProvider";
37
+ export type { OpenAICompatibleOptions } from "./providers/GenericOpenAICompatibleProvider";
35
38
 
36
39
  // Adapters
37
40
  export { PrismaAdapter } from "./adapters/PrismaAdapter";
@@ -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, withStreamRetry, logger } from "../utils";
20
+ import { withTimeoutAndRetry, withStreamRetry, resolveRetryConfig, logger, assertUsableCompletion, combineAbortSignals } from "../utils";
21
21
  import {
22
22
  classifyProviderError,
23
23
  getErrorMessage,
@@ -26,11 +26,6 @@ import {
26
26
  type ErrorClassificationOptions,
27
27
  } from "./errorClassification";
28
28
 
29
- const DEFAULT_RETRY_CONFIG = {
30
- timeout: 60000,
31
- retries: 3,
32
- };
33
-
34
29
  /**
35
30
  * Configuration options for Anthropic provider
36
31
  * Uses types from @anthropic-ai/sdk package
@@ -105,14 +100,7 @@ export class AnthropicProvider implements AiProvider {
105
100
  this.primaryModel = model;
106
101
  this.backupModels = backupModels;
107
102
  this.config = config;
108
- this.retryConfig = {
109
- // `||` is intentional: a 0ms timeout is degenerate (aborts every call
110
- // immediately), so fall back to the default.
111
- timeout: retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
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,
115
- };
103
+ this.retryConfig = resolveRetryConfig(retryConfig);
116
104
  }
117
105
 
118
106
  /**
@@ -268,7 +256,7 @@ export class AnthropicProvider implements AiProvider {
268
256
  model: string,
269
257
  input: GenerateMessageInput<TContext>
270
258
  ): Promise<GenerateMessageOutput<TStructured>> {
271
- const operation = async (): Promise<GenerateMessageOutput> => {
259
+ const operation = async (signal: AbortSignal): Promise<GenerateMessageOutput> => {
272
260
  // Anthropic requires max_tokens to be specified
273
261
  const maxTokens = input.parameters?.maxOutputTokens || 4096;
274
262
 
@@ -332,7 +320,9 @@ export class AnthropicProvider implements AiProvider {
332
320
  }
333
321
  }
334
322
 
335
- const response = await this.client.messages.create(params);
323
+ const response = await this.client.messages.create(params, {
324
+ signal: combineAbortSignals(input.signal, signal),
325
+ });
336
326
 
337
327
  // Extract text and tool calls from response
338
328
  const textContent = response.content.find(
@@ -381,16 +371,10 @@ export class AnthropicProvider implements AiProvider {
381
371
  } as AgentStructuredResponse;
382
372
  }
383
373
 
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
- }
374
+ // A parsed-but-blank message with no tool calls is as empty as no text;
375
+ // the shared guard throws so withTimeoutAndRetry retries instead of
376
+ // returning {"message":""}.
377
+ assertUsableCompletion(structured, message, toolCalls.length, "Anthropic");
394
378
 
395
379
  return {
396
380
  message,
@@ -423,8 +407,8 @@ export class AnthropicProvider implements AiProvider {
423
407
  // Try primary model first
424
408
  try {
425
409
  yield* withStreamRetry(
426
- () => this.generateStreamWithModel<TContext, TStructured>(this.primaryModel, input),
427
- { maxRetries: this.retryConfig.retries, operationName: `Anthropic ${this.primaryModel} stream` }
410
+ (signal) => this.generateStreamWithModel<TContext, TStructured>(this.primaryModel, input, signal),
411
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Anthropic ${this.primaryModel} stream` }
428
412
  );
429
413
  } catch (primaryError: unknown) {
430
414
  const primaryErrMsg = getErrorMessage(primaryError);
@@ -449,8 +433,8 @@ export class AnthropicProvider implements AiProvider {
449
433
 
450
434
  try {
451
435
  yield* withStreamRetry(
452
- () => this.generateStreamWithModel<TContext, TStructured>(backupModel, input),
453
- { maxRetries: this.retryConfig.retries, operationName: `Anthropic ${backupModel} stream` }
436
+ (signal) => this.generateStreamWithModel<TContext, TStructured>(backupModel, input, signal),
437
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Anthropic ${backupModel} stream` }
454
438
  );
455
439
  logger.debug(`[ANTHROPIC] Backup model ${backupModel} succeeded`);
456
440
  return;
@@ -486,7 +470,8 @@ export class AnthropicProvider implements AiProvider {
486
470
  TStructured = AgentStructuredResponse
487
471
  >(
488
472
  model: string,
489
- input: GenerateMessageInput<TContext>
473
+ input: GenerateMessageInput<TContext>,
474
+ attemptSignal?: AbortSignal
490
475
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
491
476
  // Anthropic requires max_tokens to be specified
492
477
  const maxTokens = input.parameters?.maxOutputTokens || 4096;
@@ -551,7 +536,9 @@ export class AnthropicProvider implements AiProvider {
551
536
  }
552
537
  }
553
538
 
554
- const stream = this.client.messages.stream(params);
539
+ const stream = this.client.messages.stream(params, {
540
+ signal: combineAbortSignals(input.signal, attemptSignal),
541
+ });
555
542
 
556
543
  let accumulated = "";
557
544
  let currentModel = model;
@@ -612,17 +599,9 @@ export class AnthropicProvider implements AiProvider {
612
599
  } as AgentStructuredResponse;
613
600
  }
614
601
 
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
- }
602
+ // Empty-completion guard — same definition as the non-streaming path, so the
603
+ // stream retries / falls back to backup instead of emitting an empty message.
604
+ assertUsableCompletion(structured, accumulated, toolCalls.length, "Anthropic");
626
605
 
627
606
  // Yield final chunk
628
607
  yield {
@@ -7,15 +7,9 @@
7
7
  import OpenAI from "openai";
8
8
  import type {
9
9
  ChatCompletionChunk,
10
- ChatCompletionCreateParamsNonStreaming,
11
10
  ChatCompletionCreateParamsStreaming,
12
11
  } from "openai/resources/chat/completions";
13
12
 
14
- import type {
15
- GenerateMessageInput,
16
- GenerateMessageOutput,
17
- StructuredSchema,
18
- } from "../types";
19
13
  import type { ProviderCapabilities } from "../types/ai";
20
14
  import { logger } from "../utils";
21
15
  import {
@@ -96,38 +90,12 @@ export class DeepSeekProvider extends OpenAICompatibleProvider {
96
90
  backupModels,
97
91
  config,
98
92
  retryConfig,
93
+ // DeepSeek has no responses.parse API; structured output goes through
94
+ // chat completions with a native json_schema response_format.
95
+ structuredOutput: "json_schema",
99
96
  });
100
97
  }
101
98
 
102
- /**
103
- * DeepSeek has no responses.parse API; structured output goes through
104
- * chat completions with a json_schema response_format.
105
- */
106
- protected override async executeStructuredGenerate(
107
- model: string,
108
- input: GenerateMessageInput<unknown>,
109
- _jsonSchema: StructuredSchema
110
- ): Promise<GenerateMessageOutput> {
111
- return this.executeChatCompletion(model, input);
112
- }
113
-
114
- /**
115
- * DeepSeek supports native json_schema enforcement in chat completions
116
- * (both streaming and non-streaming).
117
- */
118
- protected override structuredResponseFormat(
119
- jsonSchema: StructuredSchema,
120
- schemaName: string | undefined
121
- ): ChatCompletionCreateParamsNonStreaming["response_format"] {
122
- return {
123
- type: "json_schema" as const,
124
- json_schema: {
125
- name: schemaName || "structured_output",
126
- schema: this.adaptSchema(jsonSchema),
127
- },
128
- };
129
- }
130
-
131
99
  /**
132
100
  * DeepSeek reports usage in streaming chunks when explicitly requested.
133
101
  */
@@ -21,7 +21,8 @@ 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, withStreamRetry } from "../utils/retry";
24
+ import { withTimeoutAndRetry, withStreamRetry, resolveRetryConfig, combineAbortSignals } from "../utils/retry";
25
+ import { assertUsableCompletion } from "../utils/completion";
25
26
  import { tryParseJSONResponse } from "../utils/json";
26
27
  import { logger } from "../utils/logger";
27
28
  import {
@@ -32,11 +33,6 @@ import {
32
33
  type ErrorClassificationOptions,
33
34
  } from "./errorClassification";
34
35
 
35
- const DEFAULT_RETRY_CONFIG = {
36
- timeout: 60000,
37
- retries: 3,
38
- };
39
-
40
36
  /**
41
37
  * Configuration options for Gemini provider
42
38
  * Uses types from @google/genai package
@@ -106,14 +102,7 @@ export class GeminiProvider implements AiProvider {
106
102
  this.primaryModel = model;
107
103
  this.backupModels = backupModels;
108
104
  this.config = config;
109
- this.retryConfig = {
110
- // `||` is intentional: a 0ms timeout is degenerate (aborts every call
111
- // immediately), so fall back to the default.
112
- timeout: retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
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,
116
- };
105
+ this.retryConfig = resolveRetryConfig(retryConfig);
117
106
  }
118
107
 
119
108
  /**
@@ -200,13 +189,15 @@ export class GeminiProvider implements AiProvider {
200
189
  *
201
190
  * @private
202
191
  */
203
- private safeExtractText(responseOrChunk: { text?: string; candidates?: Array<{ content?: { parts?: Array<{ text?: string; functionCall?: unknown }> } }> }): string {
192
+ private safeExtractText(responseOrChunk: { text?: string; candidates?: Array<{ content?: { parts?: Array<{ text?: string; thought?: boolean; functionCall?: unknown }> } }> }): string {
204
193
  // Always extract text parts manually to avoid SDK warnings about
205
194
  // non-text parts like "thoughtSignature" in the response.
206
195
  const parts = responseOrChunk.candidates?.[0]?.content?.parts;
207
196
  if (parts) {
197
+ // Exclude reasoning parts (thought: true) — with includeThoughts enabled
198
+ // they carry text but must never leak into the user-facing message.
208
199
  return parts
209
- .filter((p) => p.text != null)
200
+ .filter((p) => p.text != null && !p.thought)
210
201
  .map((p) => p.text)
211
202
  .join("");
212
203
  }
@@ -423,7 +414,7 @@ export class GeminiProvider implements AiProvider {
423
414
  model: string,
424
415
  input: GenerateMessageInput<TContext>
425
416
  ): Promise<GenerateMessageOutput<TStructured>> {
426
- const operation = async (): Promise<GenerateMessageOutput> => {
417
+ const operation = async (signal: AbortSignal): Promise<GenerateMessageOutput> => {
427
418
  // Schema-required: configure response schema
428
419
  const configOverride: Partial<GenerateContentConfig> = { ...this.config };
429
420
 
@@ -468,12 +459,13 @@ export class GeminiProvider implements AiProvider {
468
459
  }
469
460
  }
470
461
 
462
+ const reqSignal = combineAbortSignals(input.signal, signal);
471
463
  response = await this.genAI.models.generateContent({
472
464
  model,
473
465
  contents: historyContents,
474
466
  config: {
475
467
  ...configOverride,
476
- ...(input.signal ? { abortSignal: input.signal } : {}),
468
+ ...(reqSignal ? { abortSignal: reqSignal } : {}),
477
469
  },
478
470
  });
479
471
  } catch (error: unknown) {
@@ -542,16 +534,10 @@ export class GeminiProvider implements AiProvider {
542
534
  } as AgentStructuredResponse;
543
535
  }
544
536
 
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
- }
537
+ // A parsed-but-blank message with no tool calls is as empty as no text;
538
+ // the shared guard throws so withTimeoutAndRetry retries instead of
539
+ // returning {"message":""}.
540
+ assertUsableCompletion(structured, message, toolCalls.length, "Gemini");
555
541
 
556
542
  return {
557
543
  message,
@@ -582,8 +568,8 @@ export class GeminiProvider implements AiProvider {
582
568
  // Try primary model first
583
569
  try {
584
570
  yield* withStreamRetry(
585
- () => this.generateStreamWithModel(this.primaryModel, input),
586
- { maxRetries: this.retryConfig.retries, operationName: `Gemini ${this.primaryModel} stream` }
571
+ (signal) => this.generateStreamWithModel(this.primaryModel, input, signal),
572
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Gemini ${this.primaryModel} stream` }
587
573
  );
588
574
  } catch (primaryError: unknown) {
589
575
  const primaryErrMsg = getErrorMessage(primaryError);
@@ -608,8 +594,8 @@ export class GeminiProvider implements AiProvider {
608
594
 
609
595
  try {
610
596
  yield* withStreamRetry(
611
- () => this.generateStreamWithModel(backupModel, input),
612
- { maxRetries: this.retryConfig.retries, operationName: `Gemini ${backupModel} stream` }
597
+ (signal) => this.generateStreamWithModel(backupModel, input, signal),
598
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Gemini ${backupModel} stream` }
613
599
  );
614
600
  logger.debug(`[GEMINI] Backup model ${backupModel} succeeded`);
615
601
  return;
@@ -645,8 +631,11 @@ export class GeminiProvider implements AiProvider {
645
631
  TStructured = AgentStructuredResponse
646
632
  >(
647
633
  model: string,
648
- input: GenerateMessageInput<TContext>
634
+ input: GenerateMessageInput<TContext>,
635
+ attemptSignal?: AbortSignal
649
636
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
637
+ // Caller cancellation + the retry helper's per-attempt deadline, as one.
638
+ const reqSignal = combineAbortSignals(input.signal, attemptSignal);
650
639
  // Streaming: request JSON if schema provided
651
640
  const configOverride: Partial<GenerateContentConfig> = { ...this.config };
652
641
 
@@ -696,7 +685,7 @@ export class GeminiProvider implements AiProvider {
696
685
  contents: historyContents,
697
686
  config: {
698
687
  ...configOverride,
699
- ...(input.signal ? { abortSignal: input.signal } : {}),
688
+ ...(reqSignal ? { abortSignal: reqSignal } : {}),
700
689
  },
701
690
  });
702
691
  } catch (error: unknown) {
@@ -714,7 +703,7 @@ export class GeminiProvider implements AiProvider {
714
703
  }> = [];
715
704
 
716
705
  for await (const chunk of stream) {
717
- if (input.signal?.aborted) break;
706
+ if (reqSignal?.aborted) break;
718
707
 
719
708
  // Safely extract text — chunk.text can throw when chunk has only function calls
720
709
  const delta = this.safeExtractText(chunk);
@@ -768,17 +757,9 @@ export class GeminiProvider implements AiProvider {
768
757
  } as AgentStructuredResponse;
769
758
  }
770
759
 
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
- }
760
+ // Empty-completion guard — same definition as the non-streaming path, so the
761
+ // stream retries / falls back to backup instead of emitting an empty message.
762
+ assertUsableCompletion(structured, accumulated, toolCalls.length, "Gemini");
782
763
 
783
764
  // Yield final chunk
784
765
  yield {