@falai/agent 2.4.2 → 2.5.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 (142) 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 +27 -2
  15. package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
  16. package/dist/cjs/core/ToolLoopExecutor.js +159 -74
  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/index.d.ts +3 -0
  26. package/dist/cjs/index.d.ts.map +1 -1
  27. package/dist/cjs/index.js +4 -2
  28. package/dist/cjs/index.js.map +1 -1
  29. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  30. package/dist/cjs/providers/AnthropicProvider.js +18 -14
  31. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  32. package/dist/cjs/providers/DeepSeekProvider.d.ts +1 -12
  33. package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
  34. package/dist/cjs/providers/DeepSeekProvider.js +3 -20
  35. package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
  36. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  37. package/dist/cjs/providers/GeminiProvider.js +19 -15
  38. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  39. package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
  40. package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
  41. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +93 -0
  42. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -0
  43. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts +23 -5
  44. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  45. package/dist/cjs/providers/OpenAICompatibleProvider.js +47 -23
  46. package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
  47. package/dist/cjs/providers/index.d.ts +3 -1
  48. package/dist/cjs/providers/index.d.ts.map +1 -1
  49. package/dist/cjs/providers/index.js +3 -1
  50. package/dist/cjs/providers/index.js.map +1 -1
  51. package/dist/cjs/utils/completion.d.ts +20 -0
  52. package/dist/cjs/utils/completion.d.ts.map +1 -0
  53. package/dist/cjs/utils/completion.js +33 -0
  54. package/dist/cjs/utils/completion.js.map +1 -0
  55. package/dist/cjs/utils/index.d.ts +3 -2
  56. package/dist/cjs/utils/index.d.ts.map +1 -1
  57. package/dist/cjs/utils/index.js +8 -1
  58. package/dist/cjs/utils/index.js.map +1 -1
  59. package/dist/cjs/utils/retry.d.ts +58 -1
  60. package/dist/cjs/utils/retry.d.ts.map +1 -1
  61. package/dist/cjs/utils/retry.js +128 -3
  62. package/dist/cjs/utils/retry.js.map +1 -1
  63. package/dist/core/PersistenceManager.d.ts +13 -0
  64. package/dist/core/PersistenceManager.d.ts.map +1 -1
  65. package/dist/core/PersistenceManager.js +29 -6
  66. package/dist/core/PersistenceManager.js.map +1 -1
  67. package/dist/core/ResponseModal.d.ts.map +1 -1
  68. package/dist/core/ResponseModal.js +15 -3
  69. package/dist/core/ResponseModal.js.map +1 -1
  70. package/dist/core/SessionManager.d.ts.map +1 -1
  71. package/dist/core/SessionManager.js +7 -0
  72. package/dist/core/SessionManager.js.map +1 -1
  73. package/dist/core/StreamingToolExecutor.d.ts.map +1 -1
  74. package/dist/core/StreamingToolExecutor.js +11 -0
  75. package/dist/core/StreamingToolExecutor.js.map +1 -1
  76. package/dist/core/ToolLoopExecutor.d.ts +27 -2
  77. package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
  78. package/dist/core/ToolLoopExecutor.js +160 -75
  79. package/dist/core/ToolLoopExecutor.js.map +1 -1
  80. package/dist/core/ToolManager.d.ts.map +1 -1
  81. package/dist/core/ToolManager.js +8 -31
  82. package/dist/core/ToolManager.js.map +1 -1
  83. package/dist/core/toolGates.d.ts +24 -0
  84. package/dist/core/toolGates.d.ts.map +1 -0
  85. package/dist/core/toolGates.js +49 -0
  86. package/dist/core/toolGates.js.map +1 -0
  87. package/dist/index.d.ts +3 -0
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +1 -0
  90. package/dist/index.js.map +1 -1
  91. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  92. package/dist/providers/AnthropicProvider.js +19 -15
  93. package/dist/providers/AnthropicProvider.js.map +1 -1
  94. package/dist/providers/DeepSeekProvider.d.ts +1 -12
  95. package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
  96. package/dist/providers/DeepSeekProvider.js +3 -20
  97. package/dist/providers/DeepSeekProvider.js.map +1 -1
  98. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  99. package/dist/providers/GeminiProvider.js +20 -16
  100. package/dist/providers/GeminiProvider.js.map +1 -1
  101. package/dist/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
  102. package/dist/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
  103. package/dist/providers/GenericOpenAICompatibleProvider.js +87 -0
  104. package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -0
  105. package/dist/providers/OpenAICompatibleProvider.d.ts +23 -5
  106. package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  107. package/dist/providers/OpenAICompatibleProvider.js +48 -24
  108. package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
  109. package/dist/providers/index.d.ts +3 -1
  110. package/dist/providers/index.d.ts.map +1 -1
  111. package/dist/providers/index.js +1 -0
  112. package/dist/providers/index.js.map +1 -1
  113. package/dist/utils/completion.d.ts +20 -0
  114. package/dist/utils/completion.d.ts.map +1 -0
  115. package/dist/utils/completion.js +29 -0
  116. package/dist/utils/completion.js.map +1 -0
  117. package/dist/utils/index.d.ts +3 -2
  118. package/dist/utils/index.d.ts.map +1 -1
  119. package/dist/utils/index.js +3 -1
  120. package/dist/utils/index.js.map +1 -1
  121. package/dist/utils/retry.d.ts +58 -1
  122. package/dist/utils/retry.d.ts.map +1 -1
  123. package/dist/utils/retry.js +125 -3
  124. package/dist/utils/retry.js.map +1 -1
  125. package/package.json +1 -1
  126. package/src/core/PersistenceManager.ts +30 -6
  127. package/src/core/ResponseModal.ts +16 -3
  128. package/src/core/SessionManager.ts +7 -0
  129. package/src/core/StreamingToolExecutor.ts +12 -0
  130. package/src/core/ToolLoopExecutor.ts +210 -79
  131. package/src/core/ToolManager.ts +8 -32
  132. package/src/core/toolGates.ts +72 -0
  133. package/src/index.ts +3 -0
  134. package/src/providers/AnthropicProvider.ts +26 -20
  135. package/src/providers/DeepSeekProvider.ts +3 -35
  136. package/src/providers/GeminiProvider.ts +29 -17
  137. package/src/providers/GenericOpenAICompatibleProvider.ts +138 -0
  138. package/src/providers/OpenAICompatibleProvider.ts +92 -44
  139. package/src/providers/index.ts +4 -0
  140. package/src/utils/completion.ts +38 -0
  141. package/src/utils/index.ts +5 -2
  142. package/src/utils/retry.ts +181 -4
@@ -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,8 +149,70 @@ 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
- const MAX_TOOL_LOOPS = this.deps.maxToolLoops || 5;
213
+ // `??` so an explicit `maxToolLoops: 0` is honored instead of being
214
+ // clobbered to the default by a falsy-zero check.
215
+ const MAX_TOOL_LOOPS = this.deps.maxToolLoops ?? 5;
151
216
  let toolLoopCount = 0;
152
217
  let hasToolCalls = toolCalls && toolCalls.length > 0;
153
218
  let finalMessage: string | undefined;
@@ -162,24 +227,18 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
162
227
  for (const toolCall of toolCalls || []) {
163
228
  const tool = this.findAvailableTool(toolCall.toolName, selectedFlow);
164
229
  if (tool) {
165
- // Create HistoryItem format for tool results
166
- // Add assistant message with tool_calls
167
- toolResultHistoryItems.push({
168
- role: "assistant" as const,
169
- content: null,
170
- tool_calls: [{
171
- id: toolCall.toolName,
172
- name: toolCall.toolName,
173
- arguments: toolCall.arguments,
174
- }],
175
- });
176
- // Add tool result
177
- toolResultHistoryItems.push({
178
- role: "tool" as const,
179
- tool_call_id: toolCall.toolName,
180
- name: toolCall.toolName,
181
- content: toolResultsMap.get(toolCall.toolName) || "Tool executed successfully",
182
- });
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
+ );
183
242
  }
184
243
  }
185
244
 
@@ -296,63 +355,26 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
296
355
  }
297
356
 
298
357
  // If tools were executed but no final text message was produced,
299
- // make one more LLM call to generate a proper text response from tool results.
300
- // This prevents the original tool-invocation message (e.g. "Let me check...")
301
- // from being returned as the final user-facing response.
358
+ // make one more LLM call to generate a proper text response from tool
359
+ // results. This prevents the original tool-invocation message (e.g.
360
+ // "Let me check...") from being returned as the final user-facing
361
+ // response. Shared with the streaming path via forceFinalTextFromTools.
302
362
  if (!finalMessage && toolLoopCount > 0) {
303
363
  logger.debug(`[ToolLoopExecutor] No final message after tool loop, making additional LLM call for text response`);
304
-
305
- // Build tool result history from toolResultsMap which contains ALL
306
- // tool executions (initial + follow-up). We can't use `toolCalls` here
307
- // because it was reassigned to the (empty) follow-up tool calls when
308
- // the while loop broke out.
309
- const finalToolResultHistoryItems: HistoryItem[] = [];
310
- for (const [toolName, toolResult] of toolResultsMap) {
311
- finalToolResultHistoryItems.push({
312
- role: "assistant" as const,
313
- content: null,
314
- tool_calls: [{
315
- id: toolName,
316
- name: toolName,
317
- arguments: toolArgsMap.get(toolName) || {},
318
- }],
319
- });
320
- finalToolResultHistoryItems.push({
321
- role: "tool" as const,
322
- tool_call_id: toolName,
323
- name: toolName,
324
- content: toolResult,
325
- });
364
+ const forced = await this.forceFinalTextFromTools({
365
+ history,
366
+ toolResultsMap,
367
+ toolArgsMap,
368
+ responsePrompt,
369
+ responseSchema,
370
+ context,
371
+ signal,
372
+ });
373
+ if (forced.finalMessage) {
374
+ finalMessage = forced.finalMessage;
326
375
  }
327
-
328
- const finalHistory = [...history, ...finalToolResultHistoryItems];
329
- const agentOptions = this.deps.getAgentOptions();
330
-
331
- try {
332
- const textResult = await agentOptions.provider.generateMessage({
333
- prompt: responsePrompt + "\n\nProvide a text response to the user based on the tool results. Do not call any tools.",
334
- history: finalHistory,
335
- context,
336
- tools: [], // No tools - force text response
337
- parameters: responseSchema ? {
338
- jsonSchema: responseSchema,
339
- schemaName: "tool_final_text",
340
- } : undefined,
341
- signal,
342
- });
343
-
344
- finalMessage = textResult.structured?.message || textResult.message;
345
- if (textResult.structured) {
346
- followUpStructured = textResult.structured;
347
- }
348
-
349
- logger.debug(`[ToolLoopExecutor] Generated final text response after tool loop:`, {
350
- hasMessage: !!finalMessage,
351
- messageLength: finalMessage?.length || 0,
352
- });
353
- } catch (error) {
354
- logger.error(`[ToolLoopExecutor] Failed to generate final text response after tool loop:`, error);
355
- // finalMessage remains undefined; caller will use original message as fallback
376
+ if (forced.structured) {
377
+ followUpStructured = forced.structured;
356
378
  }
357
379
  }
358
380
 
@@ -377,12 +399,74 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
377
399
  }
378
400
  }
379
401
 
402
+ /**
403
+ * After tools have executed but the model produced no closing text, make one
404
+ * more LLM call (no tools) that turns the tool results into a user-facing
405
+ * message. Shared by the non-streaming tool loop and the streaming batch so
406
+ * both paths behave identically. Returns an empty object if the call fails
407
+ * or yields nothing, leaving the caller to fall back to its prior message.
408
+ */
409
+ private async forceFinalTextFromTools(params: {
410
+ history: HistoryItem[];
411
+ toolResultsMap: Map<string, string>;
412
+ toolArgsMap: Map<string, Record<string, unknown>>;
413
+ responsePrompt: string;
414
+ responseSchema?: Record<string, unknown>;
415
+ context: TContext;
416
+ signal?: AbortSignal;
417
+ }): Promise<{ finalMessage?: string; structured?: AgentStructuredResponse }> {
418
+ const { history, toolResultsMap, toolArgsMap, responsePrompt, responseSchema, context, signal } = params;
419
+
420
+ // Reconstruct assistant tool_call + tool result pairs so the follow-up
421
+ // call can see what the tools returned.
422
+ const finalToolResultHistoryItems: HistoryItem[] = [];
423
+ for (const [toolName, toolResult] of toolResultsMap) {
424
+ finalToolResultHistoryItems.push(
425
+ assistantMessage(null, [
426
+ { id: toolName, name: toolName, arguments: toolArgsMap.get(toolName) || {} },
427
+ ]),
428
+ toolMessage(toolName, toolName, toolResult),
429
+ );
430
+ }
431
+
432
+ const finalHistory = [...history, ...finalToolResultHistoryItems];
433
+ const agentOptions = this.deps.getAgentOptions();
434
+
435
+ try {
436
+ const textResult = await agentOptions.provider.generateMessage({
437
+ prompt: responsePrompt + "\n\nProvide a text response to the user based on the tool results. Do not call any tools.",
438
+ history: finalHistory,
439
+ context,
440
+ tools: [], // No tools - force text response
441
+ parameters: responseSchema ? {
442
+ jsonSchema: responseSchema,
443
+ schemaName: "tool_final_text",
444
+ } : undefined,
445
+ signal,
446
+ });
447
+
448
+ const finalMessage = textResult.structured?.message || textResult.message;
449
+ logger.debug(`[ToolLoopExecutor] Generated final text response from tool results:`, {
450
+ hasMessage: !!finalMessage,
451
+ messageLength: finalMessage?.length || 0,
452
+ });
453
+ return { finalMessage, structured: textResult.structured };
454
+ } catch (error) {
455
+ logger.error(`[ToolLoopExecutor] Failed to generate final text response from tool results:`, error);
456
+ // Leave the caller to fall back to its prior message.
457
+ return {};
458
+ }
459
+ }
460
+
380
461
  /**
381
462
  * Execute the streaming path's initial batch of tool calls concurrently
382
463
  * via ToolManager.executeWithConcurrency, yielding tool-progress chunks.
383
- * Falls back to `runLoop()` when concurrent execution fails.
464
+ * When tools ran but the model produced no closing text, forces a final
465
+ * text response from the tool results (mirroring `runLoop()`). Falls back
466
+ * to `runLoop()` when concurrent execution fails.
384
467
  *
385
- * Returns the updated session and the final tool calls.
468
+ * Returns the updated session, final tool calls, and any forced closing
469
+ * message/structured response.
386
470
  */
387
471
  async *runStreamingBatch(params: {
388
472
  toolCalls: Array<{ toolName: string; arguments: Record<string, unknown> }>;
@@ -407,6 +491,9 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
407
491
  {
408
492
  session: SessionState<TData>;
409
493
  toolCalls: Array<{ toolName: string; arguments: Record<string, unknown> }> | undefined;
494
+ /** Closing message forced from the tool results, if one was generated. */
495
+ finalMessage?: string;
496
+ structured?: AgentStructuredResponse;
410
497
  }
411
498
  > {
412
499
  const { context, history, selectedFlow, step, accumulated, responsePrompt, availableTools, responseSchema, signal } = params;
@@ -419,9 +506,17 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
419
506
  toolName: tc.toolName,
420
507
  arguments: tc.arguments,
421
508
  }));
509
+ // Map request id -> request so completed results can be attributed back
510
+ // to their tool name/arguments for the forced final-text call.
511
+ const requestById = new Map(toolCallRequests.map((r) => [r.id, r]));
512
+ const toolResultsMap = new Map<string, string>();
513
+ const toolArgsMap = new Map<string, Record<string, unknown>>();
422
514
 
423
515
  const historyEvents = historyToEvents(history);
424
516
 
517
+ let finalMessage: string | undefined;
518
+ let structured: AgentStructuredResponse | undefined;
519
+
425
520
  try {
426
521
  for await (const update of this.deps.toolManager.executeWithConcurrency({
427
522
  toolCalls: toolCallRequests,
@@ -450,6 +545,15 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
450
545
  }
451
546
  }
452
547
 
548
+ // Capture tool results for the forced final-text call
549
+ if (update.result) {
550
+ const req = requestById.get(update.toolCallId);
551
+ if (req) {
552
+ toolResultsMap.set(req.toolName, serializeToolResult(update.result));
553
+ toolArgsMap.set(req.toolName, req.arguments);
554
+ }
555
+ }
556
+
453
557
  // Yield progress updates immediately
454
558
  if (update.progress) {
455
559
  yield {
@@ -465,18 +569,45 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
465
569
  }
466
570
 
467
571
  logger.debug(`[ToolLoopExecutor] Concurrent tool execution completed for ${toolCallRequests.length} tools`);
572
+
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;
468
595
  } catch (error) {
469
596
  logger.error(`[ToolLoopExecutor] Concurrent tool execution failed, falling back to sequential:`, error);
470
- // 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).
471
600
  const toolResult = await this.runLoop({
472
601
  toolCalls, context, session, history, selectedFlow,
473
602
  responsePrompt, availableTools, responseSchema, signal,
474
603
  });
475
604
  session = toolResult.session;
476
605
  toolCalls = toolResult.finalToolCalls;
606
+ finalMessage = toolResult.finalMessage;
607
+ structured = toolResult.structured;
477
608
  }
478
609
 
479
- return { session, toolCalls };
610
+ return { session, toolCalls, finalMessage, structured };
480
611
  }
481
612
 
482
613
  /**
@@ -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
+ }
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, 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,10 +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
- timeout: retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
110
- retries: retryConfig?.retries || DEFAULT_RETRY_CONFIG.retries,
111
- };
103
+ this.retryConfig = resolveRetryConfig(retryConfig);
112
104
  }
113
105
 
114
106
  /**
@@ -264,7 +256,7 @@ export class AnthropicProvider implements AiProvider {
264
256
  model: string,
265
257
  input: GenerateMessageInput<TContext>
266
258
  ): Promise<GenerateMessageOutput<TStructured>> {
267
- const operation = async (): Promise<GenerateMessageOutput> => {
259
+ const operation = async (signal: AbortSignal): Promise<GenerateMessageOutput> => {
268
260
  // Anthropic requires max_tokens to be specified
269
261
  const maxTokens = input.parameters?.maxOutputTokens || 4096;
270
262
 
@@ -328,7 +320,9 @@ export class AnthropicProvider implements AiProvider {
328
320
  }
329
321
  }
330
322
 
331
- const response = await this.client.messages.create(params);
323
+ const response = await this.client.messages.create(params, {
324
+ signal: combineAbortSignals(input.signal, signal),
325
+ });
332
326
 
333
327
  // Extract text and tool calls from response
334
328
  const textContent = response.content.find(
@@ -377,6 +371,11 @@ export class AnthropicProvider implements AiProvider {
377
371
  } as AgentStructuredResponse;
378
372
  }
379
373
 
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");
378
+
380
379
  return {
381
380
  message,
382
381
  metadata: {
@@ -407,9 +406,9 @@ export class AnthropicProvider implements AiProvider {
407
406
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
408
407
  // Try primary model first
409
408
  try {
410
- yield* this.generateStreamWithModel<TContext, TStructured>(
411
- this.primaryModel,
412
- input
409
+ yield* withStreamRetry(
410
+ (signal) => this.generateStreamWithModel<TContext, TStructured>(this.primaryModel, input, signal),
411
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Anthropic ${this.primaryModel} stream` }
413
412
  );
414
413
  } catch (primaryError: unknown) {
415
414
  const primaryErrMsg = getErrorMessage(primaryError);
@@ -433,9 +432,9 @@ export class AnthropicProvider implements AiProvider {
433
432
  );
434
433
 
435
434
  try {
436
- yield* this.generateStreamWithModel<TContext, TStructured>(
437
- backupModel,
438
- input
435
+ yield* withStreamRetry(
436
+ (signal) => this.generateStreamWithModel<TContext, TStructured>(backupModel, input, signal),
437
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Anthropic ${backupModel} stream` }
439
438
  );
440
439
  logger.debug(`[ANTHROPIC] Backup model ${backupModel} succeeded`);
441
440
  return;
@@ -471,7 +470,8 @@ export class AnthropicProvider implements AiProvider {
471
470
  TStructured = AgentStructuredResponse
472
471
  >(
473
472
  model: string,
474
- input: GenerateMessageInput<TContext>
473
+ input: GenerateMessageInput<TContext>,
474
+ attemptSignal?: AbortSignal
475
475
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
476
476
  // Anthropic requires max_tokens to be specified
477
477
  const maxTokens = input.parameters?.maxOutputTokens || 4096;
@@ -536,7 +536,9 @@ export class AnthropicProvider implements AiProvider {
536
536
  }
537
537
  }
538
538
 
539
- const stream = this.client.messages.stream(params);
539
+ const stream = this.client.messages.stream(params, {
540
+ signal: combineAbortSignals(input.signal, attemptSignal),
541
+ });
540
542
 
541
543
  let accumulated = "";
542
544
  let currentModel = model;
@@ -597,6 +599,10 @@ export class AnthropicProvider implements AiProvider {
597
599
  } as AgentStructuredResponse;
598
600
  }
599
601
 
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");
605
+
600
606
  // Yield final chunk
601
607
  yield {
602
608
  delta: "",