@ariaflowagents/core 0.6.1 → 0.7.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 (157) hide show
  1. package/README.md +58 -5
  2. package/dist/agents/Agent.d.ts +1 -0
  3. package/dist/agents/Agent.d.ts.map +1 -1
  4. package/dist/agents/Agent.js +10 -1
  5. package/dist/agents/Agent.js.map +1 -1
  6. package/dist/agents/CompositeAgent.d.ts +8 -12
  7. package/dist/agents/CompositeAgent.d.ts.map +1 -1
  8. package/dist/agents/CompositeAgent.js +30 -16
  9. package/dist/agents/CompositeAgent.js.map +1 -1
  10. package/dist/agents/FlowAgent.js +2 -2
  11. package/dist/agents/FlowAgent.js.map +1 -1
  12. package/dist/agents/LLMAgent.d.ts.map +1 -1
  13. package/dist/agents/LLMAgent.js +4 -25
  14. package/dist/agents/LLMAgent.js.map +1 -1
  15. package/dist/agents/TriageAgent.d.ts.map +1 -1
  16. package/dist/agents/TriageAgent.js +8 -29
  17. package/dist/agents/TriageAgent.js.map +1 -1
  18. package/dist/callbacks/httpCallback.d.ts +1 -0
  19. package/dist/callbacks/httpCallback.d.ts.map +1 -1
  20. package/dist/callbacks/httpCallback.js +20 -6
  21. package/dist/callbacks/httpCallback.js.map +1 -1
  22. package/dist/callbacks/streamCallback.d.ts +26 -0
  23. package/dist/callbacks/streamCallback.d.ts.map +1 -0
  24. package/dist/callbacks/streamCallback.js +281 -0
  25. package/dist/callbacks/streamCallback.js.map +1 -0
  26. package/dist/flows/FlowManager.d.ts +19 -4
  27. package/dist/flows/FlowManager.d.ts.map +1 -1
  28. package/dist/flows/FlowManager.js +355 -131
  29. package/dist/flows/FlowManager.js.map +1 -1
  30. package/dist/flows/extraction.d.ts +17 -0
  31. package/dist/flows/extraction.d.ts.map +1 -0
  32. package/dist/flows/extraction.js +56 -0
  33. package/dist/flows/extraction.js.map +1 -0
  34. package/dist/flows/index.d.ts +1 -2
  35. package/dist/flows/index.d.ts.map +1 -1
  36. package/dist/flows/index.js +1 -1
  37. package/dist/flows/index.js.map +1 -1
  38. package/dist/flows/validation.d.ts +7 -0
  39. package/dist/flows/validation.d.ts.map +1 -0
  40. package/dist/flows/validation.js +42 -0
  41. package/dist/flows/validation.js.map +1 -0
  42. package/dist/hooks/builtin/metrics.d.ts +4 -34
  43. package/dist/hooks/builtin/metrics.d.ts.map +1 -1
  44. package/dist/hooks/builtin/metrics.js +3 -65
  45. package/dist/hooks/builtin/metrics.js.map +1 -1
  46. package/dist/hooks/helpers.d.ts +8 -47
  47. package/dist/hooks/helpers.d.ts.map +1 -1
  48. package/dist/hooks/helpers.js +38 -104
  49. package/dist/hooks/helpers.js.map +1 -1
  50. package/dist/hooks/index.d.ts +4 -1
  51. package/dist/hooks/index.d.ts.map +1 -1
  52. package/dist/hooks/index.js +2 -0
  53. package/dist/hooks/index.js.map +1 -1
  54. package/dist/index.d.ts +4 -4
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +3 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/processors/ProcessorRunner.d.ts.map +1 -1
  59. package/dist/processors/ProcessorRunner.js +13 -0
  60. package/dist/processors/ProcessorRunner.js.map +1 -1
  61. package/dist/prompts/PromptBuilder.d.ts.map +1 -1
  62. package/dist/prompts/PromptBuilder.js +10 -3
  63. package/dist/prompts/PromptBuilder.js.map +1 -1
  64. package/dist/prompts/types.d.ts +2 -1
  65. package/dist/prompts/types.d.ts.map +1 -1
  66. package/dist/prompts/types.js +46 -20
  67. package/dist/prompts/types.js.map +1 -1
  68. package/dist/runtime/ContextManager.d.ts +1 -1
  69. package/dist/runtime/ContextManager.d.ts.map +1 -1
  70. package/dist/runtime/ContextManager.js +141 -20
  71. package/dist/runtime/ContextManager.js.map +1 -1
  72. package/dist/runtime/ExtractionEngine.d.ts +34 -0
  73. package/dist/runtime/ExtractionEngine.d.ts.map +1 -0
  74. package/dist/runtime/ExtractionEngine.js +155 -0
  75. package/dist/runtime/ExtractionEngine.js.map +1 -0
  76. package/dist/runtime/FlowExecutor.d.ts +51 -0
  77. package/dist/runtime/FlowExecutor.d.ts.map +1 -0
  78. package/dist/runtime/FlowExecutor.js +523 -0
  79. package/dist/runtime/FlowExecutor.js.map +1 -0
  80. package/dist/runtime/InjectionQueue.d.ts +8 -1
  81. package/dist/runtime/InjectionQueue.d.ts.map +1 -1
  82. package/dist/runtime/InjectionQueue.js +33 -0
  83. package/dist/runtime/InjectionQueue.js.map +1 -1
  84. package/dist/runtime/Runtime.d.ts +32 -2
  85. package/dist/runtime/Runtime.d.ts.map +1 -1
  86. package/dist/runtime/Runtime.js +513 -633
  87. package/dist/runtime/Runtime.js.map +1 -1
  88. package/dist/runtime/SessionEventManager.d.ts +17 -0
  89. package/dist/runtime/SessionEventManager.d.ts.map +1 -0
  90. package/dist/runtime/SessionEventManager.js +149 -0
  91. package/dist/runtime/SessionEventManager.js.map +1 -0
  92. package/dist/runtime/SuggestionManager.d.ts +7 -0
  93. package/dist/runtime/SuggestionManager.d.ts.map +1 -0
  94. package/dist/runtime/SuggestionManager.js +50 -0
  95. package/dist/runtime/SuggestionManager.js.map +1 -0
  96. package/dist/runtime/index.d.ts +1 -1
  97. package/dist/runtime/index.d.ts.map +1 -1
  98. package/dist/runtime/index.js +1 -1
  99. package/dist/runtime/index.js.map +1 -1
  100. package/dist/services/MetricsService.d.ts +55 -0
  101. package/dist/services/MetricsService.d.ts.map +1 -0
  102. package/dist/services/MetricsService.js +86 -0
  103. package/dist/services/MetricsService.js.map +1 -0
  104. package/dist/services/TracingService.d.ts +13 -0
  105. package/dist/services/TracingService.d.ts.map +1 -0
  106. package/dist/services/TracingService.js +62 -0
  107. package/dist/services/TracingService.js.map +1 -0
  108. package/dist/session/stores/MemoryStore.js +1 -1
  109. package/dist/session/stores/MemoryStore.js.map +1 -1
  110. package/dist/tools/Tool.d.ts +25 -3
  111. package/dist/tools/Tool.d.ts.map +1 -1
  112. package/dist/tools/Tool.js.map +1 -1
  113. package/dist/tools/errorHandling.d.ts +1 -1
  114. package/dist/tools/errorHandling.d.ts.map +1 -1
  115. package/dist/tools/errorHandling.js +27 -20
  116. package/dist/tools/errorHandling.js.map +1 -1
  117. package/dist/tools/http.d.ts.map +1 -1
  118. package/dist/tools/http.js +53 -17
  119. package/dist/tools/http.js.map +1 -1
  120. package/dist/types/index.d.ts +179 -3
  121. package/dist/types/index.d.ts.map +1 -1
  122. package/dist/types/index.js +1 -0
  123. package/dist/types/index.js.map +1 -1
  124. package/dist/types/telemetry.d.ts +52 -0
  125. package/dist/types/telemetry.d.ts.map +1 -0
  126. package/dist/types/telemetry.js +2 -0
  127. package/dist/types/telemetry.js.map +1 -0
  128. package/dist/utils/aiStream.d.ts +7 -0
  129. package/dist/utils/aiStream.d.ts.map +1 -0
  130. package/dist/utils/aiStream.js +41 -0
  131. package/dist/utils/aiStream.js.map +1 -0
  132. package/dist/utils/chrono.d.ts +3 -46
  133. package/dist/utils/chrono.d.ts.map +1 -1
  134. package/dist/utils/chrono.js.map +1 -1
  135. package/dist/utils/isRecord.d.ts +2 -0
  136. package/dist/utils/isRecord.d.ts.map +1 -0
  137. package/dist/utils/isRecord.js +4 -0
  138. package/dist/utils/isRecord.js.map +1 -0
  139. package/dist/utils/messageNormalization.d.ts +3 -0
  140. package/dist/utils/messageNormalization.d.ts.map +1 -0
  141. package/dist/utils/messageNormalization.js +121 -0
  142. package/dist/utils/messageNormalization.js.map +1 -0
  143. package/dist/utils/streamChunk.d.ts +5 -0
  144. package/dist/utils/streamChunk.d.ts.map +1 -0
  145. package/dist/utils/streamChunk.js +50 -0
  146. package/dist/utils/streamChunk.js.map +1 -0
  147. package/guides/EXAMPLE_VERIFICATION.md +53 -0
  148. package/guides/FLOWS.md +29 -0
  149. package/guides/GETTING_STARTED.md +14 -1
  150. package/guides/README.md +3 -1
  151. package/guides/RUNTIME.md +75 -0
  152. package/guides/TOOLS.md +6 -0
  153. package/package.json +2 -2
  154. package/dist/flows/AgentFlowManager.d.ts +0 -161
  155. package/dist/flows/AgentFlowManager.d.ts.map +0 -1
  156. package/dist/flows/AgentFlowManager.js +0 -448
  157. package/dist/flows/AgentFlowManager.js.map +0 -1
@@ -1,17 +1,26 @@
1
- import { streamText, generateText, streamObject, Output, stepCountIs } from 'ai';
1
+ import crypto from 'node:crypto';
2
+ import { streamText, generateText, Output, stepCountIs } from 'ai';
2
3
  import { z } from 'zod';
3
- import { FlowManager } from '../flows/FlowManager.js';
4
4
  import { createHandoffTool, isHandoffResult } from '../tools/handoff.js';
5
5
  import { isFinalResult } from '../tools/final.js';
6
- import { InjectionQueue, commonInjections } from './InjectionQueue.js';
6
+ import { InjectionQueue, getPolicyProfileInjections } from './InjectionQueue.js';
7
7
  import { checkStopConditions, defaultStopConditions } from '../guards/StopConditions.js';
8
8
  import { HookRunner } from '../hooks/HookRunner.js';
9
9
  import { ToolEnforcer } from '../guards/ToolEnforcer.js';
10
10
  import { defaultEnforcementRules } from '../guards/rules.js';
11
11
  import { MemoryStore } from '../session/stores/MemoryStore.js';
12
12
  import { createHttpCallback } from '../callbacks/httpCallback.js';
13
- import { compileSanitizePattern, renderFlowTemplate } from '../flows/template.js';
13
+ import { createStreamCallbackAdapter } from '../callbacks/streamCallback.js';
14
+ import { compileSanitizePattern } from '../flows/template.js';
14
15
  import { runInputProcessors, runOutputProcessors } from '../processors/ProcessorRunner.js';
16
+ import { getChunkArgs, getChunkErrorMessage, getChunkResult, getChunkToolCallId } from '../utils/streamChunk.js';
17
+ import { normalizeModelMessage } from '../utils/messageNormalization.js';
18
+ import { isRecord } from '../utils/isRecord.js';
19
+ import { ExtractionEngine } from './ExtractionEngine.js';
20
+ import { SessionEventManager } from './SessionEventManager.js';
21
+ import { SuggestionManager } from './SuggestionManager.js';
22
+ import { FlowExecutor } from './FlowExecutor.js';
23
+ import { SessionWorkingMemory } from './WorkingMemory.js';
15
24
  export class Runtime {
16
25
  config;
17
26
  agents = new Map();
@@ -32,9 +41,22 @@ export class Runtime {
32
41
  outputProcessors;
33
42
  outputProcessorMode;
34
43
  outputRedactions;
35
- redactCarryBySession = new Map();
44
+ redactCarryKey = '__ariaRedactCarry';
36
45
  redactLookbehind = 64;
46
+ runtimeEventLogKey = 'runtimeEventLog';
47
+ runtimeSessionTurnKey = '__ariaSessionTurn';
48
+ runtimeEventLogMaxEntries = 2000;
49
+ checkpointEventTypes = new Set([
50
+ 'tool-result',
51
+ 'tool-error',
52
+ 'flow-transition',
53
+ ]);
54
+ extractionEngine;
55
+ sessionEventManager;
56
+ suggestionManager;
57
+ flowExecutor;
37
58
  wrapToolsWithEnforcement(context, tools) {
59
+ console.log('[Runtime] Wrapping tools:', Object.keys(tools));
38
60
  const wrapped = {};
39
61
  for (const [toolName, toolDef] of Object.entries(tools ?? {})) {
40
62
  const exec = toolDef?.execute;
@@ -45,10 +67,15 @@ export class Runtime {
45
67
  wrapped[toolName] = {
46
68
  ...toolDef,
47
69
  execute: async (args, options) => {
70
+ const toolCallId = typeof options?.toolCallId === 'string'
71
+ ? options.toolCallId
72
+ : crypto.randomUUID();
73
+ const idempotencyKey = this.buildToolIdempotencyKey(context, toolName, toolCallId);
48
74
  const callRecord = {
49
- toolCallId: options?.toolCallId ?? crypto.randomUUID(),
75
+ toolCallId,
50
76
  toolName,
51
77
  args,
78
+ idempotencyKey,
52
79
  success: true,
53
80
  timestamp: Date.now(),
54
81
  };
@@ -66,7 +93,15 @@ export class Runtime {
66
93
  throw callRecord.error;
67
94
  }
68
95
  // Preserve AI SDK tool execution context (toolCallId, messages, experimental_context, etc.).
69
- return exec(args, options);
96
+ const enrichedOptions = this.withToolExecutionMetadata(options, context, toolName, toolCallId, idempotencyKey);
97
+ try {
98
+ console.log(`[Runtime] Executing tool ${toolName}...`);
99
+ return await exec(args, enrichedOptions);
100
+ }
101
+ catch (error) {
102
+ console.error(`[Runtime] Tool execution failed for ${toolName}:`, error);
103
+ throw error;
104
+ }
70
105
  },
71
106
  };
72
107
  }
@@ -84,20 +119,31 @@ export class Runtime {
84
119
  this.stopConditions = config.stopConditions ?? defaultStopConditions;
85
120
  this._sessionStore = config.sessionStore ?? new MemoryStore();
86
121
  const hooks = { ...config.hooks };
87
- if (config.callback) {
88
- console.log('[Runtime] HTTP callback configured for:', config.callback.url);
89
- console.log('[Runtime] Callback config:', JSON.stringify(config.callback, null, 2));
90
- console.log('[Runtime] Hooks before wiring:', Object.keys(hooks));
91
- const httpCallback = createHttpCallback(config.callback);
122
+ const appendOnStreamPart = (fn) => {
92
123
  const originalHook = hooks.onStreamPart;
93
124
  hooks.onStreamPart = async (context, part) => {
94
- console.log('[Runtime] onStreamPart triggered for:', part.type);
95
125
  if (originalHook) {
96
126
  await originalHook(context, part);
97
127
  }
98
- await httpCallback(context, part);
128
+ await fn(context, part);
129
+ };
130
+ };
131
+ if (config.callback) {
132
+ const httpCallback = createHttpCallback(config.callback);
133
+ appendOnStreamPart(httpCallback);
134
+ }
135
+ if (config.streamCallback) {
136
+ const adapter = createStreamCallbackAdapter(config.streamCallback);
137
+ appendOnStreamPart(adapter.onStreamPart);
138
+ const originalOnEnd = hooks.onEnd;
139
+ hooks.onEnd = async (context, result) => {
140
+ if (originalOnEnd) {
141
+ await originalOnEnd(context, result);
142
+ }
143
+ if (config.streamCallback?.flushOnEnd) {
144
+ await adapter.flush(config.streamCallback.flushTimeoutMs ?? 2000);
145
+ }
99
146
  };
100
- console.log('[Runtime] Hooks after wiring:', Object.keys(hooks));
101
147
  }
102
148
  this.hookRunner = new HookRunner(hooks);
103
149
  this.enforcer = new ToolEnforcer(config.enforcementRules ?? defaultEnforcementRules);
@@ -115,6 +161,16 @@ export class Runtime {
115
161
  return { re, replacement: r.replacement };
116
162
  });
117
163
  }
164
+ this.extractionEngine = new ExtractionEngine({
165
+ defaultModel: this.defaultModel,
166
+ telemetry: config.telemetry,
167
+ });
168
+ this.sessionEventManager = new SessionEventManager({
169
+ touchSession: session => this.touchSession(session),
170
+ getSessionTurn: session => this.getSessionTurn(session),
171
+ });
172
+ this.suggestionManager = new SuggestionManager(config);
173
+ this.flowExecutor = new FlowExecutor(config);
118
174
  }
119
175
  get sessionStore() {
120
176
  return this._sessionStore;
@@ -176,12 +232,8 @@ export class Runtime {
176
232
  consecutiveErrors: 0,
177
233
  toolCallHistory: [],
178
234
  };
179
- const injectionQueue = new InjectionQueue();
180
- injectionQueue.add(commonInjections.professional);
181
- injectionQueue.add(commonInjections.noGuessing);
182
- injectionQueue.add(commonInjections.noSecrets);
183
- injectionQueue.add(commonInjections.invisibleHandoffs);
184
- injectionQueue.add(commonInjections.confirmDestructive);
235
+ this.bumpSessionTurn(session);
236
+ const injectionQueue = this.buildPolicyInjectionQueue();
185
237
  // Yield input through emit so hooks see it (once).
186
238
  yield* this.emit(context, { type: 'input', text: input, userId: session.userId || undefined });
187
239
  const controller = new AbortController();
@@ -190,11 +242,17 @@ export class Runtime {
190
242
  this.abortControllers.delete(session.id);
191
243
  };
192
244
  controller.signal.addEventListener('abort', abortHandler);
245
+ let externalAbortHandler;
193
246
  if (abortSignal) {
194
- const externalAbortHandler = () => {
247
+ externalAbortHandler = () => {
195
248
  controller.abort(abortSignal.reason ?? 'External abort');
196
249
  };
197
- abortSignal.addEventListener('abort', externalAbortHandler);
250
+ if (abortSignal.aborted) {
251
+ controller.abort(abortSignal.reason ?? 'External abort');
252
+ }
253
+ else {
254
+ abortSignal.addEventListener('abort', externalAbortHandler);
255
+ }
198
256
  }
199
257
  await this.hookRunner.onStart(context);
200
258
  // Run input processors BEFORE persisting the user message.
@@ -210,6 +268,7 @@ export class Runtime {
210
268
  session,
211
269
  agentId: activeAgentId,
212
270
  toolCallHistory: context.toolCallHistory,
271
+ abortSignal: controller.signal,
213
272
  };
214
273
  const outcome = await runInputProcessors({
215
274
  processors: turnInputProcessors,
@@ -233,18 +292,19 @@ export class Runtime {
233
292
  processedInput = outcome.input;
234
293
  }
235
294
  // Persist the (possibly modified) user message after input processors.
236
- session.messages.push({ role: 'user', content: processedInput });
295
+ this.appendSessionMessage(session, { role: 'user', content: processedInput });
237
296
  if (this.contextManager) {
238
297
  const messagesBefore = session.messages.length;
239
298
  const totalTokens = session.messages.reduce((sum, m) => {
240
299
  const text = typeof m.content === 'string' ? m.content : '';
241
300
  return sum + Math.ceil(text.length / 4);
242
301
  }, 0);
243
- session.messages = this.contextManager.beforeTurn(session.messages, {
302
+ const compacted = await this.contextManager.beforeTurn(session.messages, {
244
303
  turnCount: this.turnCount,
245
304
  totalTokens,
246
305
  sessionId: session.id,
247
306
  });
307
+ session.messages = this.normalizeSessionHistory(compacted);
248
308
  const messagesAfter = session.messages.length;
249
309
  if (messagesBefore !== messagesAfter) {
250
310
  yield* this.emit(context, {
@@ -261,86 +321,75 @@ export class Runtime {
261
321
  await this.hookRunner.onEnd(context, { success: true });
262
322
  }
263
323
  catch (error) {
264
- await this.hookRunner.onError(context, error);
265
- await this.hookRunner.onEnd(context, { success: false, error: error });
266
- yield* this.emit(context, { type: 'error', error: error.message });
324
+ if (controller.signal.aborted) {
325
+ yield* this.emit(context, {
326
+ type: 'interrupted',
327
+ sessionId: context.session.id,
328
+ reason: controller.signal.reason ?? 'Operation cancelled',
329
+ timestamp: new Date(),
330
+ lastAgentId: context.agentId,
331
+ lastStep: context.stepCount,
332
+ });
333
+ await this.hookRunner.onEnd(context, { success: false });
334
+ }
335
+ else {
336
+ await this.hookRunner.onError(context, error);
337
+ await this.hookRunner.onEnd(context, { success: false, error: error });
338
+ yield* this.emit(context, { type: 'error', error: error.message });
339
+ }
267
340
  }
268
341
  finally {
269
342
  controller.signal.removeEventListener('abort', abortHandler);
343
+ if (abortSignal && externalAbortHandler) {
344
+ abortSignal.removeEventListener('abort', externalAbortHandler);
345
+ }
270
346
  this.abortControllers.delete(session.id);
271
- await this._sessionStore.save(session);
347
+ await this.saveSessionCheckpoint(session);
272
348
  yield* this.emit(context, { type: 'done', sessionId: session.id });
349
+ await this.saveSessionCheckpoint(session);
273
350
  }
274
351
  }
275
352
  async *generateSuggestions(context) {
276
- const suggestionModel = this.config.suggestionModel;
277
- if (!suggestionModel)
278
- return;
279
- const count = this.config.suggestionCount ?? 3;
280
- const prompt = this.config.suggestionPrompt ??
281
- `Based on the conversation above, suggest ${count} short, relevant follow-up questions or actions the user might want to take next.
282
- Keep each suggestion under 5 words.
283
- Return them as an array of strings.`;
284
- try {
285
- const result = streamObject({
286
- model: suggestionModel,
287
- schema: z.object({
288
- suggestions: z.array(z.string()).min(1).max(count),
289
- }),
290
- messages: [
291
- ...context.session.messages,
292
- { role: 'user', content: prompt }
293
- ],
294
- });
295
- for await (const partialObject of result.partialObjectStream) {
296
- if (partialObject.suggestions) {
297
- yield {
298
- type: 'suggested-questions',
299
- suggestions: partialObject.suggestions.filter((s) => !!s),
300
- isPartial: true
301
- };
302
- }
303
- }
304
- const finalObject = await result.object;
305
- yield {
306
- type: 'suggested-questions',
307
- suggestions: finalObject.suggestions,
308
- isPartial: false
309
- };
310
- }
311
- catch (error) {
312
- console.error('[Runtime] Failed to generate suggestions:', error);
313
- // Don't yield error to user, just skip suggestions
314
- }
353
+ yield* this.suggestionManager.generateSuggestions(context);
315
354
  }
316
355
  async *chat(sessionId, input, userId) {
317
356
  yield* this.stream({ input, sessionId, userId });
318
357
  }
319
358
  async *runLoop(context, injectionQueue, input, abortController) {
320
359
  let currentInput = input;
360
+ const abortSignal = abortController?.signal;
361
+ let interruptionEmitted = false;
362
+ let circularRecoveryActive = false;
363
+ let circularRecoveryAttempts = 0;
321
364
  while (context.handoffStack.length < this.maxHandoffs) {
322
- if (abortController?.signal.aborted) {
323
- yield* this.emit(context, {
324
- type: 'interrupted',
325
- sessionId: context.session.id,
326
- reason: abortController.signal.reason ?? 'Operation cancelled',
327
- timestamp: new Date(),
328
- lastAgentId: context.agentId,
329
- lastStep: context.stepCount,
330
- });
365
+ if (abortSignal?.aborted) {
366
+ if (!interruptionEmitted) {
367
+ interruptionEmitted = true;
368
+ yield* this.emit(context, {
369
+ type: 'interrupted',
370
+ sessionId: context.session.id,
371
+ reason: abortSignal.reason ?? 'Operation cancelled',
372
+ timestamp: new Date(),
373
+ lastAgentId: context.agentId,
374
+ lastStep: context.stepCount,
375
+ });
376
+ }
331
377
  return;
332
378
  }
333
379
  // Allow a single "bounce back" (A -> B -> A) for detours and multi-intent turns.
334
380
  // Stop only when an agent would be visited a third time in the same user turn.
335
381
  const priorVisits = context.handoffStack.filter(id => id === context.agentId).length;
336
- if (priorVisits >= 2) {
382
+ if (priorVisits >= 2 && !circularRecoveryActive) {
337
383
  const err = `Circular handoff detected: ${context.handoffStack.join(' -> ')} -> ${context.agentId}`;
338
384
  yield* this.emit(context, { type: 'error', error: err });
339
- const fallback = "I ran into an internal routing issue. Please rephrase your request in one sentence. " +
340
- "For security, don't share passwords, API keys, or card numbers/CVV here.";
341
- context.session.messages.push({ role: 'assistant', content: fallback });
342
- yield* this.emit(context, { type: 'text-delta', text: fallback });
343
- return;
385
+ circularRecoveryAttempts += 1;
386
+ if (circularRecoveryAttempts > 1) {
387
+ yield* this.emitCircularHandoffFallback(context);
388
+ return;
389
+ }
390
+ circularRecoveryActive = true;
391
+ // Reset path tracking for this turn and force a single in-agent recovery pass.
392
+ context.handoffStack = [];
344
393
  }
345
394
  context.handoffStack.push(context.agentId);
346
395
  const agent = this.agents.get(context.agentId);
@@ -355,6 +404,7 @@ export class Runtime {
355
404
  session: context.session,
356
405
  agentId: agent.id,
357
406
  toolCallHistory: context.toolCallHistory,
407
+ abortSignal,
358
408
  };
359
409
  const outcome = await runInputProcessors({
360
410
  processors: agent.inputProcessors,
@@ -379,6 +429,7 @@ export class Runtime {
379
429
  const last = context.session.messages[context.session.messages.length - 1];
380
430
  if (last && last.role === 'user' && typeof last.content === 'string') {
381
431
  last.content = currentInput;
432
+ this.touchSession(context.session);
382
433
  }
383
434
  }
384
435
  }
@@ -388,10 +439,12 @@ export class Runtime {
388
439
  if (agent.autoRetrieve) {
389
440
  const toolName = agent.autoRetrieve.toolName ?? 'auto_retrieve';
390
441
  const toolCallId = crypto.randomUUID();
442
+ const idempotencyKey = this.buildToolIdempotencyKey(context, toolName, toolCallId);
391
443
  const callRecord = {
392
444
  toolCallId,
393
445
  toolName,
394
446
  args: { input: currentInput },
447
+ idempotencyKey,
395
448
  success: true,
396
449
  timestamp: Date.now(),
397
450
  };
@@ -410,7 +463,7 @@ export class Runtime {
410
463
  });
411
464
  let result = null;
412
465
  try {
413
- result = await agent.autoRetrieve.run({ input: currentInput, context });
466
+ result = await agent.autoRetrieve.run({ input: currentInput, context, abortSignal });
414
467
  callRecord.result = result;
415
468
  }
416
469
  catch (error) {
@@ -449,8 +502,36 @@ export class Runtime {
449
502
  };
450
503
  }
451
504
  }
452
- const system = this.buildSystemPrompt(agent, injectionQueue, autoContext, context);
453
- const handoffCandidates = this.getHandoffCandidates(agent);
505
+ const extractionSnapshot = await this.runTurnExtraction(agent, context, currentInput, abortSignal);
506
+ let activeRoutes;
507
+ if (this.isTriageAgent(agent)) {
508
+ const agentContext = this.buildAgentContext(currentInput, context, abortSignal);
509
+ const results = await Promise.all(agent.routes.map(async (route) => {
510
+ if (!route.condition)
511
+ return { route, active: true };
512
+ try {
513
+ const active = await route.condition(currentInput, agentContext);
514
+ return { route, active };
515
+ }
516
+ catch (error) {
517
+ console.error(`Error evaluating condition for route ${route.agentId}:`, error);
518
+ return { route, active: false }; // Fail-closed on error
519
+ }
520
+ }));
521
+ activeRoutes = results.filter(r => r.active).map(r => r.route);
522
+ }
523
+ let system = this.buildSystemPrompt(agent, injectionQueue, autoContext, context, activeRoutes);
524
+ const turnToolErrors = [];
525
+ if (extractionSnapshot?.includeInSystemPrompt) {
526
+ system += this.extractionEngine.buildExtractionSystemBlock(extractionSnapshot);
527
+ }
528
+ if (circularRecoveryActive) {
529
+ system = `${system}\n\n## Routing Recovery
530
+ A circular handoff was detected in this turn.
531
+ Stay with the current agent and provide the best direct answer.
532
+ Do not call or suggest handoff/transfer tools in this response.`;
533
+ }
534
+ const handoffCandidates = circularRecoveryActive ? [] : this.getHandoffCandidates(agent, activeRoutes);
454
535
  const handoffTool = handoffCandidates.length > 0
455
536
  ? createHandoffTool(handoffCandidates, context.agentId)
456
537
  : null;
@@ -475,7 +556,7 @@ export class Runtime {
475
556
  for await (const part of this.runFlowAgent(agent, context, currentInput, system, handoffTool ?? undefined, (target, reason) => {
476
557
  handoffTo = target;
477
558
  handoffReason = reason ?? 'No reason provided';
478
- }, toolCalls)) {
559
+ }, toolCalls, abortSignal)) {
479
560
  yield part;
480
561
  }
481
562
  context.consecutiveErrors = 0;
@@ -491,6 +572,20 @@ export class Runtime {
491
572
  });
492
573
  }
493
574
  catch (error) {
575
+ if (abortSignal?.aborted) {
576
+ if (!interruptionEmitted) {
577
+ interruptionEmitted = true;
578
+ yield* this.emit(context, {
579
+ type: 'interrupted',
580
+ sessionId: context.session.id,
581
+ reason: abortSignal.reason ?? 'Operation cancelled',
582
+ timestamp: new Date(),
583
+ lastAgentId: context.agentId,
584
+ lastStep: context.stepCount,
585
+ });
586
+ }
587
+ return;
588
+ }
494
589
  context.consecutiveErrors += 1;
495
590
  await this.hookRunner.onError(context, error);
496
591
  yield* this.emit(context, { type: 'error', error: error.message });
@@ -525,21 +620,27 @@ export class Runtime {
525
620
  confidence: z.number().min(0).max(1).describe('Routing confidence from 0 to 1.'),
526
621
  stayWithCurrent: z.boolean().describe('True only if the current agent is best fit.'),
527
622
  });
528
- // AI SDK v6+ recommended structured output approach.
529
- const { output: decision } = await generateText({
530
- model: model,
531
- output: Output.object({ schema }),
532
- system: this.buildStructuredTriagePrompt(agent),
533
- messages: context.session.messages,
534
- experimental_telemetry: agent.telemetry ?? this.config.telemetry,
535
- });
536
- const allowed = new Set(agent.routes.map(route => route.agentId));
537
- let target = decision.agentId;
538
- if (!allowed.has(target)) {
539
- target = agent.defaultAgent ?? agent.routes[0]?.agentId ?? agent.id;
623
+ try {
624
+ // AI SDK v6+ recommended structured output approach.
625
+ const { output: decision } = await generateText({
626
+ model: model,
627
+ output: Output.object({ schema }),
628
+ system: this.buildStructuredTriagePrompt(agent, activeRoutes),
629
+ messages: context.session.messages,
630
+ abortSignal,
631
+ experimental_telemetry: agent.telemetry ?? this.config.telemetry,
632
+ });
633
+ const allowed = new Set(agent.routes.map(route => route.agentId));
634
+ let target = decision.agentId;
635
+ if (!allowed.has(target)) {
636
+ target = agent.defaultAgent ?? agent.routes[0]?.agentId ?? agent.id;
637
+ }
638
+ handoffTo = target;
639
+ handoffReason = decision.reason ?? 'Routed by triage';
640
+ }
641
+ catch (err) {
642
+ throw err;
540
643
  }
541
- handoffTo = target;
542
- handoffReason = decision.reason ?? 'Routed by triage';
543
644
  yield* this.emit(context, { type: 'step-end', step: context.stepCount, agentId: agent.id });
544
645
  await this.hookRunner.onStepEnd(context, context.stepCount, {
545
646
  toolCalls: [],
@@ -554,6 +655,7 @@ export class Runtime {
554
655
  system,
555
656
  messages: context.session.messages,
556
657
  tools,
658
+ abortSignal,
557
659
  // Let the AI SDK handle tool-calling steps internally.
558
660
  // Default is stepCountIs(1), which ends right after tool-calls.
559
661
  stopWhen: stepCountIs(agent.toolMaxSteps ?? 5),
@@ -576,6 +678,10 @@ export class Runtime {
576
678
  if (finalResult) {
577
679
  continue;
578
680
  }
681
+ // Fail-closed: Suppress text delta if critical tool errors have occurred in this turn.
682
+ if (turnToolErrors.length > 0) {
683
+ continue;
684
+ }
579
685
  if (bufferOutput) {
580
686
  bufferedText += chunk.text;
581
687
  }
@@ -584,32 +690,12 @@ export class Runtime {
584
690
  }
585
691
  }
586
692
  if (chunk.type === 'tool-call') {
587
- const args = 'args' in chunk ? chunk.args : chunk.input;
588
- // Deduplicate: Skip if same tool with same args was already called in this step
589
- // Normalize args by sorting keys to handle different object key orders
590
- const normalizeArgs = (obj) => {
591
- if (obj === null || obj === undefined)
592
- return String(obj);
593
- if (typeof obj !== 'object')
594
- return String(obj);
595
- if (Array.isArray(obj))
596
- return JSON.stringify(obj.map(normalizeArgs));
597
- const sorted = {};
598
- Object.keys(obj).sort().forEach(key => {
599
- sorted[key] = normalizeArgs(obj[key]);
600
- });
601
- return JSON.stringify(sorted);
602
- };
603
- const isDuplicate = toolCalls.some(existing => existing.toolName === chunk.toolName &&
604
- normalizeArgs(existing.args) === normalizeArgs(args));
605
- if (isDuplicate) {
606
- console.log(`[Runtime] Skipping duplicate tool call: ${chunk.toolName}`);
607
- continue;
608
- }
693
+ const args = getChunkArgs(chunk);
609
694
  const callRecord = {
610
695
  toolCallId: chunk.toolCallId,
611
696
  toolName: chunk.toolName,
612
697
  args,
698
+ idempotencyKey: this.buildToolIdempotencyKey(context, chunk.toolName, chunk.toolCallId),
613
699
  success: true,
614
700
  timestamp: Date.now(),
615
701
  };
@@ -633,11 +719,10 @@ export class Runtime {
633
719
  });
634
720
  }
635
721
  if (chunk.type === 'tool-error') {
636
- const errText = typeof chunk.error === 'string'
637
- ? chunk.error
638
- : chunk.error?.message ?? 'Tool execution error';
639
- const args = chunk.input ?? chunk.args;
640
- const callRecord = toolCalls.find(call => call.toolCallId === chunk.toolCallId);
722
+ const errText = getChunkErrorMessage(chunk);
723
+ const args = getChunkArgs(chunk);
724
+ const toolCallId = getChunkToolCallId(chunk);
725
+ const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
641
726
  if (callRecord) {
642
727
  callRecord.success = false;
643
728
  callRecord.error = new Error(errText);
@@ -645,17 +730,24 @@ export class Runtime {
645
730
  callRecord.args = args;
646
731
  context.toolCallHistory.push(callRecord);
647
732
  await this.hookRunner.onToolError(context, callRecord, callRecord.error);
733
+ // Fail-closed: Track critical tool errors to block turn finalization.
734
+ const toolPolicy = agent.toolPolicies?.[callRecord.toolName];
735
+ const toolDef = agent.tools?.[callRecord.toolName];
736
+ const isCritical = toolPolicy?.critical ?? toolDef?.critical ?? true;
737
+ if (isCritical) {
738
+ turnToolErrors.push(callRecord);
739
+ }
648
740
  }
649
741
  yield* this.emit(context, {
650
742
  type: 'tool-error',
651
- toolCallId: chunk.toolCallId,
743
+ toolCallId: toolCallId ?? chunk.toolCallId,
652
744
  toolName: chunk.toolName,
653
745
  error: errText,
654
746
  });
655
747
  }
656
748
  if (chunk.type === 'tool-result') {
657
749
  const startTime = toolCalls.find(call => call.toolCallId === chunk.toolCallId)?.timestamp ?? Date.now();
658
- const toolResult = 'result' in chunk ? chunk.result : chunk.output;
750
+ const toolResult = getChunkResult(chunk);
659
751
  const callRecord = toolCalls.find(call => call.toolCallId === chunk.toolCallId);
660
752
  if (callRecord) {
661
753
  callRecord.result = toolResult;
@@ -682,6 +774,8 @@ export class Runtime {
682
774
  callRecord.success = false;
683
775
  callRecord.error = new Error(reason);
684
776
  await this.hookRunner.onToolError(context, callRecord, callRecord.error);
777
+ // Fail-closed: Track enforcement failures as critical errors.
778
+ turnToolErrors.push(callRecord);
685
779
  yield* this.emit(context, {
686
780
  type: 'tool-error',
687
781
  toolCallId: chunk.toolCallId,
@@ -743,8 +837,17 @@ export class Runtime {
743
837
  // If the model ended on tool-calls, we must persist the tool messages and
744
838
  // continue the loop (or let AI SDK continue in maxSteps) instead of emitting
745
839
  // synthetic assistant text, which can duplicate responses.
746
- if (finalResult || (bufferOutput && finishReason !== 'tool-calls')) {
747
- const rawText = finalResult ? finalResult.text : bufferedText;
840
+ if (finalResult || (bufferOutput && finishReason !== 'tool-calls') || turnToolErrors.length > 0) {
841
+ let rawText = finalResult ? finalResult.text : bufferedText;
842
+ // Fail-closed: Suppress success message if critical tool failed.
843
+ if (turnToolErrors.length > 0) {
844
+ const failedTools = turnToolErrors.map(e => `\`${e.toolName}\``).join(', ');
845
+ rawText = `I encountered an error while using the following tools: ${failedTools}. I cannot proceed with the requested action at this time.`;
846
+ yield* this.emit(context, {
847
+ type: 'error',
848
+ error: `Turn blocked by critical tool failures: ${failedTools}`,
849
+ });
850
+ }
748
851
  const processed = await this.runOutputProcessing(agent, context, rawText);
749
852
  if (processed.tripwire) {
750
853
  yield* this.emit(context, {
@@ -755,14 +858,14 @@ export class Runtime {
755
858
  message: processed.tripwire.message,
756
859
  });
757
860
  }
758
- if (bufferOutput) {
861
+ if (bufferOutput || turnToolErrors.length > 0) {
759
862
  yield* this.emit(context, { type: 'text-delta', text: processed.text });
760
863
  }
761
- context.session.messages.push({ role: 'assistant', content: processed.text });
864
+ this.appendSessionMessage(context.session, { role: 'assistant', content: processed.text });
762
865
  }
763
866
  else {
764
867
  const beforeLen = context.session.messages.length;
765
- context.session.messages.push(...response.messages);
868
+ this.appendSessionMessages(context.session, response.messages);
766
869
  const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, beforeLen);
767
870
  for (const t of tripwires) {
768
871
  yield* this.emit(context, {
@@ -794,6 +897,21 @@ export class Runtime {
794
897
  }
795
898
  }
796
899
  catch (error) {
900
+ console.error('RUNTIME_LOOP_ERROR:', error);
901
+ if (abortSignal?.aborted) {
902
+ if (!interruptionEmitted) {
903
+ interruptionEmitted = true;
904
+ yield* this.emit(context, {
905
+ type: 'interrupted',
906
+ sessionId: context.session.id,
907
+ reason: abortSignal.reason ?? 'Operation cancelled',
908
+ timestamp: new Date(),
909
+ lastAgentId: context.agentId,
910
+ lastStep: context.stepCount,
911
+ });
912
+ }
913
+ return;
914
+ }
797
915
  context.consecutiveErrors += 1;
798
916
  await this.hookRunner.onError(context, error);
799
917
  yield* this.emit(context, { type: 'error', error: error.message });
@@ -805,6 +923,17 @@ export class Runtime {
805
923
  }
806
924
  yield* this.emit(context, { type: 'agent-end', agentId: agent.id });
807
925
  await this.hookRunner.onAgentEnd(context, agent.id);
926
+ if (circularRecoveryActive && handoffTo) {
927
+ yield* this.emit(context, {
928
+ type: 'error',
929
+ error: `Circular handoff recovery failed: attempted handoff ${context.agentId} -> ${handoffTo}`,
930
+ });
931
+ yield* this.emitCircularHandoffFallback(context);
932
+ return;
933
+ }
934
+ if (circularRecoveryActive) {
935
+ circularRecoveryActive = false;
936
+ }
808
937
  if (!handoffTo) {
809
938
  break;
810
939
  }
@@ -831,12 +960,34 @@ export class Runtime {
831
960
  }
832
961
  context.session.activeAgentId = handoffTo;
833
962
  context.session.currentAgent = handoffTo;
963
+ await this.saveSessionCheckpoint(context.session);
834
964
  context.agentId = handoffTo;
835
965
  }
836
966
  if (context.handoffStack.length >= this.maxHandoffs) {
837
967
  yield* this.emit(context, { type: 'error', error: `Maximum handoffs (${this.maxHandoffs}) exceeded` });
838
968
  }
839
969
  }
970
+ getCircularHandoffFallbackMessage() {
971
+ const configured = this.config.circularHandoffFallbackMessage?.trim();
972
+ if (configured) {
973
+ return configured;
974
+ }
975
+ return 'I hit a routing issue between agents. I will continue here. Tell me the exact outcome you need in one sentence.';
976
+ }
977
+ async *emitCircularHandoffFallback(context) {
978
+ const fallback = this.getCircularHandoffFallbackMessage();
979
+ this.appendSessionMessage(context.session, { role: 'assistant', content: fallback });
980
+ yield* this.emit(context, { type: 'text-delta', text: fallback });
981
+ }
982
+ buildPolicyInjectionQueue() {
983
+ const queue = new InjectionQueue();
984
+ const profile = this.config.policyProfile ?? 'minimal';
985
+ queue.addBatch(getPolicyProfileInjections(profile));
986
+ if (this.config.policyInjections && this.config.policyInjections.length > 0) {
987
+ queue.addBatch(this.config.policyInjections);
988
+ }
989
+ return queue;
990
+ }
840
991
  createSession(id, userId) {
841
992
  const now = new Date();
842
993
  return {
@@ -866,26 +1017,47 @@ export class Runtime {
866
1017
  isTriageAgent(agent) {
867
1018
  return agent.type === 'triage';
868
1019
  }
869
- buildSystemPrompt(agent, injectionQueue, autoContext, context) {
1020
+ buildPromptMemoryView(session, agent) {
1021
+ const memory = session.workingMemory ?? {};
1022
+ const allowlist = agent.promptMemoryAllowlist ?? this.config.promptMemoryAllowlist;
1023
+ const internalKeys = [
1024
+ this.runtimeEventLogKey,
1025
+ this.runtimeSessionTurnKey,
1026
+ this.redactCarryKey,
1027
+ ];
1028
+ const filtered = {};
1029
+ for (const [key, value] of Object.entries(memory)) {
1030
+ if (internalKeys.includes(key)) {
1031
+ continue;
1032
+ }
1033
+ if (allowlist && !allowlist.includes(key)) {
1034
+ continue;
1035
+ }
1036
+ filtered[key] = value;
1037
+ }
1038
+ return filtered;
1039
+ }
1040
+ buildSystemPrompt(agent, injectionQueue, autoContext, context, activeRoutes) {
870
1041
  const basePrompt = this.isTriageAgent(agent)
871
- ? this.buildTriagePrompt(agent)
1042
+ ? this.buildTriagePrompt(agent, activeRoutes)
872
1043
  : agent.systemPrompt;
873
1044
  const autoBlock = autoContext?.text?.trim()
874
1045
  ? `\n\n## ${autoContext.label}\n${autoContext.text}`
875
1046
  : '';
876
1047
  const systemInjections = injectionQueue.getFor('system');
877
- const memory = context?.session?.workingMemory ?? {};
1048
+ const memory = context?.session ? this.buildPromptMemoryView(context.session, agent) : {};
878
1049
  const memoryBlock = Object.keys(memory).length > 0
879
1050
  ? `\n\n## Known Information\n${JSON.stringify(memory, null, 2)}`
880
1051
  : '';
881
1052
  const merged = `${basePrompt}${autoBlock}${memoryBlock}`;
882
1053
  return systemInjections ? `${merged}\n\n${systemInjections}` : merged;
883
1054
  }
884
- buildStructuredTriagePrompt(agent) {
885
- const routeDescriptions = agent.routes
1055
+ buildStructuredTriagePrompt(agent, activeRoutes) {
1056
+ const routes = activeRoutes ?? agent.routes;
1057
+ const routeDescriptions = routes
886
1058
  .map(route => `- ${route.agentId}: ${route.description}`)
887
1059
  .join('\n');
888
- const allowed = agent.routes.map(route => route.agentId);
1060
+ const allowed = routes.map(route => route.agentId);
889
1061
  const defaultNote = agent.defaultAgent ? `Default: ${agent.defaultAgent}` : 'Default: none';
890
1062
  return `${agent.systemPrompt}
891
1063
 
@@ -900,8 +1072,9 @@ Return a JSON object with:
900
1072
  - stayWithCurrent: boolean (true only if current agent is best fit)
901
1073
  ${defaultNote}`;
902
1074
  }
903
- buildTriagePrompt(agent) {
904
- const routeDescriptions = agent.routes
1075
+ buildTriagePrompt(agent, activeRoutes) {
1076
+ const routes = activeRoutes ?? agent.routes;
1077
+ const routeDescriptions = routes
905
1078
  .map(route => `- **${route.agentId}**: ${route.description}`)
906
1079
  .join('\n');
907
1080
  const defaultNote = agent.defaultAgent
@@ -917,9 +1090,10 @@ ${routeDescriptions}
917
1090
  - When the customer needs specialized help, use the handoff tool
918
1091
  - Always provide a brief reason for the handoff${defaultNote}`;
919
1092
  }
920
- getHandoffCandidates(agent) {
1093
+ getHandoffCandidates(agent, activeRoutes) {
921
1094
  if (this.isTriageAgent(agent)) {
922
- return agent.routes
1095
+ const routes = activeRoutes ?? agent.routes;
1096
+ return routes
923
1097
  .map(route => this.agents.get(route.agentId))
924
1098
  .filter((candidate) => Boolean(candidate));
925
1099
  }
@@ -939,15 +1113,45 @@ ${routeDescriptions}
939
1113
  }
940
1114
  return stored;
941
1115
  }
1116
+ buildAgentContext(input, context, abortSignal) {
1117
+ return {
1118
+ session: context.session,
1119
+ messages: context.session.messages,
1120
+ workingMemory: new SessionWorkingMemory(context.session),
1121
+ currentAgent: context.agentId,
1122
+ turnCount: this.turnCount,
1123
+ metadata: context.session.metadata,
1124
+ abortSignal,
1125
+ };
1126
+ }
942
1127
  setFlowState(session, agentId, state) {
943
1128
  session.agentStates[agentId] = {
944
1129
  agentId,
945
1130
  state: state,
946
1131
  lastActive: new Date(),
947
1132
  };
1133
+ this.updateFlowStateSnapshot(session, agentId, state);
1134
+ this.touchSession(session);
948
1135
  }
949
1136
  clearFlowState(session, agentId) {
950
1137
  delete session.agentStates[agentId];
1138
+ this.touchSession(session);
1139
+ }
1140
+ updateFlowStateSnapshot(session, agentId, state) {
1141
+ const existing = session.workingMemory.flowStateByAgent;
1142
+ const byAgent = typeof existing === 'object' && existing !== null
1143
+ ? { ...existing }
1144
+ : {};
1145
+ byAgent[agentId] = {
1146
+ currentNode: state.context.currentNode,
1147
+ collectedData: state.context.collectedData,
1148
+ nodeHistory: state.context.nodeHistory,
1149
+ initialized: state.initialized,
1150
+ flowEnded: state.flowEnded,
1151
+ updatedAt: new Date().toISOString(),
1152
+ };
1153
+ session.workingMemory.flowStateByAgent = byAgent;
1154
+ this.touchSession(session);
951
1155
  }
952
1156
  buildFlowWithHandoff(agent, handoffTool, suppressAutoRespond) {
953
1157
  // If we don't need to inject a handoff tool and we don't need to suppress the initial autoRespond,
@@ -997,64 +1201,74 @@ ${routeDescriptions}
997
1201
  }
998
1202
  return agent.flow.nodes.find(node => node.id === nodeId);
999
1203
  }
1000
- async shouldHandleFlowInput(agent, input, flowState) {
1001
- // Before the flow is initialized, be conservative: run the flow.
1002
- // This avoids misrouting the very first user message after a handoff into "detour",
1003
- // which can lead to unnecessary specialist handoffs and handoff loops.
1004
- if (!flowState?.initialized) {
1005
- return true;
1006
- }
1007
- const nodeId = flowState?.context.currentNode ?? agent.initialNode;
1008
- const node = this.getFlowNode(agent, nodeId);
1009
- if (!node) {
1010
- return true;
1011
- }
1012
- const rules = agent.detourRules;
1013
- if (rules) {
1014
- const normalized = input.trim().toLowerCase();
1015
- if (rules.allowShortAffirmations !== false &&
1016
- /^(yes|yep|yeah|ok|okay|sure|great|thanks|thank you|please|proceed|go ahead)\b/.test(normalized)) {
1017
- return true;
1018
- }
1019
- if (rules.allowDateTime !== false &&
1020
- (/\b20\d{2}-\d{2}-\d{2}\b/.test(normalized) || /\b\d{1,2}:\d{2}\b/.test(normalized))) {
1021
- return true;
1022
- }
1023
- if (this.matchesDetourRule(normalized, rules.deny)) {
1024
- return false;
1025
- }
1026
- if (this.matchesDetourRule(normalized, rules.allow)) {
1027
- return true;
1204
+ getExtractionConfig(agent, session) {
1205
+ if (this.isFlowAgent(agent)) {
1206
+ const flowState = this.getFlowState(session, agent.id);
1207
+ const nodeId = flowState?.context.currentNode ?? agent.initialNode;
1208
+ const node = this.getFlowNode(agent, nodeId);
1209
+ const config = node?.extraction ?? agent.extraction;
1210
+ if (!config) {
1211
+ return null;
1028
1212
  }
1213
+ return { config, nodeId };
1029
1214
  }
1030
- const collectedData = flowState?.context.collectedData ?? {};
1031
- const renderedNodePrompt = renderFlowTemplate(node.prompt, collectedData, { missing: 'keep' });
1032
- const routerPrompt = `You are routing a message for a structured conversation flow.
1033
- Decide if the user input is answering the current flow step or is a side question.
1034
-
1035
- Current flow step:
1036
- ${renderedNodePrompt}
1037
-
1038
- Collected data:
1039
- ${JSON.stringify(collectedData, null, 2)}
1040
-
1041
- Return only one word: "flow" if the input should be handled by the flow step, or "detour" if it should be answered outside the flow then resume.`;
1042
- const result = await generateText({
1043
- model: (agent.model ?? this.defaultModel),
1044
- system: routerPrompt,
1045
- prompt: input,
1046
- experimental_telemetry: agent.telemetry ?? this.config.telemetry,
1047
- });
1048
- const decision = result.text.trim().toLowerCase();
1049
- if (decision.startsWith('detour')) {
1050
- return false;
1051
- }
1052
- if (decision.startsWith('flow')) {
1053
- return true;
1215
+ if (!agent.extraction) {
1216
+ return null;
1054
1217
  }
1055
- return true;
1218
+ return { config: agent.extraction };
1219
+ }
1220
+ async runTurnExtraction(agent, context, input, abortSignal) {
1221
+ return this.extractionEngine.runTurnExtraction(agent, context, input, {
1222
+ getFlowState: (s, id) => this.getFlowState(s, id),
1223
+ setFlowState: (s, id, st) => this.setFlowState(s, id, st),
1224
+ getFlowNode: (a, nid) => this.getFlowNode(a, nid),
1225
+ isFlowAgent: (a) => this.isFlowAgent(a),
1226
+ touchSession: (s) => this.touchSession(s),
1227
+ }, abortSignal);
1228
+ }
1229
+ async shouldHandleFlowInput(agent, input, flowState, abortSignal) {
1230
+ return this.flowExecutor.shouldHandleFlowInput(agent, input, flowState, this.getFlowExecutorHelpers(), abortSignal);
1056
1231
  }
1057
- matchesDetourRule(input, patterns) {
1232
+ async *runDetourResponse(agent, context, input, flowState, handoffTool, onHandoff, abortSignal) {
1233
+ yield* this.flowExecutor.runDetourResponse(agent, context, input, flowState, handoffTool, onHandoff, this.getFlowExecutorHelpers(), abortSignal);
1234
+ }
1235
+ async *runFlowAgent(agent, context, input, systemPrompt, handoffTool, onHandoff, toolCalls, abortSignal) {
1236
+ yield* this.flowExecutor.runFlowAgent(agent, context, input, systemPrompt, handoffTool, onHandoff, toolCalls, this.getFlowExecutorHelpers(), abortSignal);
1237
+ }
1238
+ getFlowExecutorHelpers() {
1239
+ return {
1240
+ emit: (ctx, part) => this.emit(ctx, part),
1241
+ runOutputProcessing: (agent, ctx, text) => this.runOutputProcessing(agent, ctx, text),
1242
+ appendSessionMessage: (s, m) => this.appendSessionMessage(s, m),
1243
+ appendSessionMessages: (s, ms) => this.appendSessionMessages(s, ms),
1244
+ postProcessPersistedAssistantMessages: (agent, ctx, start) => this.postProcessPersistedAssistantMessages(agent, ctx, start),
1245
+ getAgentOutputProcessors: (agent) => this.getAgentOutputProcessors(agent),
1246
+ getSessionTurn: (s) => this.getSessionTurn(s),
1247
+ buildToolIdempotencyKey: (ctx, tn, tcid) => this.buildToolIdempotencyKey(ctx, tn, tcid),
1248
+ setFlowState: (s, id, st) => this.setFlowState(s, id, st),
1249
+ getFlowState: (s, id) => this.getFlowState(s, id),
1250
+ updateFlowStateSnapshot: (s, id, st) => this.updateFlowStateSnapshot(s, id, st),
1251
+ clearFlowState: (s, id) => this.clearFlowState(s, id),
1252
+ buildFlowWithHandoff: (a, ht, s) => this.buildFlowWithHandoff(a, ht, s),
1253
+ getFlowNode: (a, ni) => this.getFlowNode(a, ni),
1254
+ touchSession: (s) => this.touchSession(s),
1255
+ matchesDetourRule: (i, p) => this.matchesDetourRuleHelper(i, p),
1256
+ enforcecheck: (call, ctx) => this.enforcer.check(call, {
1257
+ previousCalls: ctx.toolCallHistory,
1258
+ currentStep: ctx.stepCount,
1259
+ sessionState: ctx.session.state ?? {},
1260
+ }),
1261
+ enforcecheckResult: (call, ctx) => this.enforcer.checkResult(call, {
1262
+ previousCalls: ctx.toolCallHistory,
1263
+ currentStep: ctx.stepCount,
1264
+ sessionState: ctx.session.state ?? {},
1265
+ }),
1266
+ onToolCallHook: (ctx, call) => this.hookRunner.onToolCall(ctx, call),
1267
+ onToolResultHook: (ctx, call) => this.hookRunner.onToolResult(ctx, call),
1268
+ onToolErrorHook: (ctx, call, error) => this.hookRunner.onToolError(ctx, call, error),
1269
+ };
1270
+ }
1271
+ matchesDetourRuleHelper(input, patterns) {
1058
1272
  if (!patterns || patterns.length === 0) {
1059
1273
  return false;
1060
1274
  }
@@ -1073,426 +1287,6 @@ Return only one word: "flow" if the input should be handled by the flow step, or
1073
1287
  }
1074
1288
  return false;
1075
1289
  }
1076
- async *runDetourResponse(agent, context, input, flowState, handoffTool, onHandoff) {
1077
- const nodeId = flowState?.context.currentNode ?? agent.initialNode;
1078
- const node = this.getFlowNode(agent, nodeId);
1079
- const collectedData = flowState?.context.collectedData ?? {};
1080
- const nodePrompt = node
1081
- ? renderFlowTemplate(node.prompt, collectedData, { missing: 'keep' })
1082
- : 'Continue the flow.';
1083
- const handoffLine = handoffTool
1084
- ? 'If the request should be handled by a specialist agent, use the handoff tool instead of answering.'
1085
- : 'Do not attempt to route to other agents. Handle the detour here and then resume the flow.';
1086
- const detourPrompt = `You are handling a short detour during a structured flow.
1087
- Answer the user's question clearly and briefly. ${handoffLine} Then ask the user to continue with the current flow step.
1088
-
1089
- Current flow step:
1090
- ${nodePrompt}
1091
-
1092
- Collected data:
1093
- ${JSON.stringify(collectedData, null, 2)}
1094
-
1095
- Do not change the flow requirements. Keep the reply concise.`;
1096
- const result = streamText({
1097
- model: (agent.model ?? this.defaultModel),
1098
- system: detourPrompt,
1099
- messages: context.session.messages,
1100
- tools: handoffTool ? { handoff: handoffTool } : undefined,
1101
- experimental_telemetry: agent.telemetry ?? this.config.telemetry,
1102
- });
1103
- let handoffTriggered = false;
1104
- const outputProcessors = this.getAgentOutputProcessors(agent);
1105
- const bufferOutput = this.outputProcessorMode === 'buffer' && outputProcessors.length > 0;
1106
- let bufferedText = '';
1107
- for await (const chunk of result.fullStream) {
1108
- if (chunk.type === 'text-delta') {
1109
- if (handoffTriggered) {
1110
- continue;
1111
- }
1112
- if (bufferOutput) {
1113
- bufferedText += chunk.text;
1114
- }
1115
- else {
1116
- yield* this.emit(context, { type: 'text-delta', text: chunk.text });
1117
- }
1118
- }
1119
- if (chunk.type === 'tool-call') {
1120
- const args = 'args' in chunk ? chunk.args : chunk.input;
1121
- yield* this.emit(context, {
1122
- type: 'tool-call',
1123
- toolCallId: chunk.toolCallId,
1124
- toolName: chunk.toolName,
1125
- args,
1126
- });
1127
- }
1128
- if (chunk.type === 'tool-result') {
1129
- const toolResult = 'result' in chunk ? chunk.result : chunk.output;
1130
- yield* this.emit(context, {
1131
- type: 'tool-result',
1132
- toolCallId: chunk.toolCallId,
1133
- toolName: chunk.toolName,
1134
- result: toolResult,
1135
- });
1136
- if (isHandoffResult(toolResult)) {
1137
- const targetAgent = toolResult.targetAgent ?? toolResult.targetAgentId;
1138
- onHandoff(targetAgent, toolResult.reason);
1139
- handoffTriggered = true;
1140
- break;
1141
- }
1142
- }
1143
- }
1144
- const response = await result.response;
1145
- if (bufferOutput) {
1146
- const processed = await this.runOutputProcessing(agent, context, bufferedText);
1147
- if (processed.tripwire) {
1148
- yield* this.emit(context, {
1149
- type: 'tripwire',
1150
- phase: 'output',
1151
- processorId: processed.tripwire.processorId,
1152
- reason: processed.tripwire.reason,
1153
- message: processed.tripwire.message,
1154
- });
1155
- }
1156
- yield* this.emit(context, { type: 'text-delta', text: processed.text });
1157
- context.session.messages.push({ role: 'assistant', content: processed.text });
1158
- }
1159
- else {
1160
- const beforeLen = context.session.messages.length;
1161
- context.session.messages.push(...response.messages);
1162
- const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, beforeLen);
1163
- for (const t of tripwires) {
1164
- yield* this.emit(context, {
1165
- type: 'tripwire',
1166
- phase: 'output',
1167
- processorId: t.processorId,
1168
- reason: t.reason,
1169
- message: t.message,
1170
- });
1171
- }
1172
- }
1173
- const usage = await result.usage;
1174
- const totalTokens = usage.totalTokens ?? 0;
1175
- context.totalTokens += totalTokens;
1176
- if (context.session.metadata) {
1177
- context.session.metadata.totalTokens += totalTokens;
1178
- context.session.metadata.totalSteps += 1;
1179
- }
1180
- if (!handoffTriggered) {
1181
- yield* this.emit(context, { type: 'turn-end' });
1182
- }
1183
- }
1184
- async *runFlowAgent(agent, context, input, systemPrompt, handoffTool, onHandoff, toolCalls) {
1185
- const model = agent.model ?? this.defaultModel;
1186
- if (!model) {
1187
- throw new Error(`Agent "${agent.id}" is missing a model`);
1188
- }
1189
- const detourRules = agent.detourRules;
1190
- if (detourRules?.emergency && this.matchesDetourRule(input, detourRules.emergency)) {
1191
- const emergencyText = detourRules.emergencyMessage
1192
- ?? 'This sounds urgent. Please call local emergency services immediately or go to the nearest emergency room.';
1193
- const processed = await this.runOutputProcessing(agent, context, emergencyText);
1194
- if (processed.tripwire) {
1195
- yield* this.emit(context, {
1196
- type: 'tripwire',
1197
- phase: 'output',
1198
- processorId: processed.tripwire.processorId,
1199
- reason: processed.tripwire.reason,
1200
- message: processed.tripwire.message,
1201
- });
1202
- }
1203
- context.session.messages.push({ role: 'assistant', content: processed.text });
1204
- yield* this.emit(context, { type: 'text-delta', text: processed.text });
1205
- yield* this.emit(context, { type: 'turn-end' });
1206
- if (detourRules.emergencyHandoffAgent) {
1207
- onHandoff(detourRules.emergencyHandoffAgent, 'Emergency pattern matched');
1208
- }
1209
- return;
1210
- }
1211
- const flowState = this.getFlowState(context.session, agent.id);
1212
- if (agent.mode === 'hybrid') {
1213
- const shouldRunFlow = await this.shouldHandleFlowInput(agent, input, flowState);
1214
- if (!shouldRunFlow) {
1215
- yield* this.runDetourResponse(agent, context, input, flowState, handoffTool, onHandoff);
1216
- return;
1217
- }
1218
- }
1219
- const suppressAutoRespond = !flowState?.initialized;
1220
- const flow = this.buildFlowWithHandoff(agent, handoffTool, suppressAutoRespond);
1221
- const flowManager = new FlowManager({
1222
- flow,
1223
- initialNode: agent.initialNode,
1224
- model: model,
1225
- defaultRolePrompt: systemPrompt,
1226
- contextMessages: flowState?.context.messages ?? [],
1227
- sessionMessages: context.session.messages,
1228
- state: flowState,
1229
- telemetry: agent.telemetry ?? this.config.telemetry,
1230
- toolCallGuard: async ({ toolName, args }) => {
1231
- const callRecord = {
1232
- toolCallId: crypto.randomUUID(),
1233
- toolName,
1234
- args,
1235
- success: true,
1236
- timestamp: Date.now(),
1237
- };
1238
- const enforcement = await this.enforcer.check(callRecord, {
1239
- previousCalls: context.toolCallHistory,
1240
- currentStep: context.stepCount,
1241
- sessionState: context.session.state ?? {},
1242
- });
1243
- if (!enforcement.allowed) {
1244
- return { allowed: false, reason: enforcement.reason ?? 'Tool call blocked by enforcement' };
1245
- }
1246
- return { allowed: true };
1247
- },
1248
- });
1249
- let persistedStartIndex = context.session.messages.length;
1250
- if (!flowState?.initialized) {
1251
- for await (const part of flowManager.initialize()) {
1252
- switch (part.type) {
1253
- case 'text-delta':
1254
- yield* this.emit(context, { type: 'text-delta', text: part.text });
1255
- break;
1256
- case 'tool-call': {
1257
- const toolCallId = part.toolCallId ?? crypto.randomUUID();
1258
- const callRecord = {
1259
- toolCallId,
1260
- toolName: part.toolName,
1261
- args: part.args,
1262
- success: true,
1263
- timestamp: Date.now(),
1264
- };
1265
- toolCalls.push(callRecord);
1266
- await this.hookRunner.onToolCall(context, callRecord);
1267
- yield* this.emit(context, {
1268
- type: 'tool-call',
1269
- toolCallId,
1270
- toolName: part.toolName,
1271
- args: part.args,
1272
- });
1273
- break;
1274
- }
1275
- case 'tool-result': {
1276
- const toolCallId = part.toolCallId
1277
- ?? toolCalls.find(call => call.toolName === part.toolName && call.result === undefined)?.toolCallId
1278
- ?? crypto.randomUUID();
1279
- const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
1280
- if (callRecord) {
1281
- callRecord.result = part.result;
1282
- callRecord.durationMs = Date.now() - callRecord.timestamp;
1283
- context.toolCallHistory.push(callRecord);
1284
- await this.hookRunner.onToolResult(context, callRecord);
1285
- const enforcement = await this.enforcer.checkResult(callRecord, {
1286
- previousCalls: context.toolCallHistory,
1287
- currentStep: context.stepCount,
1288
- sessionState: context.session.state ?? {},
1289
- });
1290
- if (!enforcement.allowed) {
1291
- const reason = enforcement.reason ?? 'Tool result blocked by enforcement';
1292
- callRecord.success = false;
1293
- callRecord.error = new Error(reason);
1294
- await this.hookRunner.onToolError(context, callRecord, callRecord.error);
1295
- yield* this.emit(context, {
1296
- type: 'tool-error',
1297
- toolCallId,
1298
- toolName: part.toolName,
1299
- error: reason,
1300
- });
1301
- yield* this.emit(context, { type: 'error', error: reason });
1302
- return;
1303
- }
1304
- }
1305
- yield* this.emit(context, {
1306
- type: 'tool-result',
1307
- toolCallId,
1308
- toolName: part.toolName,
1309
- result: part.result,
1310
- });
1311
- break;
1312
- }
1313
- case 'tool-error': {
1314
- const toolCallId = part.toolCallId
1315
- ?? toolCalls.find(call => call.toolName === part.toolName && call.error === undefined)?.toolCallId
1316
- ?? crypto.randomUUID();
1317
- const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
1318
- if (callRecord) {
1319
- callRecord.success = false;
1320
- callRecord.error = new Error(part.error);
1321
- callRecord.durationMs = Date.now() - callRecord.timestamp;
1322
- context.toolCallHistory.push(callRecord);
1323
- await this.hookRunner.onToolError(context, callRecord, callRecord.error);
1324
- }
1325
- yield* this.emit(context, {
1326
- type: 'tool-error',
1327
- toolCallId,
1328
- toolName: part.toolName,
1329
- error: part.error,
1330
- });
1331
- break;
1332
- }
1333
- case 'handoff':
1334
- onHandoff(part.targetAgent, part.reason);
1335
- break;
1336
- case 'node-enter':
1337
- yield* this.emit(context, { type: 'node-enter', nodeName: part.nodeName });
1338
- break;
1339
- case 'node-exit':
1340
- yield* this.emit(context, { type: 'node-exit', nodeName: part.nodeName });
1341
- break;
1342
- case 'flow-transition':
1343
- yield* this.emit(context, { type: 'flow-transition', from: part.from, to: part.to });
1344
- break;
1345
- case 'flow-end':
1346
- yield* this.emit(context, { type: 'flow-end', reason: part.reason });
1347
- break;
1348
- case 'turn-end':
1349
- yield* this.emit(context, { type: 'turn-end' });
1350
- break;
1351
- case 'error':
1352
- yield* this.emit(context, { type: 'error', error: part.error });
1353
- break;
1354
- default:
1355
- break;
1356
- }
1357
- }
1358
- // Sanitize/redact anything FlowManager persisted during initialization.
1359
- const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, persistedStartIndex);
1360
- for (const t of tripwires) {
1361
- yield* this.emit(context, {
1362
- type: 'tripwire',
1363
- phase: 'output',
1364
- processorId: t.processorId,
1365
- reason: t.reason,
1366
- message: t.message,
1367
- });
1368
- }
1369
- persistedStartIndex = context.session.messages.length;
1370
- }
1371
- for await (const part of flowManager.process(input, { appendUserToSession: false })) {
1372
- switch (part.type) {
1373
- case 'text-delta':
1374
- yield* this.emit(context, { type: 'text-delta', text: part.text });
1375
- break;
1376
- case 'tool-call': {
1377
- const toolCallId = part.toolCallId ?? crypto.randomUUID();
1378
- const callRecord = {
1379
- toolCallId,
1380
- toolName: part.toolName,
1381
- args: part.args,
1382
- success: true,
1383
- timestamp: Date.now(),
1384
- };
1385
- toolCalls.push(callRecord);
1386
- await this.hookRunner.onToolCall(context, callRecord);
1387
- yield* this.emit(context, {
1388
- type: 'tool-call',
1389
- toolCallId,
1390
- toolName: part.toolName,
1391
- args: part.args,
1392
- });
1393
- break;
1394
- }
1395
- case 'tool-result': {
1396
- const toolCallId = part.toolCallId
1397
- ?? toolCalls.find(call => call.toolName === part.toolName && call.result === undefined)?.toolCallId
1398
- ?? crypto.randomUUID();
1399
- const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
1400
- if (callRecord) {
1401
- callRecord.result = part.result;
1402
- callRecord.durationMs = Date.now() - callRecord.timestamp;
1403
- context.toolCallHistory.push(callRecord);
1404
- await this.hookRunner.onToolResult(context, callRecord);
1405
- const enforcement = await this.enforcer.checkResult(callRecord, {
1406
- previousCalls: context.toolCallHistory,
1407
- currentStep: context.stepCount,
1408
- sessionState: context.session.state ?? {},
1409
- });
1410
- if (!enforcement.allowed) {
1411
- const reason = enforcement.reason ?? 'Tool result blocked by enforcement';
1412
- callRecord.success = false;
1413
- callRecord.error = new Error(reason);
1414
- await this.hookRunner.onToolError(context, callRecord, callRecord.error);
1415
- yield* this.emit(context, {
1416
- type: 'tool-error',
1417
- toolCallId,
1418
- toolName: part.toolName,
1419
- error: reason,
1420
- });
1421
- yield* this.emit(context, { type: 'error', error: reason });
1422
- return;
1423
- }
1424
- }
1425
- yield* this.emit(context, {
1426
- type: 'tool-result',
1427
- toolCallId,
1428
- toolName: part.toolName,
1429
- result: part.result,
1430
- });
1431
- break;
1432
- }
1433
- case 'tool-error': {
1434
- const toolCallId = part.toolCallId
1435
- ?? toolCalls.find(call => call.toolName === part.toolName && call.error === undefined)?.toolCallId
1436
- ?? crypto.randomUUID();
1437
- const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
1438
- if (callRecord) {
1439
- callRecord.success = false;
1440
- callRecord.error = new Error(part.error);
1441
- callRecord.durationMs = Date.now() - callRecord.timestamp;
1442
- context.toolCallHistory.push(callRecord);
1443
- await this.hookRunner.onToolError(context, callRecord, callRecord.error);
1444
- }
1445
- yield* this.emit(context, {
1446
- type: 'tool-error',
1447
- toolCallId,
1448
- toolName: part.toolName,
1449
- error: part.error,
1450
- });
1451
- break;
1452
- }
1453
- case 'handoff':
1454
- onHandoff(part.targetAgent, part.reason);
1455
- break;
1456
- case 'node-enter':
1457
- yield* this.emit(context, { type: 'node-enter', nodeName: part.nodeName });
1458
- break;
1459
- case 'node-exit':
1460
- yield* this.emit(context, { type: 'node-exit', nodeName: part.nodeName });
1461
- break;
1462
- case 'flow-transition':
1463
- yield* this.emit(context, { type: 'flow-transition', from: part.from, to: part.to });
1464
- break;
1465
- case 'flow-end':
1466
- yield* this.emit(context, { type: 'flow-end', reason: part.reason });
1467
- break;
1468
- case 'turn-end':
1469
- yield* this.emit(context, { type: 'turn-end' });
1470
- break;
1471
- case 'error':
1472
- yield* this.emit(context, { type: 'error', error: part.error });
1473
- break;
1474
- default:
1475
- break;
1476
- }
1477
- }
1478
- // Sanitize/redact anything FlowManager persisted during this user turn.
1479
- const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, persistedStartIndex);
1480
- for (const t of tripwires) {
1481
- yield* this.emit(context, {
1482
- type: 'tripwire',
1483
- phase: 'output',
1484
- processorId: t.processorId,
1485
- reason: t.reason,
1486
- message: t.message,
1487
- });
1488
- }
1489
- if (flowManager.hasEnded) {
1490
- this.clearFlowState(context.session, agent.id);
1491
- }
1492
- else {
1493
- this.setFlowState(context.session, agent.id, flowManager.getState());
1494
- }
1495
- }
1496
1290
  async getSession(id) {
1497
1291
  return this.sessionStore.get(id);
1498
1292
  }
@@ -1505,11 +1299,89 @@ Do not change the flow requirements. Keep the reply concise.`;
1505
1299
  getAllAgents() {
1506
1300
  return Array.from(this.agents.values());
1507
1301
  }
1508
- buildProcessorContext(context) {
1302
+ normalizeSessionMessage(message) {
1303
+ return normalizeModelMessage(message);
1304
+ }
1305
+ normalizeSessionHistory(messages) {
1306
+ const normalized = [];
1307
+ for (const message of messages) {
1308
+ const next = this.normalizeSessionMessage(message);
1309
+ if (next) {
1310
+ normalized.push(next);
1311
+ }
1312
+ }
1313
+ return normalized;
1314
+ }
1315
+ appendSessionMessage(session, message) {
1316
+ const normalized = this.normalizeSessionMessage(message);
1317
+ if (!normalized) {
1318
+ return;
1319
+ }
1320
+ session.messages.push(normalized);
1321
+ this.touchSession(session);
1322
+ }
1323
+ appendSessionMessages(session, messages) {
1324
+ for (const message of messages) {
1325
+ this.appendSessionMessage(session, message);
1326
+ }
1327
+ }
1328
+ touchSession(session) {
1329
+ const now = new Date();
1330
+ session.updatedAt = now;
1331
+ if (session.metadata) {
1332
+ session.metadata.lastActiveAt = now;
1333
+ }
1334
+ }
1335
+ getSessionTurn(session) {
1336
+ const value = session.workingMemory[this.runtimeSessionTurnKey];
1337
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
1338
+ }
1339
+ bumpSessionTurn(session) {
1340
+ const next = this.getSessionTurn(session) + 1;
1341
+ session.workingMemory[this.runtimeSessionTurnKey] = next;
1342
+ this.touchSession(session);
1343
+ return next;
1344
+ }
1345
+ async saveSessionCheckpoint(session) {
1346
+ this.touchSession(session);
1347
+ await this._sessionStore.save(session);
1348
+ }
1349
+ shouldCheckpointAfterPart(part) {
1350
+ return this.checkpointEventTypes.has(part.type);
1351
+ }
1352
+ recordRuntimeEvent(context, part) {
1353
+ this.sessionEventManager.recordRuntimeEvent(context, part);
1354
+ }
1355
+ buildToolIdempotencyKey(context, toolName, toolCallId) {
1356
+ return `${context.session.id}:${context.agentId}:${context.stepCount}:${toolName}:${toolCallId}`;
1357
+ }
1358
+ withToolExecutionMetadata(options, context, toolName, toolCallId, idempotencyKey) {
1359
+ const baseOptions = isRecord(options) ? options : {};
1360
+ const existingContext = isRecord(baseOptions.experimental_context)
1361
+ ? baseOptions.experimental_context
1362
+ : {};
1363
+ return {
1364
+ ...baseOptions,
1365
+ toolCallId,
1366
+ experimental_context: {
1367
+ ...existingContext,
1368
+ session: context.session,
1369
+ sessionId: context.session.id,
1370
+ agentId: context.agentId,
1371
+ step: context.stepCount,
1372
+ turn: this.getSessionTurn(context.session),
1373
+ toolName,
1374
+ toolCallId,
1375
+ idempotencyKey,
1376
+ },
1377
+ };
1378
+ }
1379
+ buildProcessorContext(context, abortSignal) {
1509
1380
  return {
1510
1381
  session: context.session,
1511
1382
  agentId: context.agentId,
1512
1383
  toolCallHistory: context.toolCallHistory,
1384
+ abortSignal,
1513
1385
  };
1514
1386
  }
1515
1387
  getAgentOutputProcessors(agent) {
@@ -1531,11 +1403,12 @@ Do not change the flow requirements. Keep the reply concise.`;
1531
1403
  const processors = this.getAgentOutputProcessors(agent);
1532
1404
  let cur = text;
1533
1405
  if (processors.length > 0) {
1406
+ const abortSignal = this.getActiveAbortController(context.session.id)?.signal;
1534
1407
  const outcome = await runOutputProcessors({
1535
1408
  processors,
1536
1409
  text: cur,
1537
1410
  messages: context.session.messages,
1538
- context: this.buildProcessorContext(context),
1411
+ context: this.buildProcessorContext(context, abortSignal),
1539
1412
  });
1540
1413
  if (outcome.blocked) {
1541
1414
  const msg = this.applyRedactionsToText(outcome.message);
@@ -1569,46 +1442,53 @@ Do not change the flow requirements. Keep the reply concise.`;
1569
1442
  if (this.outputRedactions && this.outputRedactions.length > 0) {
1570
1443
  const sessionId = context.session.id;
1571
1444
  if (part.type === 'text-delta') {
1572
- const next = this.applyOutputRedactions(sessionId, part.text, false);
1445
+ const next = this.applyOutputRedactions(context.session, part.text, false);
1573
1446
  if (next) {
1574
1447
  const redacted = { ...part, text: next };
1575
- await this.hookRunner.onStreamPart(context, redacted);
1576
- yield redacted;
1448
+ yield* this.emitWithHooks(context, redacted);
1577
1449
  }
1578
1450
  return;
1579
1451
  }
1580
1452
  if (part.type === 'turn-end' || part.type === 'done') {
1581
- const flushed = this.applyOutputRedactions(sessionId, '', true);
1453
+ const flushed = this.applyOutputRedactions(context.session, '', true);
1582
1454
  if (flushed) {
1583
1455
  const carryPart = { type: 'text-delta', text: flushed };
1584
- await this.hookRunner.onStreamPart(context, carryPart);
1585
- yield carryPart;
1456
+ yield* this.emitWithHooks(context, carryPart);
1586
1457
  }
1587
- this.redactCarryBySession.delete(sessionId);
1458
+ delete context.session.workingMemory[this.redactCarryKey];
1588
1459
  }
1589
1460
  }
1461
+ yield* this.emitWithHooks(context, part);
1462
+ }
1463
+ async *emitWithHooks(context, part) {
1464
+ this.recordRuntimeEvent(context, part);
1590
1465
  await this.hookRunner.onStreamPart(context, part);
1466
+ if (this.shouldCheckpointAfterPart(part)) {
1467
+ await this.saveSessionCheckpoint(context.session);
1468
+ }
1591
1469
  yield part;
1592
1470
  }
1593
- applyOutputRedactions(sessionId, text, flush) {
1471
+ applyOutputRedactions(session, text, flush) {
1594
1472
  if (!this.outputRedactions || this.outputRedactions.length === 0)
1595
1473
  return text;
1596
- const carry = this.redactCarryBySession.get(sessionId) ?? '';
1474
+ const carry = typeof session.workingMemory[this.redactCarryKey] === 'string'
1475
+ ? session.workingMemory[this.redactCarryKey]
1476
+ : '';
1597
1477
  let combined = `${carry}${text}`;
1598
1478
  for (const r of this.outputRedactions) {
1599
1479
  combined = combined.replace(r.re, r.replacement);
1600
1480
  }
1601
1481
  const keep = flush ? 0 : this.redactLookbehind;
1602
1482
  if (keep === 0) {
1603
- this.redactCarryBySession.set(sessionId, '');
1483
+ session.workingMemory[this.redactCarryKey] = '';
1604
1484
  return combined;
1605
1485
  }
1606
1486
  if (combined.length <= keep) {
1607
- this.redactCarryBySession.set(sessionId, combined);
1487
+ session.workingMemory[this.redactCarryKey] = combined;
1608
1488
  return '';
1609
1489
  }
1610
1490
  const out = combined.slice(0, combined.length - keep);
1611
- this.redactCarryBySession.set(sessionId, combined.slice(-keep));
1491
+ session.workingMemory[this.redactCarryKey] = combined.slice(-keep);
1612
1492
  return out;
1613
1493
  }
1614
1494
  }