@ariaflowagents/core 0.6.3 → 0.7.1

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 (161) 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 +360 -132
  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/template.d.ts +2 -2
  39. package/dist/flows/template.d.ts.map +1 -1
  40. package/dist/flows/template.js +13 -0
  41. package/dist/flows/template.js.map +1 -1
  42. package/dist/flows/validation.d.ts +7 -0
  43. package/dist/flows/validation.d.ts.map +1 -0
  44. package/dist/flows/validation.js +42 -0
  45. package/dist/flows/validation.js.map +1 -0
  46. package/dist/hooks/builtin/metrics.d.ts +4 -34
  47. package/dist/hooks/builtin/metrics.d.ts.map +1 -1
  48. package/dist/hooks/builtin/metrics.js +3 -65
  49. package/dist/hooks/builtin/metrics.js.map +1 -1
  50. package/dist/hooks/helpers.d.ts +8 -47
  51. package/dist/hooks/helpers.d.ts.map +1 -1
  52. package/dist/hooks/helpers.js +38 -104
  53. package/dist/hooks/helpers.js.map +1 -1
  54. package/dist/hooks/index.d.ts +4 -1
  55. package/dist/hooks/index.d.ts.map +1 -1
  56. package/dist/hooks/index.js +2 -0
  57. package/dist/hooks/index.js.map +1 -1
  58. package/dist/index.d.ts +4 -4
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +3 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/processors/ProcessorRunner.d.ts.map +1 -1
  63. package/dist/processors/ProcessorRunner.js +13 -0
  64. package/dist/processors/ProcessorRunner.js.map +1 -1
  65. package/dist/prompts/PromptBuilder.d.ts.map +1 -1
  66. package/dist/prompts/PromptBuilder.js +1 -0
  67. package/dist/prompts/PromptBuilder.js.map +1 -1
  68. package/dist/prompts/types.d.ts +2 -1
  69. package/dist/prompts/types.d.ts.map +1 -1
  70. package/dist/prompts/types.js +29 -7
  71. package/dist/prompts/types.js.map +1 -1
  72. package/dist/runtime/ContextManager.d.ts +1 -1
  73. package/dist/runtime/ContextManager.d.ts.map +1 -1
  74. package/dist/runtime/ContextManager.js +141 -20
  75. package/dist/runtime/ContextManager.js.map +1 -1
  76. package/dist/runtime/ExtractionEngine.d.ts +34 -0
  77. package/dist/runtime/ExtractionEngine.d.ts.map +1 -0
  78. package/dist/runtime/ExtractionEngine.js +155 -0
  79. package/dist/runtime/ExtractionEngine.js.map +1 -0
  80. package/dist/runtime/FlowExecutor.d.ts +51 -0
  81. package/dist/runtime/FlowExecutor.d.ts.map +1 -0
  82. package/dist/runtime/FlowExecutor.js +523 -0
  83. package/dist/runtime/FlowExecutor.js.map +1 -0
  84. package/dist/runtime/InjectionQueue.d.ts +8 -1
  85. package/dist/runtime/InjectionQueue.d.ts.map +1 -1
  86. package/dist/runtime/InjectionQueue.js +33 -0
  87. package/dist/runtime/InjectionQueue.js.map +1 -1
  88. package/dist/runtime/Runtime.d.ts +32 -2
  89. package/dist/runtime/Runtime.d.ts.map +1 -1
  90. package/dist/runtime/Runtime.js +513 -612
  91. package/dist/runtime/Runtime.js.map +1 -1
  92. package/dist/runtime/SessionEventManager.d.ts +17 -0
  93. package/dist/runtime/SessionEventManager.d.ts.map +1 -0
  94. package/dist/runtime/SessionEventManager.js +149 -0
  95. package/dist/runtime/SessionEventManager.js.map +1 -0
  96. package/dist/runtime/SuggestionManager.d.ts +7 -0
  97. package/dist/runtime/SuggestionManager.d.ts.map +1 -0
  98. package/dist/runtime/SuggestionManager.js +50 -0
  99. package/dist/runtime/SuggestionManager.js.map +1 -0
  100. package/dist/runtime/index.d.ts +1 -1
  101. package/dist/runtime/index.d.ts.map +1 -1
  102. package/dist/runtime/index.js +1 -1
  103. package/dist/runtime/index.js.map +1 -1
  104. package/dist/services/MetricsService.d.ts +55 -0
  105. package/dist/services/MetricsService.d.ts.map +1 -0
  106. package/dist/services/MetricsService.js +86 -0
  107. package/dist/services/MetricsService.js.map +1 -0
  108. package/dist/services/TracingService.d.ts +13 -0
  109. package/dist/services/TracingService.d.ts.map +1 -0
  110. package/dist/services/TracingService.js +62 -0
  111. package/dist/services/TracingService.js.map +1 -0
  112. package/dist/session/stores/MemoryStore.js +1 -1
  113. package/dist/session/stores/MemoryStore.js.map +1 -1
  114. package/dist/tools/Tool.d.ts +25 -3
  115. package/dist/tools/Tool.d.ts.map +1 -1
  116. package/dist/tools/Tool.js.map +1 -1
  117. package/dist/tools/errorHandling.d.ts +1 -1
  118. package/dist/tools/errorHandling.d.ts.map +1 -1
  119. package/dist/tools/errorHandling.js +27 -20
  120. package/dist/tools/errorHandling.js.map +1 -1
  121. package/dist/tools/http.d.ts.map +1 -1
  122. package/dist/tools/http.js +53 -17
  123. package/dist/tools/http.js.map +1 -1
  124. package/dist/types/index.d.ts +179 -3
  125. package/dist/types/index.d.ts.map +1 -1
  126. package/dist/types/index.js +1 -0
  127. package/dist/types/index.js.map +1 -1
  128. package/dist/types/telemetry.d.ts +52 -0
  129. package/dist/types/telemetry.d.ts.map +1 -0
  130. package/dist/types/telemetry.js +2 -0
  131. package/dist/types/telemetry.js.map +1 -0
  132. package/dist/utils/aiStream.d.ts +7 -0
  133. package/dist/utils/aiStream.d.ts.map +1 -0
  134. package/dist/utils/aiStream.js +41 -0
  135. package/dist/utils/aiStream.js.map +1 -0
  136. package/dist/utils/chrono.d.ts +3 -46
  137. package/dist/utils/chrono.d.ts.map +1 -1
  138. package/dist/utils/chrono.js.map +1 -1
  139. package/dist/utils/isRecord.d.ts +2 -0
  140. package/dist/utils/isRecord.d.ts.map +1 -0
  141. package/dist/utils/isRecord.js +4 -0
  142. package/dist/utils/isRecord.js.map +1 -0
  143. package/dist/utils/messageNormalization.d.ts +3 -0
  144. package/dist/utils/messageNormalization.d.ts.map +1 -0
  145. package/dist/utils/messageNormalization.js +121 -0
  146. package/dist/utils/messageNormalization.js.map +1 -0
  147. package/dist/utils/streamChunk.d.ts +5 -0
  148. package/dist/utils/streamChunk.d.ts.map +1 -0
  149. package/dist/utils/streamChunk.js +50 -0
  150. package/dist/utils/streamChunk.js.map +1 -0
  151. package/guides/EXAMPLE_VERIFICATION.md +53 -0
  152. package/guides/FLOWS.md +29 -0
  153. package/guides/GETTING_STARTED.md +14 -1
  154. package/guides/README.md +3 -1
  155. package/guides/RUNTIME.md +75 -0
  156. package/guides/TOOLS.md +6 -0
  157. package/package.json +2 -2
  158. package/dist/flows/AgentFlowManager.d.ts +0 -161
  159. package/dist/flows/AgentFlowManager.d.ts.map +0 -1
  160. package/dist/flows/AgentFlowManager.js +0 -448
  161. 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,11 +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;
693
+ const args = getChunkArgs(chunk);
588
694
  const callRecord = {
589
695
  toolCallId: chunk.toolCallId,
590
696
  toolName: chunk.toolName,
591
697
  args,
698
+ idempotencyKey: this.buildToolIdempotencyKey(context, chunk.toolName, chunk.toolCallId),
592
699
  success: true,
593
700
  timestamp: Date.now(),
594
701
  };
@@ -612,11 +719,10 @@ export class Runtime {
612
719
  });
613
720
  }
614
721
  if (chunk.type === 'tool-error') {
615
- const errText = typeof chunk.error === 'string'
616
- ? chunk.error
617
- : chunk.error?.message ?? 'Tool execution error';
618
- const args = chunk.input ?? chunk.args;
619
- 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);
620
726
  if (callRecord) {
621
727
  callRecord.success = false;
622
728
  callRecord.error = new Error(errText);
@@ -624,17 +730,24 @@ export class Runtime {
624
730
  callRecord.args = args;
625
731
  context.toolCallHistory.push(callRecord);
626
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
+ }
627
740
  }
628
741
  yield* this.emit(context, {
629
742
  type: 'tool-error',
630
- toolCallId: chunk.toolCallId,
743
+ toolCallId: toolCallId ?? chunk.toolCallId,
631
744
  toolName: chunk.toolName,
632
745
  error: errText,
633
746
  });
634
747
  }
635
748
  if (chunk.type === 'tool-result') {
636
749
  const startTime = toolCalls.find(call => call.toolCallId === chunk.toolCallId)?.timestamp ?? Date.now();
637
- const toolResult = 'result' in chunk ? chunk.result : chunk.output;
750
+ const toolResult = getChunkResult(chunk);
638
751
  const callRecord = toolCalls.find(call => call.toolCallId === chunk.toolCallId);
639
752
  if (callRecord) {
640
753
  callRecord.result = toolResult;
@@ -661,6 +774,8 @@ export class Runtime {
661
774
  callRecord.success = false;
662
775
  callRecord.error = new Error(reason);
663
776
  await this.hookRunner.onToolError(context, callRecord, callRecord.error);
777
+ // Fail-closed: Track enforcement failures as critical errors.
778
+ turnToolErrors.push(callRecord);
664
779
  yield* this.emit(context, {
665
780
  type: 'tool-error',
666
781
  toolCallId: chunk.toolCallId,
@@ -722,8 +837,17 @@ export class Runtime {
722
837
  // If the model ended on tool-calls, we must persist the tool messages and
723
838
  // continue the loop (or let AI SDK continue in maxSteps) instead of emitting
724
839
  // synthetic assistant text, which can duplicate responses.
725
- if (finalResult || (bufferOutput && finishReason !== 'tool-calls')) {
726
- 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
+ }
727
851
  const processed = await this.runOutputProcessing(agent, context, rawText);
728
852
  if (processed.tripwire) {
729
853
  yield* this.emit(context, {
@@ -734,14 +858,14 @@ export class Runtime {
734
858
  message: processed.tripwire.message,
735
859
  });
736
860
  }
737
- if (bufferOutput) {
861
+ if (bufferOutput || turnToolErrors.length > 0) {
738
862
  yield* this.emit(context, { type: 'text-delta', text: processed.text });
739
863
  }
740
- context.session.messages.push({ role: 'assistant', content: processed.text });
864
+ this.appendSessionMessage(context.session, { role: 'assistant', content: processed.text });
741
865
  }
742
866
  else {
743
867
  const beforeLen = context.session.messages.length;
744
- context.session.messages.push(...response.messages);
868
+ this.appendSessionMessages(context.session, response.messages);
745
869
  const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, beforeLen);
746
870
  for (const t of tripwires) {
747
871
  yield* this.emit(context, {
@@ -773,6 +897,21 @@ export class Runtime {
773
897
  }
774
898
  }
775
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
+ }
776
915
  context.consecutiveErrors += 1;
777
916
  await this.hookRunner.onError(context, error);
778
917
  yield* this.emit(context, { type: 'error', error: error.message });
@@ -784,6 +923,17 @@ export class Runtime {
784
923
  }
785
924
  yield* this.emit(context, { type: 'agent-end', agentId: agent.id });
786
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
+ }
787
937
  if (!handoffTo) {
788
938
  break;
789
939
  }
@@ -810,12 +960,34 @@ export class Runtime {
810
960
  }
811
961
  context.session.activeAgentId = handoffTo;
812
962
  context.session.currentAgent = handoffTo;
963
+ await this.saveSessionCheckpoint(context.session);
813
964
  context.agentId = handoffTo;
814
965
  }
815
966
  if (context.handoffStack.length >= this.maxHandoffs) {
816
967
  yield* this.emit(context, { type: 'error', error: `Maximum handoffs (${this.maxHandoffs}) exceeded` });
817
968
  }
818
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
+ }
819
991
  createSession(id, userId) {
820
992
  const now = new Date();
821
993
  return {
@@ -845,26 +1017,47 @@ export class Runtime {
845
1017
  isTriageAgent(agent) {
846
1018
  return agent.type === 'triage';
847
1019
  }
848
- 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) {
849
1041
  const basePrompt = this.isTriageAgent(agent)
850
- ? this.buildTriagePrompt(agent)
1042
+ ? this.buildTriagePrompt(agent, activeRoutes)
851
1043
  : agent.systemPrompt;
852
1044
  const autoBlock = autoContext?.text?.trim()
853
1045
  ? `\n\n## ${autoContext.label}\n${autoContext.text}`
854
1046
  : '';
855
1047
  const systemInjections = injectionQueue.getFor('system');
856
- const memory = context?.session?.workingMemory ?? {};
1048
+ const memory = context?.session ? this.buildPromptMemoryView(context.session, agent) : {};
857
1049
  const memoryBlock = Object.keys(memory).length > 0
858
1050
  ? `\n\n## Known Information\n${JSON.stringify(memory, null, 2)}`
859
1051
  : '';
860
1052
  const merged = `${basePrompt}${autoBlock}${memoryBlock}`;
861
1053
  return systemInjections ? `${merged}\n\n${systemInjections}` : merged;
862
1054
  }
863
- buildStructuredTriagePrompt(agent) {
864
- const routeDescriptions = agent.routes
1055
+ buildStructuredTriagePrompt(agent, activeRoutes) {
1056
+ const routes = activeRoutes ?? agent.routes;
1057
+ const routeDescriptions = routes
865
1058
  .map(route => `- ${route.agentId}: ${route.description}`)
866
1059
  .join('\n');
867
- const allowed = agent.routes.map(route => route.agentId);
1060
+ const allowed = routes.map(route => route.agentId);
868
1061
  const defaultNote = agent.defaultAgent ? `Default: ${agent.defaultAgent}` : 'Default: none';
869
1062
  return `${agent.systemPrompt}
870
1063
 
@@ -879,8 +1072,9 @@ Return a JSON object with:
879
1072
  - stayWithCurrent: boolean (true only if current agent is best fit)
880
1073
  ${defaultNote}`;
881
1074
  }
882
- buildTriagePrompt(agent) {
883
- const routeDescriptions = agent.routes
1075
+ buildTriagePrompt(agent, activeRoutes) {
1076
+ const routes = activeRoutes ?? agent.routes;
1077
+ const routeDescriptions = routes
884
1078
  .map(route => `- **${route.agentId}**: ${route.description}`)
885
1079
  .join('\n');
886
1080
  const defaultNote = agent.defaultAgent
@@ -896,9 +1090,10 @@ ${routeDescriptions}
896
1090
  - When the customer needs specialized help, use the handoff tool
897
1091
  - Always provide a brief reason for the handoff${defaultNote}`;
898
1092
  }
899
- getHandoffCandidates(agent) {
1093
+ getHandoffCandidates(agent, activeRoutes) {
900
1094
  if (this.isTriageAgent(agent)) {
901
- return agent.routes
1095
+ const routes = activeRoutes ?? agent.routes;
1096
+ return routes
902
1097
  .map(route => this.agents.get(route.agentId))
903
1098
  .filter((candidate) => Boolean(candidate));
904
1099
  }
@@ -918,15 +1113,45 @@ ${routeDescriptions}
918
1113
  }
919
1114
  return stored;
920
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
+ }
921
1127
  setFlowState(session, agentId, state) {
922
1128
  session.agentStates[agentId] = {
923
1129
  agentId,
924
1130
  state: state,
925
1131
  lastActive: new Date(),
926
1132
  };
1133
+ this.updateFlowStateSnapshot(session, agentId, state);
1134
+ this.touchSession(session);
927
1135
  }
928
1136
  clearFlowState(session, agentId) {
929
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);
930
1155
  }
931
1156
  buildFlowWithHandoff(agent, handoffTool, suppressAutoRespond) {
932
1157
  // If we don't need to inject a handoff tool and we don't need to suppress the initial autoRespond,
@@ -976,64 +1201,74 @@ ${routeDescriptions}
976
1201
  }
977
1202
  return agent.flow.nodes.find(node => node.id === nodeId);
978
1203
  }
979
- async shouldHandleFlowInput(agent, input, flowState) {
980
- // Before the flow is initialized, be conservative: run the flow.
981
- // This avoids misrouting the very first user message after a handoff into "detour",
982
- // which can lead to unnecessary specialist handoffs and handoff loops.
983
- if (!flowState?.initialized) {
984
- return true;
985
- }
986
- const nodeId = flowState?.context.currentNode ?? agent.initialNode;
987
- const node = this.getFlowNode(agent, nodeId);
988
- if (!node) {
989
- return true;
990
- }
991
- const rules = agent.detourRules;
992
- if (rules) {
993
- const normalized = input.trim().toLowerCase();
994
- if (rules.allowShortAffirmations !== false &&
995
- /^(yes|yep|yeah|ok|okay|sure|great|thanks|thank you|please|proceed|go ahead)\b/.test(normalized)) {
996
- return true;
997
- }
998
- if (rules.allowDateTime !== false &&
999
- (/\b20\d{2}-\d{2}-\d{2}\b/.test(normalized) || /\b\d{1,2}:\d{2}\b/.test(normalized))) {
1000
- 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;
1001
1212
  }
1002
- if (this.matchesDetourRule(normalized, rules.deny)) {
1003
- return false;
1004
- }
1005
- if (this.matchesDetourRule(normalized, rules.allow)) {
1006
- return true;
1007
- }
1008
- }
1009
- const collectedData = flowState?.context.collectedData ?? {};
1010
- const renderedNodePrompt = renderFlowTemplate(node.prompt, collectedData, { missing: 'keep' });
1011
- const routerPrompt = `You are routing a message for a structured conversation flow.
1012
- Decide if the user input is answering the current flow step or is a side question.
1013
-
1014
- Current flow step:
1015
- ${renderedNodePrompt}
1016
-
1017
- Collected data:
1018
- ${JSON.stringify(collectedData, null, 2)}
1019
-
1020
- 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.`;
1021
- const result = await generateText({
1022
- model: (agent.model ?? this.defaultModel),
1023
- system: routerPrompt,
1024
- prompt: input,
1025
- experimental_telemetry: agent.telemetry ?? this.config.telemetry,
1026
- });
1027
- const decision = result.text.trim().toLowerCase();
1028
- if (decision.startsWith('detour')) {
1029
- return false;
1213
+ return { config, nodeId };
1030
1214
  }
1031
- if (decision.startsWith('flow')) {
1032
- return true;
1215
+ if (!agent.extraction) {
1216
+ return null;
1033
1217
  }
1034
- 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);
1231
+ }
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);
1035
1237
  }
1036
- matchesDetourRule(input, patterns) {
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) {
1037
1272
  if (!patterns || patterns.length === 0) {
1038
1273
  return false;
1039
1274
  }
@@ -1052,426 +1287,6 @@ Return only one word: "flow" if the input should be handled by the flow step, or
1052
1287
  }
1053
1288
  return false;
1054
1289
  }
1055
- async *runDetourResponse(agent, context, input, flowState, handoffTool, onHandoff) {
1056
- const nodeId = flowState?.context.currentNode ?? agent.initialNode;
1057
- const node = this.getFlowNode(agent, nodeId);
1058
- const collectedData = flowState?.context.collectedData ?? {};
1059
- const nodePrompt = node
1060
- ? renderFlowTemplate(node.prompt, collectedData, { missing: 'keep' })
1061
- : 'Continue the flow.';
1062
- const handoffLine = handoffTool
1063
- ? 'If the request should be handled by a specialist agent, use the handoff tool instead of answering.'
1064
- : 'Do not attempt to route to other agents. Handle the detour here and then resume the flow.';
1065
- const detourPrompt = `You are handling a short detour during a structured flow.
1066
- Answer the user's question clearly and briefly. ${handoffLine} Then ask the user to continue with the current flow step.
1067
-
1068
- Current flow step:
1069
- ${nodePrompt}
1070
-
1071
- Collected data:
1072
- ${JSON.stringify(collectedData, null, 2)}
1073
-
1074
- Do not change the flow requirements. Keep the reply concise.`;
1075
- const result = streamText({
1076
- model: (agent.model ?? this.defaultModel),
1077
- system: detourPrompt,
1078
- messages: context.session.messages,
1079
- tools: handoffTool ? { handoff: handoffTool } : undefined,
1080
- experimental_telemetry: agent.telemetry ?? this.config.telemetry,
1081
- });
1082
- let handoffTriggered = false;
1083
- const outputProcessors = this.getAgentOutputProcessors(agent);
1084
- const bufferOutput = this.outputProcessorMode === 'buffer' && outputProcessors.length > 0;
1085
- let bufferedText = '';
1086
- for await (const chunk of result.fullStream) {
1087
- if (chunk.type === 'text-delta') {
1088
- if (handoffTriggered) {
1089
- continue;
1090
- }
1091
- if (bufferOutput) {
1092
- bufferedText += chunk.text;
1093
- }
1094
- else {
1095
- yield* this.emit(context, { type: 'text-delta', text: chunk.text });
1096
- }
1097
- }
1098
- if (chunk.type === 'tool-call') {
1099
- const args = 'args' in chunk ? chunk.args : chunk.input;
1100
- yield* this.emit(context, {
1101
- type: 'tool-call',
1102
- toolCallId: chunk.toolCallId,
1103
- toolName: chunk.toolName,
1104
- args,
1105
- });
1106
- }
1107
- if (chunk.type === 'tool-result') {
1108
- const toolResult = 'result' in chunk ? chunk.result : chunk.output;
1109
- yield* this.emit(context, {
1110
- type: 'tool-result',
1111
- toolCallId: chunk.toolCallId,
1112
- toolName: chunk.toolName,
1113
- result: toolResult,
1114
- });
1115
- if (isHandoffResult(toolResult)) {
1116
- const targetAgent = toolResult.targetAgent ?? toolResult.targetAgentId;
1117
- onHandoff(targetAgent, toolResult.reason);
1118
- handoffTriggered = true;
1119
- break;
1120
- }
1121
- }
1122
- }
1123
- const response = await result.response;
1124
- if (bufferOutput) {
1125
- const processed = await this.runOutputProcessing(agent, context, bufferedText);
1126
- if (processed.tripwire) {
1127
- yield* this.emit(context, {
1128
- type: 'tripwire',
1129
- phase: 'output',
1130
- processorId: processed.tripwire.processorId,
1131
- reason: processed.tripwire.reason,
1132
- message: processed.tripwire.message,
1133
- });
1134
- }
1135
- yield* this.emit(context, { type: 'text-delta', text: processed.text });
1136
- context.session.messages.push({ role: 'assistant', content: processed.text });
1137
- }
1138
- else {
1139
- const beforeLen = context.session.messages.length;
1140
- context.session.messages.push(...response.messages);
1141
- const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, beforeLen);
1142
- for (const t of tripwires) {
1143
- yield* this.emit(context, {
1144
- type: 'tripwire',
1145
- phase: 'output',
1146
- processorId: t.processorId,
1147
- reason: t.reason,
1148
- message: t.message,
1149
- });
1150
- }
1151
- }
1152
- const usage = await result.usage;
1153
- const totalTokens = usage.totalTokens ?? 0;
1154
- context.totalTokens += totalTokens;
1155
- if (context.session.metadata) {
1156
- context.session.metadata.totalTokens += totalTokens;
1157
- context.session.metadata.totalSteps += 1;
1158
- }
1159
- if (!handoffTriggered) {
1160
- yield* this.emit(context, { type: 'turn-end' });
1161
- }
1162
- }
1163
- async *runFlowAgent(agent, context, input, systemPrompt, handoffTool, onHandoff, toolCalls) {
1164
- const model = agent.model ?? this.defaultModel;
1165
- if (!model) {
1166
- throw new Error(`Agent "${agent.id}" is missing a model`);
1167
- }
1168
- const detourRules = agent.detourRules;
1169
- if (detourRules?.emergency && this.matchesDetourRule(input, detourRules.emergency)) {
1170
- const emergencyText = detourRules.emergencyMessage
1171
- ?? 'This sounds urgent. Please call local emergency services immediately or go to the nearest emergency room.';
1172
- const processed = await this.runOutputProcessing(agent, context, emergencyText);
1173
- if (processed.tripwire) {
1174
- yield* this.emit(context, {
1175
- type: 'tripwire',
1176
- phase: 'output',
1177
- processorId: processed.tripwire.processorId,
1178
- reason: processed.tripwire.reason,
1179
- message: processed.tripwire.message,
1180
- });
1181
- }
1182
- context.session.messages.push({ role: 'assistant', content: processed.text });
1183
- yield* this.emit(context, { type: 'text-delta', text: processed.text });
1184
- yield* this.emit(context, { type: 'turn-end' });
1185
- if (detourRules.emergencyHandoffAgent) {
1186
- onHandoff(detourRules.emergencyHandoffAgent, 'Emergency pattern matched');
1187
- }
1188
- return;
1189
- }
1190
- const flowState = this.getFlowState(context.session, agent.id);
1191
- if (agent.mode === 'hybrid') {
1192
- const shouldRunFlow = await this.shouldHandleFlowInput(agent, input, flowState);
1193
- if (!shouldRunFlow) {
1194
- yield* this.runDetourResponse(agent, context, input, flowState, handoffTool, onHandoff);
1195
- return;
1196
- }
1197
- }
1198
- const suppressAutoRespond = !flowState?.initialized;
1199
- const flow = this.buildFlowWithHandoff(agent, handoffTool, suppressAutoRespond);
1200
- const flowManager = new FlowManager({
1201
- flow,
1202
- initialNode: agent.initialNode,
1203
- model: model,
1204
- defaultRolePrompt: systemPrompt,
1205
- contextMessages: flowState?.context.messages ?? [],
1206
- sessionMessages: context.session.messages,
1207
- state: flowState,
1208
- telemetry: agent.telemetry ?? this.config.telemetry,
1209
- toolCallGuard: async ({ toolName, args }) => {
1210
- const callRecord = {
1211
- toolCallId: crypto.randomUUID(),
1212
- toolName,
1213
- args,
1214
- success: true,
1215
- timestamp: Date.now(),
1216
- };
1217
- const enforcement = await this.enforcer.check(callRecord, {
1218
- previousCalls: context.toolCallHistory,
1219
- currentStep: context.stepCount,
1220
- sessionState: context.session.state ?? {},
1221
- });
1222
- if (!enforcement.allowed) {
1223
- return { allowed: false, reason: enforcement.reason ?? 'Tool call blocked by enforcement' };
1224
- }
1225
- return { allowed: true };
1226
- },
1227
- });
1228
- let persistedStartIndex = context.session.messages.length;
1229
- if (!flowState?.initialized) {
1230
- for await (const part of flowManager.initialize()) {
1231
- switch (part.type) {
1232
- case 'text-delta':
1233
- yield* this.emit(context, { type: 'text-delta', text: part.text });
1234
- break;
1235
- case 'tool-call': {
1236
- const toolCallId = part.toolCallId ?? crypto.randomUUID();
1237
- const callRecord = {
1238
- toolCallId,
1239
- toolName: part.toolName,
1240
- args: part.args,
1241
- success: true,
1242
- timestamp: Date.now(),
1243
- };
1244
- toolCalls.push(callRecord);
1245
- await this.hookRunner.onToolCall(context, callRecord);
1246
- yield* this.emit(context, {
1247
- type: 'tool-call',
1248
- toolCallId,
1249
- toolName: part.toolName,
1250
- args: part.args,
1251
- });
1252
- break;
1253
- }
1254
- case 'tool-result': {
1255
- const toolCallId = part.toolCallId
1256
- ?? toolCalls.find(call => call.toolName === part.toolName && call.result === undefined)?.toolCallId
1257
- ?? crypto.randomUUID();
1258
- const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
1259
- if (callRecord) {
1260
- callRecord.result = part.result;
1261
- callRecord.durationMs = Date.now() - callRecord.timestamp;
1262
- context.toolCallHistory.push(callRecord);
1263
- await this.hookRunner.onToolResult(context, callRecord);
1264
- const enforcement = await this.enforcer.checkResult(callRecord, {
1265
- previousCalls: context.toolCallHistory,
1266
- currentStep: context.stepCount,
1267
- sessionState: context.session.state ?? {},
1268
- });
1269
- if (!enforcement.allowed) {
1270
- const reason = enforcement.reason ?? 'Tool result blocked by enforcement';
1271
- callRecord.success = false;
1272
- callRecord.error = new Error(reason);
1273
- await this.hookRunner.onToolError(context, callRecord, callRecord.error);
1274
- yield* this.emit(context, {
1275
- type: 'tool-error',
1276
- toolCallId,
1277
- toolName: part.toolName,
1278
- error: reason,
1279
- });
1280
- yield* this.emit(context, { type: 'error', error: reason });
1281
- return;
1282
- }
1283
- }
1284
- yield* this.emit(context, {
1285
- type: 'tool-result',
1286
- toolCallId,
1287
- toolName: part.toolName,
1288
- result: part.result,
1289
- });
1290
- break;
1291
- }
1292
- case 'tool-error': {
1293
- const toolCallId = part.toolCallId
1294
- ?? toolCalls.find(call => call.toolName === part.toolName && call.error === undefined)?.toolCallId
1295
- ?? crypto.randomUUID();
1296
- const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
1297
- if (callRecord) {
1298
- callRecord.success = false;
1299
- callRecord.error = new Error(part.error);
1300
- callRecord.durationMs = Date.now() - callRecord.timestamp;
1301
- context.toolCallHistory.push(callRecord);
1302
- await this.hookRunner.onToolError(context, callRecord, callRecord.error);
1303
- }
1304
- yield* this.emit(context, {
1305
- type: 'tool-error',
1306
- toolCallId,
1307
- toolName: part.toolName,
1308
- error: part.error,
1309
- });
1310
- break;
1311
- }
1312
- case 'handoff':
1313
- onHandoff(part.targetAgent, part.reason);
1314
- break;
1315
- case 'node-enter':
1316
- yield* this.emit(context, { type: 'node-enter', nodeName: part.nodeName });
1317
- break;
1318
- case 'node-exit':
1319
- yield* this.emit(context, { type: 'node-exit', nodeName: part.nodeName });
1320
- break;
1321
- case 'flow-transition':
1322
- yield* this.emit(context, { type: 'flow-transition', from: part.from, to: part.to });
1323
- break;
1324
- case 'flow-end':
1325
- yield* this.emit(context, { type: 'flow-end', reason: part.reason });
1326
- break;
1327
- case 'turn-end':
1328
- yield* this.emit(context, { type: 'turn-end' });
1329
- break;
1330
- case 'error':
1331
- yield* this.emit(context, { type: 'error', error: part.error });
1332
- break;
1333
- default:
1334
- break;
1335
- }
1336
- }
1337
- // Sanitize/redact anything FlowManager persisted during initialization.
1338
- const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, persistedStartIndex);
1339
- for (const t of tripwires) {
1340
- yield* this.emit(context, {
1341
- type: 'tripwire',
1342
- phase: 'output',
1343
- processorId: t.processorId,
1344
- reason: t.reason,
1345
- message: t.message,
1346
- });
1347
- }
1348
- persistedStartIndex = context.session.messages.length;
1349
- }
1350
- for await (const part of flowManager.process(input, { appendUserToSession: false })) {
1351
- switch (part.type) {
1352
- case 'text-delta':
1353
- yield* this.emit(context, { type: 'text-delta', text: part.text });
1354
- break;
1355
- case 'tool-call': {
1356
- const toolCallId = part.toolCallId ?? crypto.randomUUID();
1357
- const callRecord = {
1358
- toolCallId,
1359
- toolName: part.toolName,
1360
- args: part.args,
1361
- success: true,
1362
- timestamp: Date.now(),
1363
- };
1364
- toolCalls.push(callRecord);
1365
- await this.hookRunner.onToolCall(context, callRecord);
1366
- yield* this.emit(context, {
1367
- type: 'tool-call',
1368
- toolCallId,
1369
- toolName: part.toolName,
1370
- args: part.args,
1371
- });
1372
- break;
1373
- }
1374
- case 'tool-result': {
1375
- const toolCallId = part.toolCallId
1376
- ?? toolCalls.find(call => call.toolName === part.toolName && call.result === undefined)?.toolCallId
1377
- ?? crypto.randomUUID();
1378
- const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
1379
- if (callRecord) {
1380
- callRecord.result = part.result;
1381
- callRecord.durationMs = Date.now() - callRecord.timestamp;
1382
- context.toolCallHistory.push(callRecord);
1383
- await this.hookRunner.onToolResult(context, callRecord);
1384
- const enforcement = await this.enforcer.checkResult(callRecord, {
1385
- previousCalls: context.toolCallHistory,
1386
- currentStep: context.stepCount,
1387
- sessionState: context.session.state ?? {},
1388
- });
1389
- if (!enforcement.allowed) {
1390
- const reason = enforcement.reason ?? 'Tool result blocked by enforcement';
1391
- callRecord.success = false;
1392
- callRecord.error = new Error(reason);
1393
- await this.hookRunner.onToolError(context, callRecord, callRecord.error);
1394
- yield* this.emit(context, {
1395
- type: 'tool-error',
1396
- toolCallId,
1397
- toolName: part.toolName,
1398
- error: reason,
1399
- });
1400
- yield* this.emit(context, { type: 'error', error: reason });
1401
- return;
1402
- }
1403
- }
1404
- yield* this.emit(context, {
1405
- type: 'tool-result',
1406
- toolCallId,
1407
- toolName: part.toolName,
1408
- result: part.result,
1409
- });
1410
- break;
1411
- }
1412
- case 'tool-error': {
1413
- const toolCallId = part.toolCallId
1414
- ?? toolCalls.find(call => call.toolName === part.toolName && call.error === undefined)?.toolCallId
1415
- ?? crypto.randomUUID();
1416
- const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
1417
- if (callRecord) {
1418
- callRecord.success = false;
1419
- callRecord.error = new Error(part.error);
1420
- callRecord.durationMs = Date.now() - callRecord.timestamp;
1421
- context.toolCallHistory.push(callRecord);
1422
- await this.hookRunner.onToolError(context, callRecord, callRecord.error);
1423
- }
1424
- yield* this.emit(context, {
1425
- type: 'tool-error',
1426
- toolCallId,
1427
- toolName: part.toolName,
1428
- error: part.error,
1429
- });
1430
- break;
1431
- }
1432
- case 'handoff':
1433
- onHandoff(part.targetAgent, part.reason);
1434
- break;
1435
- case 'node-enter':
1436
- yield* this.emit(context, { type: 'node-enter', nodeName: part.nodeName });
1437
- break;
1438
- case 'node-exit':
1439
- yield* this.emit(context, { type: 'node-exit', nodeName: part.nodeName });
1440
- break;
1441
- case 'flow-transition':
1442
- yield* this.emit(context, { type: 'flow-transition', from: part.from, to: part.to });
1443
- break;
1444
- case 'flow-end':
1445
- yield* this.emit(context, { type: 'flow-end', reason: part.reason });
1446
- break;
1447
- case 'turn-end':
1448
- yield* this.emit(context, { type: 'turn-end' });
1449
- break;
1450
- case 'error':
1451
- yield* this.emit(context, { type: 'error', error: part.error });
1452
- break;
1453
- default:
1454
- break;
1455
- }
1456
- }
1457
- // Sanitize/redact anything FlowManager persisted during this user turn.
1458
- const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, persistedStartIndex);
1459
- for (const t of tripwires) {
1460
- yield* this.emit(context, {
1461
- type: 'tripwire',
1462
- phase: 'output',
1463
- processorId: t.processorId,
1464
- reason: t.reason,
1465
- message: t.message,
1466
- });
1467
- }
1468
- if (flowManager.hasEnded) {
1469
- this.clearFlowState(context.session, agent.id);
1470
- }
1471
- else {
1472
- this.setFlowState(context.session, agent.id, flowManager.getState());
1473
- }
1474
- }
1475
1290
  async getSession(id) {
1476
1291
  return this.sessionStore.get(id);
1477
1292
  }
@@ -1484,11 +1299,89 @@ Do not change the flow requirements. Keep the reply concise.`;
1484
1299
  getAllAgents() {
1485
1300
  return Array.from(this.agents.values());
1486
1301
  }
1487
- 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) {
1488
1380
  return {
1489
1381
  session: context.session,
1490
1382
  agentId: context.agentId,
1491
1383
  toolCallHistory: context.toolCallHistory,
1384
+ abortSignal,
1492
1385
  };
1493
1386
  }
1494
1387
  getAgentOutputProcessors(agent) {
@@ -1510,11 +1403,12 @@ Do not change the flow requirements. Keep the reply concise.`;
1510
1403
  const processors = this.getAgentOutputProcessors(agent);
1511
1404
  let cur = text;
1512
1405
  if (processors.length > 0) {
1406
+ const abortSignal = this.getActiveAbortController(context.session.id)?.signal;
1513
1407
  const outcome = await runOutputProcessors({
1514
1408
  processors,
1515
1409
  text: cur,
1516
1410
  messages: context.session.messages,
1517
- context: this.buildProcessorContext(context),
1411
+ context: this.buildProcessorContext(context, abortSignal),
1518
1412
  });
1519
1413
  if (outcome.blocked) {
1520
1414
  const msg = this.applyRedactionsToText(outcome.message);
@@ -1548,46 +1442,53 @@ Do not change the flow requirements. Keep the reply concise.`;
1548
1442
  if (this.outputRedactions && this.outputRedactions.length > 0) {
1549
1443
  const sessionId = context.session.id;
1550
1444
  if (part.type === 'text-delta') {
1551
- const next = this.applyOutputRedactions(sessionId, part.text, false);
1445
+ const next = this.applyOutputRedactions(context.session, part.text, false);
1552
1446
  if (next) {
1553
1447
  const redacted = { ...part, text: next };
1554
- await this.hookRunner.onStreamPart(context, redacted);
1555
- yield redacted;
1448
+ yield* this.emitWithHooks(context, redacted);
1556
1449
  }
1557
1450
  return;
1558
1451
  }
1559
1452
  if (part.type === 'turn-end' || part.type === 'done') {
1560
- const flushed = this.applyOutputRedactions(sessionId, '', true);
1453
+ const flushed = this.applyOutputRedactions(context.session, '', true);
1561
1454
  if (flushed) {
1562
1455
  const carryPart = { type: 'text-delta', text: flushed };
1563
- await this.hookRunner.onStreamPart(context, carryPart);
1564
- yield carryPart;
1456
+ yield* this.emitWithHooks(context, carryPart);
1565
1457
  }
1566
- this.redactCarryBySession.delete(sessionId);
1458
+ delete context.session.workingMemory[this.redactCarryKey];
1567
1459
  }
1568
1460
  }
1461
+ yield* this.emitWithHooks(context, part);
1462
+ }
1463
+ async *emitWithHooks(context, part) {
1464
+ this.recordRuntimeEvent(context, part);
1569
1465
  await this.hookRunner.onStreamPart(context, part);
1466
+ if (this.shouldCheckpointAfterPart(part)) {
1467
+ await this.saveSessionCheckpoint(context.session);
1468
+ }
1570
1469
  yield part;
1571
1470
  }
1572
- applyOutputRedactions(sessionId, text, flush) {
1471
+ applyOutputRedactions(session, text, flush) {
1573
1472
  if (!this.outputRedactions || this.outputRedactions.length === 0)
1574
1473
  return text;
1575
- const carry = this.redactCarryBySession.get(sessionId) ?? '';
1474
+ const carry = typeof session.workingMemory[this.redactCarryKey] === 'string'
1475
+ ? session.workingMemory[this.redactCarryKey]
1476
+ : '';
1576
1477
  let combined = `${carry}${text}`;
1577
1478
  for (const r of this.outputRedactions) {
1578
1479
  combined = combined.replace(r.re, r.replacement);
1579
1480
  }
1580
1481
  const keep = flush ? 0 : this.redactLookbehind;
1581
1482
  if (keep === 0) {
1582
- this.redactCarryBySession.set(sessionId, '');
1483
+ session.workingMemory[this.redactCarryKey] = '';
1583
1484
  return combined;
1584
1485
  }
1585
1486
  if (combined.length <= keep) {
1586
- this.redactCarryBySession.set(sessionId, combined);
1487
+ session.workingMemory[this.redactCarryKey] = combined;
1587
1488
  return '';
1588
1489
  }
1589
1490
  const out = combined.slice(0, combined.length - keep);
1590
- this.redactCarryBySession.set(sessionId, combined.slice(-keep));
1491
+ session.workingMemory[this.redactCarryKey] = combined.slice(-keep);
1591
1492
  return out;
1592
1493
  }
1593
1494
  }