@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.
- package/dist/cjs/core/PersistenceManager.d.ts +13 -0
- package/dist/cjs/core/PersistenceManager.d.ts.map +1 -1
- package/dist/cjs/core/PersistenceManager.js +29 -6
- package/dist/cjs/core/PersistenceManager.js.map +1 -1
- package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
- package/dist/cjs/core/ResponseModal.js +15 -3
- package/dist/cjs/core/ResponseModal.js.map +1 -1
- package/dist/cjs/core/SessionManager.d.ts.map +1 -1
- package/dist/cjs/core/SessionManager.js +7 -0
- package/dist/cjs/core/SessionManager.js.map +1 -1
- package/dist/cjs/core/StreamingToolExecutor.d.ts.map +1 -1
- package/dist/cjs/core/StreamingToolExecutor.js +11 -0
- package/dist/cjs/core/StreamingToolExecutor.js.map +1 -1
- package/dist/cjs/core/ToolLoopExecutor.d.ts +27 -2
- package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
- package/dist/cjs/core/ToolLoopExecutor.js +159 -74
- package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
- package/dist/cjs/core/ToolManager.d.ts.map +1 -1
- package/dist/cjs/core/ToolManager.js +8 -31
- package/dist/cjs/core/ToolManager.js.map +1 -1
- package/dist/cjs/core/toolGates.d.ts +24 -0
- package/dist/cjs/core/toolGates.d.ts.map +1 -0
- package/dist/cjs/core/toolGates.js +52 -0
- package/dist/cjs/core/toolGates.js.map +1 -0
- package/dist/cjs/index.d.ts +3 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/cjs/providers/AnthropicProvider.js +18 -14
- package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
- package/dist/cjs/providers/DeepSeekProvider.d.ts +1 -12
- package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
- package/dist/cjs/providers/DeepSeekProvider.js +3 -20
- package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
- package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/cjs/providers/GeminiProvider.js +19 -15
- package/dist/cjs/providers/GeminiProvider.js.map +1 -1
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +93 -0
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -0
- package/dist/cjs/providers/OpenAICompatibleProvider.d.ts +23 -5
- package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
- package/dist/cjs/providers/OpenAICompatibleProvider.js +47 -23
- package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
- package/dist/cjs/providers/index.d.ts +3 -1
- package/dist/cjs/providers/index.d.ts.map +1 -1
- package/dist/cjs/providers/index.js +3 -1
- package/dist/cjs/providers/index.js.map +1 -1
- package/dist/cjs/utils/completion.d.ts +20 -0
- package/dist/cjs/utils/completion.d.ts.map +1 -0
- package/dist/cjs/utils/completion.js +33 -0
- package/dist/cjs/utils/completion.js.map +1 -0
- package/dist/cjs/utils/index.d.ts +3 -2
- package/dist/cjs/utils/index.d.ts.map +1 -1
- package/dist/cjs/utils/index.js +8 -1
- package/dist/cjs/utils/index.js.map +1 -1
- package/dist/cjs/utils/retry.d.ts +58 -1
- package/dist/cjs/utils/retry.d.ts.map +1 -1
- package/dist/cjs/utils/retry.js +128 -3
- package/dist/cjs/utils/retry.js.map +1 -1
- package/dist/core/PersistenceManager.d.ts +13 -0
- package/dist/core/PersistenceManager.d.ts.map +1 -1
- package/dist/core/PersistenceManager.js +29 -6
- package/dist/core/PersistenceManager.js.map +1 -1
- package/dist/core/ResponseModal.d.ts.map +1 -1
- package/dist/core/ResponseModal.js +15 -3
- package/dist/core/ResponseModal.js.map +1 -1
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +7 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/StreamingToolExecutor.d.ts.map +1 -1
- package/dist/core/StreamingToolExecutor.js +11 -0
- package/dist/core/StreamingToolExecutor.js.map +1 -1
- package/dist/core/ToolLoopExecutor.d.ts +27 -2
- package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
- package/dist/core/ToolLoopExecutor.js +160 -75
- package/dist/core/ToolLoopExecutor.js.map +1 -1
- package/dist/core/ToolManager.d.ts.map +1 -1
- package/dist/core/ToolManager.js +8 -31
- package/dist/core/ToolManager.js.map +1 -1
- package/dist/core/toolGates.d.ts +24 -0
- package/dist/core/toolGates.d.ts.map +1 -0
- package/dist/core/toolGates.js +49 -0
- package/dist/core/toolGates.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/providers/AnthropicProvider.js +19 -15
- package/dist/providers/AnthropicProvider.js.map +1 -1
- package/dist/providers/DeepSeekProvider.d.ts +1 -12
- package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
- package/dist/providers/DeepSeekProvider.js +3 -20
- package/dist/providers/DeepSeekProvider.js.map +1 -1
- package/dist/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/providers/GeminiProvider.js +20 -16
- package/dist/providers/GeminiProvider.js.map +1 -1
- package/dist/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
- package/dist/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
- package/dist/providers/GenericOpenAICompatibleProvider.js +87 -0
- package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -0
- package/dist/providers/OpenAICompatibleProvider.d.ts +23 -5
- package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
- package/dist/providers/OpenAICompatibleProvider.js +48 -24
- package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
- package/dist/providers/index.d.ts +3 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/utils/completion.d.ts +20 -0
- package/dist/utils/completion.d.ts.map +1 -0
- package/dist/utils/completion.js +29 -0
- package/dist/utils/completion.js.map +1 -0
- package/dist/utils/index.d.ts +3 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/retry.d.ts +58 -1
- package/dist/utils/retry.d.ts.map +1 -1
- package/dist/utils/retry.js +125 -3
- package/dist/utils/retry.js.map +1 -1
- package/package.json +1 -1
- package/src/core/PersistenceManager.ts +30 -6
- package/src/core/ResponseModal.ts +16 -3
- package/src/core/SessionManager.ts +7 -0
- package/src/core/StreamingToolExecutor.ts +12 -0
- package/src/core/ToolLoopExecutor.ts +210 -79
- package/src/core/ToolManager.ts +8 -32
- package/src/core/toolGates.ts +72 -0
- package/src/index.ts +3 -0
- package/src/providers/AnthropicProvider.ts +26 -20
- package/src/providers/DeepSeekProvider.ts +3 -35
- package/src/providers/GeminiProvider.ts +29 -17
- package/src/providers/GenericOpenAICompatibleProvider.ts +138 -0
- package/src/providers/OpenAICompatibleProvider.ts +92 -44
- package/src/providers/index.ts +4 -0
- package/src/utils/completion.ts +38 -0
- package/src/utils/index.ts +5 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
166
|
-
//
|
|
167
|
-
toolResultHistoryItems.push(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
300
|
-
// This prevents the original tool-invocation message (e.g.
|
|
301
|
-
// from being returned as the final user-facing
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
/**
|
package/src/core/ToolManager.ts
CHANGED
|
@@ -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
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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*
|
|
411
|
-
this.primaryModel,
|
|
412
|
-
|
|
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*
|
|
437
|
-
backupModel,
|
|
438
|
-
|
|
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: "",
|