@falai/agent 2.4.2 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/cjs/core/PersistenceManager.d.ts +13 -0
  2. package/dist/cjs/core/PersistenceManager.d.ts.map +1 -1
  3. package/dist/cjs/core/PersistenceManager.js +29 -6
  4. package/dist/cjs/core/PersistenceManager.js.map +1 -1
  5. package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
  6. package/dist/cjs/core/ResponseModal.js +15 -3
  7. package/dist/cjs/core/ResponseModal.js.map +1 -1
  8. package/dist/cjs/core/SessionManager.d.ts.map +1 -1
  9. package/dist/cjs/core/SessionManager.js +7 -0
  10. package/dist/cjs/core/SessionManager.js.map +1 -1
  11. package/dist/cjs/core/StreamingToolExecutor.d.ts.map +1 -1
  12. package/dist/cjs/core/StreamingToolExecutor.js +11 -0
  13. package/dist/cjs/core/StreamingToolExecutor.js.map +1 -1
  14. package/dist/cjs/core/ToolLoopExecutor.d.ts +16 -2
  15. package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
  16. package/dist/cjs/core/ToolLoopExecutor.js +115 -54
  17. package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
  18. package/dist/cjs/core/ToolManager.d.ts.map +1 -1
  19. package/dist/cjs/core/ToolManager.js +8 -31
  20. package/dist/cjs/core/ToolManager.js.map +1 -1
  21. package/dist/cjs/core/toolGates.d.ts +24 -0
  22. package/dist/cjs/core/toolGates.d.ts.map +1 -0
  23. package/dist/cjs/core/toolGates.js +52 -0
  24. package/dist/cjs/core/toolGates.js.map +1 -0
  25. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  26. package/dist/cjs/providers/AnthropicProvider.js +24 -3
  27. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  28. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  29. package/dist/cjs/providers/GeminiProvider.js +24 -3
  30. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  31. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  32. package/dist/cjs/providers/OpenAICompatibleProvider.js +25 -3
  33. package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
  34. package/dist/cjs/utils/index.d.ts +2 -2
  35. package/dist/cjs/utils/index.d.ts.map +1 -1
  36. package/dist/cjs/utils/index.js +2 -1
  37. package/dist/cjs/utils/index.js.map +1 -1
  38. package/dist/cjs/utils/retry.d.ts +21 -0
  39. package/dist/cjs/utils/retry.d.ts.map +1 -1
  40. package/dist/cjs/utils/retry.js +37 -0
  41. package/dist/cjs/utils/retry.js.map +1 -1
  42. package/dist/core/PersistenceManager.d.ts +13 -0
  43. package/dist/core/PersistenceManager.d.ts.map +1 -1
  44. package/dist/core/PersistenceManager.js +29 -6
  45. package/dist/core/PersistenceManager.js.map +1 -1
  46. package/dist/core/ResponseModal.d.ts.map +1 -1
  47. package/dist/core/ResponseModal.js +15 -3
  48. package/dist/core/ResponseModal.js.map +1 -1
  49. package/dist/core/SessionManager.d.ts.map +1 -1
  50. package/dist/core/SessionManager.js +7 -0
  51. package/dist/core/SessionManager.js.map +1 -1
  52. package/dist/core/StreamingToolExecutor.d.ts.map +1 -1
  53. package/dist/core/StreamingToolExecutor.js +11 -0
  54. package/dist/core/StreamingToolExecutor.js.map +1 -1
  55. package/dist/core/ToolLoopExecutor.d.ts +16 -2
  56. package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
  57. package/dist/core/ToolLoopExecutor.js +115 -54
  58. package/dist/core/ToolLoopExecutor.js.map +1 -1
  59. package/dist/core/ToolManager.d.ts.map +1 -1
  60. package/dist/core/ToolManager.js +8 -31
  61. package/dist/core/ToolManager.js.map +1 -1
  62. package/dist/core/toolGates.d.ts +24 -0
  63. package/dist/core/toolGates.d.ts.map +1 -0
  64. package/dist/core/toolGates.js +49 -0
  65. package/dist/core/toolGates.js.map +1 -0
  66. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  67. package/dist/providers/AnthropicProvider.js +25 -4
  68. package/dist/providers/AnthropicProvider.js.map +1 -1
  69. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  70. package/dist/providers/GeminiProvider.js +25 -4
  71. package/dist/providers/GeminiProvider.js.map +1 -1
  72. package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  73. package/dist/providers/OpenAICompatibleProvider.js +26 -4
  74. package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
  75. package/dist/utils/index.d.ts +2 -2
  76. package/dist/utils/index.d.ts.map +1 -1
  77. package/dist/utils/index.js +1 -1
  78. package/dist/utils/index.js.map +1 -1
  79. package/dist/utils/retry.d.ts +21 -0
  80. package/dist/utils/retry.d.ts.map +1 -1
  81. package/dist/utils/retry.js +36 -0
  82. package/dist/utils/retry.js.map +1 -1
  83. package/package.json +1 -1
  84. package/src/core/PersistenceManager.ts +30 -6
  85. package/src/core/ResponseModal.ts +16 -3
  86. package/src/core/SessionManager.ts +7 -0
  87. package/src/core/StreamingToolExecutor.ts +12 -0
  88. package/src/core/ToolLoopExecutor.ts +133 -58
  89. package/src/core/ToolManager.ts +8 -32
  90. package/src/core/toolGates.ts +72 -0
  91. package/src/providers/AnthropicProvider.ts +35 -8
  92. package/src/providers/GeminiProvider.ts +37 -4
  93. package/src/providers/OpenAICompatibleProvider.ts +36 -8
  94. package/src/utils/index.ts +2 -2
  95. package/src/utils/retry.ts +55 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falai/agent",
3
- "version": "2.4.2",
3
+ "version": "2.4.3",
4
4
  "description": "Conversational state engine for TypeScript where the AI understands, but the code is in control",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -42,6 +42,24 @@ export class PersistenceManager<TData = Record<string, unknown>> {
42
42
  this.messageRepository = config.adapter.messageRepository;
43
43
  }
44
44
 
45
+ /**
46
+ * Reject a non-string id before it reaches a repository query filter.
47
+ *
48
+ * SECURITY: session/user ids flow into adapter query VALUE positions (e.g.
49
+ * Mongo `findOne({ id })`). A non-string value such as `{ $ne: null }` —
50
+ * trivially produced by an HTTP body or an Express `?id[$ne]=` query — would
51
+ * be interpreted by a NoSQL driver as query operators, turning an equality
52
+ * lookup into an operator query (cross-tenant read / mass delete). Callers
53
+ * type these as `string`, but that guarantee is erased at runtime, so we
54
+ * enforce it here: the single layer every public session/user lookup funnels
55
+ * through.
56
+ */
57
+ private assertScalarId(value: unknown, label: string): asserts value is string {
58
+ if (typeof value !== "string" || value.length === 0) {
59
+ throw new Error(`${label} must be a non-empty string`);
60
+ }
61
+ }
62
+
45
63
  /**
46
64
  * Create a new session
47
65
  */
@@ -67,6 +85,7 @@ export class PersistenceManager<TData = Record<string, unknown>> {
67
85
  * Get session by ID
68
86
  */
69
87
  async getSession(sessionId: string): Promise<SessionData<TData> | null> {
88
+ this.assertScalarId(sessionId, "Session ID");
70
89
  return await this.sessionRepository.findById(sessionId);
71
90
  }
72
91
 
@@ -80,6 +99,7 @@ export class PersistenceManager<TData = Record<string, unknown>> {
80
99
  "userId must be provided or configured in PersistenceConfig"
81
100
  );
82
101
  }
102
+ this.assertScalarId(effectiveUserId, "User ID");
83
103
  return await this.sessionRepository.findActiveByUserId(effectiveUserId);
84
104
  }
85
105
 
@@ -96,6 +116,7 @@ export class PersistenceManager<TData = Record<string, unknown>> {
96
116
  "userId must be provided or configured in PersistenceConfig"
97
117
  );
98
118
  }
119
+ this.assertScalarId(effectiveUserId, "User ID");
99
120
  return await this.sessionRepository.findByUserId(effectiveUserId, limit);
100
121
  }
101
122
 
@@ -170,6 +191,7 @@ export class PersistenceManager<TData = Record<string, unknown>> {
170
191
  sessionId: string,
171
192
  limit?: number
172
193
  ): Promise<MessageData[]> {
194
+ this.assertScalarId(sessionId, "Session ID");
173
195
  return await this.messageRepository.findBySessionId(sessionId, limit);
174
196
  }
175
197
 
@@ -186,6 +208,7 @@ export class PersistenceManager<TData = Record<string, unknown>> {
186
208
  "userId must be provided or configured in PersistenceConfig"
187
209
  );
188
210
  }
211
+ this.assertScalarId(effectiveUserId, "User ID");
189
212
  return await this.messageRepository.findByUserId(effectiveUserId, limit);
190
213
  }
191
214
 
@@ -193,6 +216,7 @@ export class PersistenceManager<TData = Record<string, unknown>> {
193
216
  * Delete a session and all its messages
194
217
  */
195
218
  async deleteSession(sessionId: string): Promise<boolean> {
219
+ this.assertScalarId(sessionId, "Session ID");
196
220
  // Delete all messages first
197
221
  await this.messageRepository.deleteBySessionId(sessionId);
198
222
 
@@ -260,9 +284,7 @@ export class PersistenceManager<TData = Record<string, unknown>> {
260
284
  sessionStep: SessionState<TData>
261
285
  ): Promise<SessionData<TData> | null> {
262
286
  // Validate input parameters
263
- if (!sessionId || typeof sessionId !== 'string') {
264
- throw new Error('Session ID must be a non-empty string');
265
- }
287
+ this.assertScalarId(sessionId, "Session ID");
266
288
 
267
289
  if (!sessionStep || typeof sessionStep !== 'object') {
268
290
  throw new Error('Session step must be a valid object');
@@ -307,9 +329,10 @@ export class PersistenceManager<TData = Record<string, unknown>> {
307
329
  } else {
308
330
  saved = await this.sessionRepository.create({
309
331
  id: sessionId,
310
- userId: persistenceData.collectedData.metadata?.userId
311
- ? JSON.stringify(persistenceData.collectedData.metadata?.userId)
312
- : this.config.userId,
332
+ // Owner is the authenticated principal from config — never derived
333
+ // from collectedData.metadata (model/user-influenced state), which
334
+ // would let collected data mis-attribute a session's tenant.
335
+ userId: this.config.userId,
313
336
  status: "active",
314
337
  currentFlow: persistenceData.currentFlow,
315
338
  currentStep: persistenceData.currentStep,
@@ -342,6 +365,7 @@ export class PersistenceManager<TData = Record<string, unknown>> {
342
365
  async loadSessionState(
343
366
  sessionId: string
344
367
  ): Promise<SessionState<TData> | null> {
368
+ this.assertScalarId(sessionId, "Session ID");
345
369
  const sessionData = await this.sessionRepository.findById(sessionId);
346
370
 
347
371
  if (!sessionData) {
@@ -1243,6 +1243,10 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1243
1243
  // Stream chunks with unified tool handling
1244
1244
  for await (const chunk of stream) {
1245
1245
  let toolCalls: Array<{ toolName: string; arguments: Record<string, unknown> }> | undefined = undefined;
1246
+ // Final message/structured may be replaced by a forced post-tool
1247
+ // response (see runStreamingBatch / gap: tools-ran-but-no-text).
1248
+ let finalAccumulated = chunk.accumulated;
1249
+ let finalStructured = chunk.structured;
1246
1250
 
1247
1251
  // Extract tool calls from AI response on final chunk
1248
1252
  if (chunk.done && chunk.structured?.toolCalls) {
@@ -1265,9 +1269,18 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1265
1269
  });
1266
1270
  session = batchResult.session;
1267
1271
  toolCalls = batchResult.toolCalls;
1272
+
1273
+ // Tools ran but the model produced no result-aware text — use
1274
+ // the forced closing message so we never emit the bare
1275
+ // preamble (or an empty message) as the final response.
1276
+ if (batchResult.finalMessage) {
1277
+ finalAccumulated = batchResult.finalMessage;
1278
+ finalStructured = batchResult.structured ?? finalStructured;
1279
+ }
1268
1280
  }
1269
1281
 
1270
- // Extract collected data on final chunk
1282
+ // Extract collected data on final chunk (from the model's own
1283
+ // structured output for this step, not the forced follow-up)
1271
1284
  if (chunk.done && chunk.structured && nextStep.collect) {
1272
1285
  session = await this.collectDataFromResponse({
1273
1286
  result: { structured: chunk.structured },
@@ -1283,7 +1296,7 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1283
1296
  // - session.currentStep: reflects the executed step
1284
1297
  yield {
1285
1298
  delta: chunk.delta,
1286
- accumulated: chunk.accumulated,
1299
+ accumulated: finalAccumulated,
1287
1300
  done: chunk.done,
1288
1301
  session,
1289
1302
  toolCalls,
@@ -1291,7 +1304,7 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1291
1304
  executedSteps: chunk.done ? [{ id: nextStep.id, flowId: selectedFlow.id }] : undefined,
1292
1305
  stoppedReason: chunk.done ? 'needs_input' : undefined,
1293
1306
  metadata: chunk.metadata,
1294
- structured: chunk.structured,
1307
+ structured: finalStructured,
1295
1308
  appliedInstructions: chunk.done ? appliedInstructions : undefined,
1296
1309
  };
1297
1310
  }
@@ -59,6 +59,13 @@ export class SessionManager<TData = unknown> {
59
59
  * Works for sessionIds that exist, don't exist, or auto-generated IDs
60
60
  */
61
61
  async getOrCreate(sessionId?: string): Promise<SessionState<TData>> {
62
+ // SECURITY: a non-string sessionId (e.g. `{ $ne: null }` from an HTTP body
63
+ // or `?sessionId[$ne]=` query) forwarded into a NoSQL query becomes an
64
+ // operator clause. Reject it at the entry point; `undefined` is allowed and
65
+ // auto-generates an id, and an empty string keeps its fall-back semantics.
66
+ if (sessionId !== undefined && typeof sessionId !== "string") {
67
+ throw new Error("sessionId must be a string");
68
+ }
62
69
  // Use provided sessionId or fall back to default
63
70
  const effectiveSessionId = sessionId || this.defaultSessionId;
64
71
 
@@ -19,6 +19,7 @@ import type {
19
19
  Tool,
20
20
  TrackedTool,
21
21
  } from "../types/tool";
22
+ import { evaluateToolGates } from "./toolGates";
22
23
 
23
24
  /** Options for the StreamingToolExecutor */
24
25
  interface StreamingToolExecutorOptions {
@@ -221,6 +222,17 @@ export class StreamingToolExecutor<TContext = unknown, TData = unknown> {
221
222
  const batchAbortController = this.siblingAbortController;
222
223
 
223
224
  try {
225
+ // Pre-execution gates (validateInput → checkPermissions), shared with
226
+ // ToolManager.executeTool. These MUST run on the streaming path too:
227
+ // when a gate denies, the handler is NOT invoked. Run before any abort
228
+ // wiring so a denial returns cleanly with nothing to unwind.
229
+ const gateDenial = await evaluateToolGates(tool, toolCall.arguments, this.toolContext);
230
+ if (gateDenial) {
231
+ tracked.results.push(gateDenial);
232
+ tracked.status = "completed";
233
+ return;
234
+ }
235
+
224
236
  // Create a combined abort signal from parent + sibling
225
237
  const toolAbortController = new AbortController();
226
238
  const abortTool = () => {
@@ -147,7 +147,9 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
147
147
  }
148
148
 
149
149
  // TOOL LOOP: Allow AI to make follow-up tool calls after initial tool execution
150
- const MAX_TOOL_LOOPS = this.deps.maxToolLoops || 5;
150
+ // `??` so an explicit `maxToolLoops: 0` is honored instead of being
151
+ // clobbered to the default by a falsy-zero check.
152
+ const MAX_TOOL_LOOPS = this.deps.maxToolLoops ?? 5;
151
153
  let toolLoopCount = 0;
152
154
  let hasToolCalls = toolCalls && toolCalls.length > 0;
153
155
  let finalMessage: string | undefined;
@@ -296,63 +298,26 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
296
298
  }
297
299
 
298
300
  // 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.
301
+ // make one more LLM call to generate a proper text response from tool
302
+ // results. This prevents the original tool-invocation message (e.g.
303
+ // "Let me check...") from being returned as the final user-facing
304
+ // response. Shared with the streaming path via forceFinalTextFromTools.
302
305
  if (!finalMessage && toolLoopCount > 0) {
303
306
  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
- });
307
+ const forced = await this.forceFinalTextFromTools({
308
+ history,
309
+ toolResultsMap,
310
+ toolArgsMap,
311
+ responsePrompt,
312
+ responseSchema,
313
+ context,
314
+ signal,
315
+ });
316
+ if (forced.finalMessage) {
317
+ finalMessage = forced.finalMessage;
326
318
  }
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
319
+ if (forced.structured) {
320
+ followUpStructured = forced.structured;
356
321
  }
357
322
  }
358
323
 
@@ -377,12 +342,83 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
377
342
  }
378
343
  }
379
344
 
345
+ /**
346
+ * After tools have executed but the model produced no closing text, make one
347
+ * more LLM call (no tools) that turns the tool results into a user-facing
348
+ * message. Shared by the non-streaming tool loop and the streaming batch so
349
+ * both paths behave identically. Returns an empty object if the call fails
350
+ * or yields nothing, leaving the caller to fall back to its prior message.
351
+ */
352
+ private async forceFinalTextFromTools(params: {
353
+ history: HistoryItem[];
354
+ toolResultsMap: Map<string, string>;
355
+ toolArgsMap: Map<string, Record<string, unknown>>;
356
+ responsePrompt: string;
357
+ responseSchema?: Record<string, unknown>;
358
+ context: TContext;
359
+ signal?: AbortSignal;
360
+ }): Promise<{ finalMessage?: string; structured?: AgentStructuredResponse }> {
361
+ const { history, toolResultsMap, toolArgsMap, responsePrompt, responseSchema, context, signal } = params;
362
+
363
+ // Reconstruct assistant tool_call + tool result pairs so the follow-up
364
+ // call can see what the tools returned.
365
+ const finalToolResultHistoryItems: HistoryItem[] = [];
366
+ 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
+ });
382
+ }
383
+
384
+ const finalHistory = [...history, ...finalToolResultHistoryItems];
385
+ const agentOptions = this.deps.getAgentOptions();
386
+
387
+ try {
388
+ const textResult = await agentOptions.provider.generateMessage({
389
+ prompt: responsePrompt + "\n\nProvide a text response to the user based on the tool results. Do not call any tools.",
390
+ history: finalHistory,
391
+ context,
392
+ tools: [], // No tools - force text response
393
+ parameters: responseSchema ? {
394
+ jsonSchema: responseSchema,
395
+ schemaName: "tool_final_text",
396
+ } : undefined,
397
+ signal,
398
+ });
399
+
400
+ const finalMessage = textResult.structured?.message || textResult.message;
401
+ logger.debug(`[ToolLoopExecutor] Generated final text response from tool results:`, {
402
+ hasMessage: !!finalMessage,
403
+ messageLength: finalMessage?.length || 0,
404
+ });
405
+ return { finalMessage, structured: textResult.structured };
406
+ } catch (error) {
407
+ logger.error(`[ToolLoopExecutor] Failed to generate final text response from tool results:`, error);
408
+ // Leave the caller to fall back to its prior message.
409
+ return {};
410
+ }
411
+ }
412
+
380
413
  /**
381
414
  * Execute the streaming path's initial batch of tool calls concurrently
382
415
  * via ToolManager.executeWithConcurrency, yielding tool-progress chunks.
383
- * Falls back to `runLoop()` when concurrent execution fails.
416
+ * When tools ran but the model produced no closing text, forces a final
417
+ * text response from the tool results (mirroring `runLoop()`). Falls back
418
+ * to `runLoop()` when concurrent execution fails.
384
419
  *
385
- * Returns the updated session and the final tool calls.
420
+ * Returns the updated session, final tool calls, and any forced closing
421
+ * message/structured response.
386
422
  */
387
423
  async *runStreamingBatch(params: {
388
424
  toolCalls: Array<{ toolName: string; arguments: Record<string, unknown> }>;
@@ -407,6 +443,9 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
407
443
  {
408
444
  session: SessionState<TData>;
409
445
  toolCalls: Array<{ toolName: string; arguments: Record<string, unknown> }> | undefined;
446
+ /** Closing message forced from the tool results, if one was generated. */
447
+ finalMessage?: string;
448
+ structured?: AgentStructuredResponse;
410
449
  }
411
450
  > {
412
451
  const { context, history, selectedFlow, step, accumulated, responsePrompt, availableTools, responseSchema, signal } = params;
@@ -419,9 +458,17 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
419
458
  toolName: tc.toolName,
420
459
  arguments: tc.arguments,
421
460
  }));
461
+ // Map request id -> request so completed results can be attributed back
462
+ // to their tool name/arguments for the forced final-text call.
463
+ const requestById = new Map(toolCallRequests.map((r) => [r.id, r]));
464
+ const toolResultsMap = new Map<string, string>();
465
+ const toolArgsMap = new Map<string, Record<string, unknown>>();
422
466
 
423
467
  const historyEvents = historyToEvents(history);
424
468
 
469
+ let finalMessage: string | undefined;
470
+ let structured: AgentStructuredResponse | undefined;
471
+
425
472
  try {
426
473
  for await (const update of this.deps.toolManager.executeWithConcurrency({
427
474
  toolCalls: toolCallRequests,
@@ -450,6 +497,15 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
450
497
  }
451
498
  }
452
499
 
500
+ // Capture tool results for the forced final-text call
501
+ if (update.result) {
502
+ const req = requestById.get(update.toolCallId);
503
+ if (req) {
504
+ toolResultsMap.set(req.toolName, serializeToolResult(update.result));
505
+ toolArgsMap.set(req.toolName, req.arguments);
506
+ }
507
+ }
508
+
453
509
  // Yield progress updates immediately
454
510
  if (update.progress) {
455
511
  yield {
@@ -465,6 +521,23 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
465
521
  }
466
522
 
467
523
  logger.debug(`[ToolLoopExecutor] Concurrent tool execution completed for ${toolCallRequests.length} tools`);
524
+
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
+ }
468
541
  } catch (error) {
469
542
  logger.error(`[ToolLoopExecutor] Concurrent tool execution failed, falling back to sequential:`, error);
470
543
  // Fall back to the unified tool loop on failure
@@ -474,9 +547,11 @@ export class ToolLoopExecutor<TContext = unknown, TData = unknown> {
474
547
  });
475
548
  session = toolResult.session;
476
549
  toolCalls = toolResult.finalToolCalls;
550
+ finalMessage = toolResult.finalMessage;
551
+ structured = toolResult.structured;
477
552
  }
478
553
 
479
- return { session, toolCalls };
554
+ return { session, toolCalls, finalMessage, structured };
480
555
  }
481
556
 
482
557
  /**
@@ -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
+ }