@ariaflowagents/core 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/capabilities/AutoRetrieveCapability.d.ts +30 -0
  2. package/dist/capabilities/AutoRetrieveCapability.d.ts.map +1 -0
  3. package/dist/capabilities/AutoRetrieveCapability.js +36 -0
  4. package/dist/capabilities/AutoRetrieveCapability.js.map +1 -0
  5. package/dist/capabilities/ExtractionCapability.d.ts +25 -0
  6. package/dist/capabilities/ExtractionCapability.d.ts.map +1 -0
  7. package/dist/capabilities/ExtractionCapability.js +74 -0
  8. package/dist/capabilities/ExtractionCapability.js.map +1 -0
  9. package/dist/capabilities/FlowCapability.d.ts +81 -0
  10. package/dist/capabilities/FlowCapability.d.ts.map +1 -0
  11. package/dist/capabilities/FlowCapability.js +482 -0
  12. package/dist/capabilities/FlowCapability.js.map +1 -0
  13. package/dist/capabilities/GuardrailCapability.d.ts +30 -0
  14. package/dist/capabilities/GuardrailCapability.d.ts.map +1 -0
  15. package/dist/capabilities/GuardrailCapability.js +38 -0
  16. package/dist/capabilities/GuardrailCapability.js.map +1 -0
  17. package/dist/capabilities/HandoffCapability.d.ts +19 -0
  18. package/dist/capabilities/HandoffCapability.d.ts.map +1 -0
  19. package/dist/capabilities/HandoffCapability.js +58 -0
  20. package/dist/capabilities/HandoffCapability.js.map +1 -0
  21. package/dist/capabilities/LivePromptAssembler.d.ts +108 -0
  22. package/dist/capabilities/LivePromptAssembler.d.ts.map +1 -0
  23. package/dist/capabilities/LivePromptAssembler.js +157 -0
  24. package/dist/capabilities/LivePromptAssembler.js.map +1 -0
  25. package/dist/capabilities/TriageCapability.d.ts +16 -0
  26. package/dist/capabilities/TriageCapability.d.ts.map +1 -0
  27. package/dist/capabilities/TriageCapability.js +61 -0
  28. package/dist/capabilities/TriageCapability.js.map +1 -0
  29. package/dist/capabilities/adapters/ai-sdk.d.ts +14 -0
  30. package/dist/capabilities/adapters/ai-sdk.d.ts.map +1 -0
  31. package/dist/capabilities/adapters/ai-sdk.js +29 -0
  32. package/dist/capabilities/adapters/ai-sdk.js.map +1 -0
  33. package/dist/capabilities/adapters/gemini.d.ts +15 -0
  34. package/dist/capabilities/adapters/gemini.d.ts.map +1 -0
  35. package/dist/capabilities/adapters/gemini.js +40 -0
  36. package/dist/capabilities/adapters/gemini.js.map +1 -0
  37. package/dist/capabilities/index.d.ts +154 -0
  38. package/dist/capabilities/index.d.ts.map +1 -0
  39. package/dist/capabilities/index.js +128 -0
  40. package/dist/capabilities/index.js.map +1 -0
  41. package/dist/eval/EvalRunner.d.ts +12 -0
  42. package/dist/eval/EvalRunner.d.ts.map +1 -0
  43. package/dist/eval/EvalRunner.js +64 -0
  44. package/dist/eval/EvalRunner.js.map +1 -0
  45. package/dist/eval/scoring.d.ts +15 -0
  46. package/dist/eval/scoring.d.ts.map +1 -0
  47. package/dist/eval/scoring.js +152 -0
  48. package/dist/eval/scoring.js.map +1 -0
  49. package/dist/eval/types.d.ts +59 -0
  50. package/dist/eval/types.d.ts.map +1 -0
  51. package/dist/eval/types.js +2 -0
  52. package/dist/eval/types.js.map +1 -0
  53. package/dist/flows/FlowGraph.d.ts +3 -1
  54. package/dist/flows/FlowGraph.d.ts.map +1 -1
  55. package/dist/flows/FlowGraph.js +5 -0
  56. package/dist/flows/FlowGraph.js.map +1 -1
  57. package/dist/flows/FlowManager.d.ts +60 -1
  58. package/dist/flows/FlowManager.d.ts.map +1 -1
  59. package/dist/flows/FlowManager.js +467 -34
  60. package/dist/flows/FlowManager.js.map +1 -1
  61. package/dist/flows/extraction.d.ts +16 -1
  62. package/dist/flows/extraction.d.ts.map +1 -1
  63. package/dist/flows/extraction.js +34 -0
  64. package/dist/flows/extraction.js.map +1 -1
  65. package/dist/flows/index.d.ts +2 -0
  66. package/dist/flows/index.d.ts.map +1 -1
  67. package/dist/flows/index.js +1 -0
  68. package/dist/flows/index.js.map +1 -1
  69. package/dist/flows/validation.d.ts +1 -1
  70. package/dist/flows/validation.d.ts.map +1 -1
  71. package/dist/flows/validation.js +13 -1
  72. package/dist/flows/validation.js.map +1 -1
  73. package/dist/hooks/HookRunner.d.ts +3 -1
  74. package/dist/hooks/HookRunner.d.ts.map +1 -1
  75. package/dist/hooks/HookRunner.js +3 -0
  76. package/dist/hooks/HookRunner.js.map +1 -1
  77. package/dist/hooks/builtin/metrics.d.ts.map +1 -1
  78. package/dist/hooks/builtin/metrics.js +12 -0
  79. package/dist/hooks/builtin/metrics.js.map +1 -1
  80. package/dist/hooks/builtin/observability.d.ts +21 -0
  81. package/dist/hooks/builtin/observability.d.ts.map +1 -0
  82. package/dist/hooks/builtin/observability.js +535 -0
  83. package/dist/hooks/builtin/observability.js.map +1 -0
  84. package/dist/index.d.ts +11 -1
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +7 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/orchestration/DefaultOrchestrationAuthority.d.ts +91 -0
  89. package/dist/orchestration/DefaultOrchestrationAuthority.d.ts.map +1 -0
  90. package/dist/orchestration/DefaultOrchestrationAuthority.js +786 -0
  91. package/dist/orchestration/DefaultOrchestrationAuthority.js.map +1 -0
  92. package/dist/orchestration/OrchestrationAuthority.d.ts +119 -0
  93. package/dist/orchestration/OrchestrationAuthority.d.ts.map +1 -0
  94. package/dist/orchestration/OrchestrationAuthority.js +2 -0
  95. package/dist/orchestration/OrchestrationAuthority.js.map +1 -0
  96. package/dist/orchestration/RealtimeExtractionRunner.d.ts +25 -0
  97. package/dist/orchestration/RealtimeExtractionRunner.d.ts.map +1 -0
  98. package/dist/orchestration/RealtimeExtractionRunner.js +62 -0
  99. package/dist/orchestration/RealtimeExtractionRunner.js.map +1 -0
  100. package/dist/orchestration/index.d.ts +5 -0
  101. package/dist/orchestration/index.d.ts.map +1 -0
  102. package/dist/orchestration/index.js +4 -0
  103. package/dist/orchestration/index.js.map +1 -0
  104. package/dist/orchestration/types.d.ts +134 -0
  105. package/dist/orchestration/types.d.ts.map +1 -0
  106. package/dist/orchestration/types.js +2 -0
  107. package/dist/orchestration/types.js.map +1 -0
  108. package/dist/realtime/RealtimeAudioClient.d.ts +105 -0
  109. package/dist/realtime/RealtimeAudioClient.d.ts.map +1 -0
  110. package/dist/realtime/RealtimeAudioClient.js +15 -0
  111. package/dist/realtime/RealtimeAudioClient.js.map +1 -0
  112. package/dist/realtime/RealtimeRuntime.d.ts +136 -0
  113. package/dist/realtime/RealtimeRuntime.d.ts.map +1 -0
  114. package/dist/realtime/RealtimeRuntime.js +270 -0
  115. package/dist/realtime/RealtimeRuntime.js.map +1 -0
  116. package/dist/realtime/index.d.ts +4 -0
  117. package/dist/realtime/index.d.ts.map +1 -0
  118. package/dist/realtime/index.js +2 -0
  119. package/dist/realtime/index.js.map +1 -0
  120. package/dist/runtime/ExtractionEngine.d.ts +2 -1
  121. package/dist/runtime/ExtractionEngine.d.ts.map +1 -1
  122. package/dist/runtime/ExtractionEngine.js +11 -0
  123. package/dist/runtime/ExtractionEngine.js.map +1 -1
  124. package/dist/runtime/FlowExecutor.d.ts +7 -5
  125. package/dist/runtime/FlowExecutor.d.ts.map +1 -1
  126. package/dist/runtime/FlowExecutor.js +71 -12
  127. package/dist/runtime/FlowExecutor.js.map +1 -1
  128. package/dist/runtime/Runtime.d.ts +22 -0
  129. package/dist/runtime/Runtime.d.ts.map +1 -1
  130. package/dist/runtime/Runtime.js +47 -0
  131. package/dist/runtime/Runtime.js.map +1 -1
  132. package/dist/runtime/pipeline/AgentExecuteStage.d.ts.map +1 -1
  133. package/dist/runtime/pipeline/AgentExecuteStage.js +94 -25
  134. package/dist/runtime/pipeline/AgentExecuteStage.js.map +1 -1
  135. package/dist/runtime/pipeline/ContextAssembleStage.js +1 -1
  136. package/dist/types/index.d.ts +61 -3
  137. package/dist/types/index.d.ts.map +1 -1
  138. package/dist/types/index.js +4 -0
  139. package/dist/types/index.js.map +1 -1
  140. package/dist/types/telemetry.d.ts +107 -0
  141. package/dist/types/telemetry.d.ts.map +1 -1
  142. package/package.json +15 -2
@@ -1,12 +1,15 @@
1
1
  import { streamText, generateText, tool } from 'ai';
2
2
  import { z } from 'zod';
3
+ import { isExtractionNode, } from '../types/index.js';
3
4
  import { createFlowTransition, isFlowTransition, isFlowUpdate } from './transitions.js';
5
+ import { extractStructuredFields, mergeExtractionData, computeMissingFields, toNullableSchema } from './extraction.js';
4
6
  import { validateFlowConfig } from './validation.js';
5
7
  import { isHandoffResult } from '../tools/handoff.js';
6
8
  import { isFinalResult } from '../tools/final.js';
7
9
  import { compileSanitizePattern, renderNodePrompt } from './template.js';
8
10
  import { normalizeModelMessage } from '../utils/messageNormalization.js';
9
11
  import { processAIStream } from '../utils/aiStream.js';
12
+ import { FlowCapability } from '../capabilities/FlowCapability.js';
10
13
  const implicitTransitionToolInputSchema = z.object({
11
14
  data: z.record(z.unknown()).optional(),
12
15
  message: z.string().optional(),
@@ -24,6 +27,13 @@ export class FlowManager {
24
27
  deferredActions = [];
25
28
  flowEnded = false;
26
29
  sessionMessages;
30
+ /**
31
+ * Headless FlowCapability used to share state-management code with
32
+ * CapabilityCallWorker. FlowManager rebuilds this lazily from its own
33
+ * authoritative state (context/initialized/flowEnded) whenever a getter
34
+ * or resolveTools() needs it — keeping streaming logic untouched.
35
+ */
36
+ flowCapability;
27
37
  /**
28
38
  * Tracks transition edges (from->to) within a single process() call.
29
39
  * Reset at the start of each user turn. Used to detect oscillation
@@ -32,6 +42,18 @@ export class FlowManager {
32
42
  turnTransitionCounts = new Map();
33
43
  /** Maximum times the same from->to edge can fire in one turn before being blocked. */
34
44
  maxOscillations;
45
+ pendingMetrics = [];
46
+ emitMetric(name, data) {
47
+ this.pendingMetrics.push({ name, data });
48
+ this.config.metricsEmitter?.(name, data);
49
+ }
50
+ /** Drain queued metrics as custom stream events. Call from any generator. */
51
+ *drainMetrics() {
52
+ while (this.pendingMetrics.length > 0) {
53
+ const metric = this.pendingMetrics.shift();
54
+ yield { type: 'custom', name: metric.name, data: metric.data, timestamp: new Date() };
55
+ }
56
+ }
35
57
  constructor(config) {
36
58
  this.config = config;
37
59
  validateFlowConfig(config.flow, config.initialNode);
@@ -72,6 +94,7 @@ export class FlowManager {
72
94
  yield* this.runInference(false);
73
95
  }
74
96
  yield* this.flushDeferredActions();
97
+ yield* this.drainMetrics();
75
98
  }
76
99
  async *process(userInput) {
77
100
  if (!this.initialized) {
@@ -96,6 +119,7 @@ export class FlowManager {
96
119
  }
97
120
  yield* this.runInference(true);
98
121
  yield* this.flushDeferredActions();
122
+ yield* this.drainMetrics();
99
123
  }
100
124
  async transitionTo(nodeId, data) {
101
125
  for await (const _part of this.transitionToGenerator(nodeId, data)) {
@@ -108,6 +132,7 @@ export class FlowManager {
108
132
  }
109
133
  }
110
134
  async *transitionToGenerator(nodeId, data, dynamicNode) {
135
+ const transitionStart = Date.now();
111
136
  if (!this.nodes.has(nodeId) && dynamicNode) {
112
137
  this.nodes.set(nodeId, dynamicNode);
113
138
  }
@@ -133,6 +158,16 @@ export class FlowManager {
133
158
  return;
134
159
  }
135
160
  }
161
+ // Output contract validation: validate exiting node's outputSchema
162
+ if (previousNode?.outputSchema) {
163
+ const result = previousNode.outputSchema.safeParse(this.context.collectedData);
164
+ if (!result.success) {
165
+ this.emitMetric('flow.contract.validation_fail', { nodeId: previousNode.id, direction: 'output' });
166
+ yield { type: 'error', error: `Output contract violation on node "${previousNode.id}": ${result.error.message}` };
167
+ return;
168
+ }
169
+ this.emitMetric('flow.contract.validation_pass', { nodeId: previousNode.id, direction: 'output' });
170
+ }
136
171
  if (previousNode) {
137
172
  yield* this.runActions(previousNode.postActions ?? []);
138
173
  if (this.flowEnded) {
@@ -147,6 +182,16 @@ export class FlowManager {
147
182
  Object.assign(this.context.collectedData, data);
148
183
  }
149
184
  await this.applyContextStrategy(nextNode);
185
+ // Input contract validation: validate entering node's inputSchema
186
+ if (nextNode.inputSchema) {
187
+ const result = nextNode.inputSchema.safeParse(this.context.collectedData);
188
+ if (!result.success) {
189
+ this.emitMetric('flow.contract.validation_fail', { nodeId: nextNode.id, direction: 'input' });
190
+ yield { type: 'error', error: `Input contract violation on node "${nextNode.id}": ${result.error.message}` };
191
+ return;
192
+ }
193
+ this.emitMetric('flow.contract.validation_pass', { nodeId: nextNode.id, direction: 'input' });
194
+ }
150
195
  yield { type: 'node-enter', nodeName: nextNode.name ?? nextNode.id };
151
196
  yield* this.runActions(nextNode.preActions ?? []);
152
197
  if (this.flowEnded) {
@@ -155,28 +200,59 @@ export class FlowManager {
155
200
  if (previousNode) {
156
201
  yield { type: 'flow-transition', from: previousNode.id, to: nextNode.id };
157
202
  }
203
+ this.emitMetric('flow.transition.duration', {
204
+ durationMs: Date.now() - transitionStart,
205
+ from: previousNode?.id ?? '__init__',
206
+ to: nodeId,
207
+ });
158
208
  }
159
209
  get collectedData() {
160
- return this.context.collectedData;
210
+ return this.rebuildCapability().collectedData;
161
211
  }
162
212
  get currentNode() {
163
- return this.currentNodeConfig?.id;
213
+ return this.rebuildCapability().currentNode;
164
214
  }
165
215
  get hasEnded() {
166
- return this.flowEnded;
216
+ return this.rebuildCapability().hasEnded;
167
217
  }
168
218
  getState() {
219
+ const capState = this.rebuildCapability().getState();
169
220
  return {
221
+ context: capState.context,
222
+ initialized: capState.initialized,
223
+ flowEnded: capState.flowEnded,
224
+ };
225
+ }
226
+ /**
227
+ * Build a FlowCapability snapshot from FlowManager's current authoritative state.
228
+ * FlowManager owns transitions/streaming; FlowCapability owns state-query logic.
229
+ * Rebuilding on each call is cheap (pure in-memory graph traversal, no I/O).
230
+ */
231
+ rebuildCapability() {
232
+ const state = {
170
233
  context: this.context,
171
234
  initialized: this.initialized,
172
235
  flowEnded: this.flowEnded,
173
236
  };
237
+ this.flowCapability = new FlowCapability({
238
+ flow: this.config.flow,
239
+ initialNode: this.config.initialNode,
240
+ defaultRolePrompt: this.config.defaultRolePrompt,
241
+ state,
242
+ });
243
+ return this.flowCapability;
174
244
  }
175
245
  async *runInference(triggeredByUserTurn) {
176
246
  if (this.flowEnded || !this.currentNodeConfig) {
177
247
  return;
178
248
  }
249
+ const inferenceStart = Date.now();
179
250
  const node = this.currentNodeConfig;
251
+ // Delegate to extraction node handler if applicable
252
+ if (isExtractionNode(node)) {
253
+ yield* this.runExtractionNodeInference(node, triggeredByUserTurn);
254
+ return;
255
+ }
180
256
  const systemPrompt = this.buildSystemPrompt(node);
181
257
  const tools = this.resolveTools(node);
182
258
  const maxSteps = node.maxSteps ?? this.config.maxSteps ?? this.config.flow.maxSteps ?? 25;
@@ -207,12 +283,21 @@ export class FlowManager {
207
283
  let finalEmitted = false;
208
284
  let hadToolResult = false;
209
285
  let pendingToolMessage = null;
286
+ let ttftEmitted = false;
210
287
  for await (const part of processAIStream(result.fullStream)) {
211
288
  // In buffered mode, hold text-delta tokens until we decide whether to emit.
212
289
  // This allows transition/handoff turns to stay silent in routing nodes.
213
290
  if (!(shouldBuffer && part.type === 'text-delta')) {
214
291
  yield part;
215
292
  }
293
+ // Emit TTFT metric on first text-delta
294
+ if (part.type === 'text-delta' && !ttftEmitted) {
295
+ ttftEmitted = true;
296
+ this.emitMetric('flow.inference.ttft', {
297
+ durationMs: Date.now() - inferenceStart,
298
+ nodeId: node.id,
299
+ });
300
+ }
216
301
  if (part.type === 'text-delta') {
217
302
  if (finalResult) {
218
303
  continue;
@@ -274,6 +359,20 @@ export class FlowManager {
274
359
  }
275
360
  }
276
361
  const response = await result.response;
362
+ // Emit inference duration and cache metrics
363
+ this.emitMetric('flow.inference.duration', {
364
+ durationMs: Date.now() - inferenceStart,
365
+ nodeId: node.id,
366
+ });
367
+ const anthropicMeta = response.providerMetadata?.anthropic;
368
+ if (anthropicMeta) {
369
+ if (typeof anthropicMeta.cacheReadInputTokens === 'number' && anthropicMeta.cacheReadInputTokens > 0) {
370
+ this.emitMetric('flow.cache.hit_tokens', { tokens: anthropicMeta.cacheReadInputTokens, nodeId: node.id });
371
+ }
372
+ if (typeof anthropicMeta.cacheCreationInputTokens === 'number' && anthropicMeta.cacheCreationInputTokens > 0) {
373
+ this.emitMetric('flow.cache.miss_tokens', { tokens: anthropicMeta.cacheCreationInputTokens, nodeId: node.id });
374
+ }
375
+ }
277
376
  let safeText = responseText;
278
377
  if (shouldBuffer && node.output?.sanitize?.pattern && node.output?.sanitize?.message) {
279
378
  const re = compileSanitizePattern(node.output.sanitize.pattern);
@@ -370,15 +469,25 @@ export class FlowManager {
370
469
  yield { type: 'flow-end', reason: 'post-action' };
371
470
  }
372
471
  }
472
+ /**
473
+ * Builds the system prompt as an array of SystemModelMessage objects.
474
+ * Layer 1 (role prompt) is marked with Anthropic cache_control for prompt caching.
475
+ * Uses AI SDK SystemModelMessage format: { role: 'system', content: string, providerOptions? }.
476
+ */
373
477
  buildSystemPrompt(node) {
374
478
  const includeGlobalPrompt = node.addGlobalPrompt !== false;
375
479
  const rolePrompt = includeGlobalPrompt
376
480
  ? (this.config.defaultRolePrompt ?? this.config.flow.defaultRolePrompt ?? '')
377
481
  : '';
378
482
  const renderedPrompt = renderNodePrompt(node.prompt, this.context);
379
- const dataKeys = Object.keys(this.context.collectedData);
483
+ // Filter collectedData to relevantFields if specified (Phase 3: selective data inclusion)
484
+ const allData = this.context.collectedData;
485
+ const filteredData = node.relevantFields
486
+ ? Object.fromEntries(Object.entries(allData).filter(([k]) => node.relevantFields.includes(k)))
487
+ : allData;
488
+ const dataKeys = Object.keys(filteredData);
380
489
  const dataContext = dataKeys.length > 0
381
- ? `\n\n## Collected Information\n${JSON.stringify(this.context.collectedData, null, 2)}`
490
+ ? `\n\n## Collected Information\n${JSON.stringify(filteredData, null, 2)}`
382
491
  : '';
383
492
  const historyContext = this.context.nodeHistory.length > 1
384
493
  ? `\n\n## Progress\n${this.context.nodeHistory.slice(0, -1).join(' -> ')} -> **${node.name ?? node.id}**`
@@ -389,13 +498,22 @@ ${renderedPrompt}${dataContext}${historyContext}`;
389
498
  - Focus only on the current task
390
499
  - Use the available tools to progress the conversation
391
500
  - Do not attempt tasks outside your current scope`;
392
- const sections = [];
501
+ const blocks = [];
502
+ // Layer 1: Role prompt (cacheable -- stable across all turns and nodes)
393
503
  if (rolePrompt.trim().length > 0) {
394
- sections.push(rolePrompt.trim());
504
+ blocks.push({
505
+ role: 'system',
506
+ content: rolePrompt.trim(),
507
+ providerOptions: {
508
+ anthropic: { cacheControl: { type: 'ephemeral' } },
509
+ },
510
+ });
395
511
  }
396
- sections.push(currentTaskSection);
397
- sections.push(instructionsSection);
398
- return sections.join('\n\n');
512
+ // Layer 2: Node-specific task context (changes per node)
513
+ blocks.push({ role: 'system', content: currentTaskSection });
514
+ // Layer 3: Fixed instructions
515
+ blocks.push({ role: 'system', content: instructionsSection });
516
+ return blocks;
399
517
  }
400
518
  async applyContextStrategy(node) {
401
519
  const strategy = node.contextStrategy ?? this.config.flow.contextStrategy ?? 'append';
@@ -419,6 +537,7 @@ ${renderedPrompt}${dataContext}${historyContext}`;
419
537
  }
420
538
  }
421
539
  async generateSummary(node) {
540
+ const summaryStart = Date.now();
422
541
  const prompt = node.summaryPrompt
423
542
  ?? this.config.flow.summaryPrompt
424
543
  ?? 'Summarize the key points from this conversation in 2-3 sentences.';
@@ -436,20 +555,33 @@ ${renderedPrompt}${dataContext}${historyContext}`;
436
555
  });
437
556
  return result.text;
438
557
  };
558
+ let summary;
439
559
  if (timeoutMs <= 0) {
440
- return await generate();
560
+ summary = await generate();
441
561
  }
442
- let timeoutHandle;
443
- const timeout = new Promise(resolve => {
444
- timeoutHandle = setTimeout(() => resolve(null), timeoutMs);
445
- });
446
- const summary = await Promise.race([generate().catch(() => null), timeout]);
447
- if (timeoutHandle) {
448
- clearTimeout(timeoutHandle);
562
+ else {
563
+ let timeoutHandle;
564
+ const timeout = new Promise(resolve => {
565
+ timeoutHandle = setTimeout(() => resolve(null), timeoutMs);
566
+ });
567
+ summary = await Promise.race([generate().catch(() => null), timeout]);
568
+ if (timeoutHandle) {
569
+ clearTimeout(timeoutHandle);
570
+ }
449
571
  }
572
+ this.emitMetric('flow.summary.duration', {
573
+ durationMs: Date.now() - summaryStart,
574
+ nodeId: node.id,
575
+ timedOut: summary === null,
576
+ });
450
577
  return summary;
451
578
  }
452
579
  catch {
580
+ this.emitMetric('flow.summary.duration', {
581
+ durationMs: Date.now() - summaryStart,
582
+ nodeId: node.id,
583
+ error: true,
584
+ });
453
585
  return null;
454
586
  }
455
587
  }
@@ -508,23 +640,26 @@ ${renderedPrompt}${dataContext}${historyContext}`;
508
640
  },
509
641
  };
510
642
  }
643
+ // When multiple transitions are requested in the same turn, take the first one.
644
+ // The LLM calls tools in order of intent -- the first tool call is the primary action.
645
+ // Conflicting targets or payloads are warned, not errored, to avoid leaving the user
646
+ // with no response. The first transition is the safest bet because it corresponds
647
+ // to the LLM's first (and usually correct) tool call.
511
648
  const firstTransition = transitions[0];
512
649
  for (let i = 1; i < transitions.length; i++) {
513
650
  const next = transitions[i];
514
651
  if (next.targetNode !== firstTransition.targetNode) {
515
- return {
516
- conflict: `Conflicting transitions requested in the same turn: "${firstTransition.targetNode}" and "${next.targetNode}".`,
517
- };
652
+ console.warn(`[AriaFlow] Conflicting transitions in the same turn: "${firstTransition.targetNode}" (tool: ${firstTransition.toolName}) ` +
653
+ `vs "${next.targetNode}" (tool: ${next.toolName}). Taking the first transition "${firstTransition.targetNode}".`);
654
+ break;
518
655
  }
519
656
  if (!this.isEquivalentPayload(next.data, firstTransition.data)) {
520
- return {
521
- conflict: `Conflicting transition payloads for node "${firstTransition.targetNode}" in the same turn.`,
522
- };
657
+ console.warn(`[AriaFlow] Conflicting transition payloads for node "${firstTransition.targetNode}" in the same turn. Using the first payload.`);
658
+ break;
523
659
  }
524
660
  if ((next.node?.id ?? null) !== (firstTransition.node?.id ?? null)) {
525
- return {
526
- conflict: `Conflicting dynamic node definitions for transition target "${firstTransition.targetNode}" in the same turn.`,
527
- };
661
+ console.warn(`[AriaFlow] Conflicting dynamic node definitions for transition target "${firstTransition.targetNode}" in the same turn. Using the first definition.`);
662
+ break;
528
663
  }
529
664
  }
530
665
  return {
@@ -536,13 +671,25 @@ ${renderedPrompt}${dataContext}${historyContext}`;
536
671
  };
537
672
  }
538
673
  resolveTools(node) {
539
- const implicitTools = this.buildImplicitTransitionTools(node);
540
- let explicitTools = {};
541
- if (node.tools) {
542
- explicitTools =
543
- typeof node.tools === 'function'
544
- ? (node.tools(this.context) ?? {})
545
- : node.tools;
674
+ // Resolve explicit node tools first so we know which names to exclude.
675
+ const explicitTools = node.tools
676
+ ? (typeof node.tools === 'function' ? (node.tools(this.context) ?? {}) : node.tools)
677
+ : {};
678
+ const explicitNames = new Set(Object.keys(explicitTools));
679
+ // Use FlowCapability to generate implicit transition tool declarations.
680
+ // This shares tool-building logic with CapabilityCallWorker; FlowManager
681
+ // wraps each ToolDeclaration in the AI SDK tool() format for streamText.
682
+ const cap = this.rebuildCapability();
683
+ const implicitTools = {};
684
+ for (const decl of cap.getTools()) {
685
+ // Skip explicit node tools — they will be merged in below with higher priority.
686
+ if (explicitNames.has(decl.name))
687
+ continue;
688
+ implicitTools[decl.name] = tool({
689
+ description: decl.description,
690
+ inputSchema: decl.parameters,
691
+ execute: decl.execute,
692
+ });
546
693
  }
547
694
  // Explicit node tools win on name collision to preserve backwards compatibility.
548
695
  return this.wrapTools({
@@ -742,6 +889,292 @@ ${renderedPrompt}${dataContext}${historyContext}`;
742
889
  }
743
890
  return {};
744
891
  }
892
+ /**
893
+ * Extraction node inference: loops until a Zod schema is fully satisfied.
894
+ * Replaces both the ExtractionEngine's generateObject call and the standard
895
+ * streamText inference -- single LLM call per turn.
896
+ *
897
+ * Three key correctness properties:
898
+ * 1. safeParse uses nullified collectedData (undefined -> null) so nullable
899
+ * schema fields don't fail on absent keys.
900
+ * 2. The follow-up prompt only mentions REQUIRED missing fields, not optional ones.
901
+ * 3. On auto-transition, if the next node is also an extraction node, the last
902
+ * user message is re-extracted against the new schema (cross-node carry-forward).
903
+ */
904
+ async *runExtractionNodeInference(node, triggeredByUserTurn) {
905
+ const extractionStart = Date.now();
906
+ const turnCount = this.context.nodeTurnCounts[node.id] ?? 0;
907
+ // Check if extraction is already complete from prior turns.
908
+ // Fill undefined schema keys with null so .nullable() fields pass safeParse.
909
+ if (this.isExtractionComplete(node)) {
910
+ this.emitMetric('flow.extraction.complete', { nodeId: node.id, turns: turnCount });
911
+ if (node.extractionCompleteTransition) {
912
+ yield* this.transitionToGenerator(node.extractionCompleteTransition, this.context.collectedData);
913
+ if (!this.flowEnded && this.currentNodeConfig) {
914
+ // Cross-node carry-forward: if the next node is also an extraction node,
915
+ // re-extract from the last user message against the new schema.
916
+ if (isExtractionNode(this.currentNodeConfig)) {
917
+ yield* this.runExtractionCarryForward(this.currentNodeConfig);
918
+ }
919
+ else if (this.currentNodeConfig.autoRespond !== false) {
920
+ yield* this.runInference(false);
921
+ }
922
+ }
923
+ }
924
+ return;
925
+ }
926
+ // Enforce max extraction turns
927
+ const maxTurns = node.extractionMaxTurns ?? 10;
928
+ if (turnCount > maxTurns) {
929
+ this.emitMetric('flow.extraction.max_turns_exceeded', { nodeId: node.id, turnCount });
930
+ yield { type: 'error', error: `Extraction node "${node.id}" exceeded max turns (${maxTurns})` };
931
+ if (node.extractionCompleteTransition) {
932
+ yield* this.transitionToGenerator(node.extractionCompleteTransition, this.context.collectedData);
933
+ if (!this.flowEnded && this.currentNodeConfig?.autoRespond !== false) {
934
+ yield* this.runInference(false);
935
+ }
936
+ }
937
+ return;
938
+ }
939
+ // Extract structured fields from the latest user message.
940
+ // Use a nullable version of the schema so the LLM can return null for
941
+ // fields not mentioned, instead of hallucinating values.
942
+ //
943
+ // Run extraction if:
944
+ // - This was triggered by a user turn (normal extraction loop), OR
945
+ // - This is the first entry to the extraction node (turnCount === 0)
946
+ // and there is a user message available (e.g., the user said something
947
+ // that triggered a transition from a non-extraction node).
948
+ const shouldExtract = (triggeredByUserTurn || turnCount === 0) && this.context.messages.length > 0;
949
+ if (shouldExtract) {
950
+ await this.extractFromLastUserMessage(node);
951
+ }
952
+ // Re-check after extraction
953
+ if (this.isExtractionComplete(node)) {
954
+ const reqFields = node.extractionRequiredFields ?? Object.keys(node.extractionSchema?.shape ?? {});
955
+ const doneCollected = {};
956
+ for (const key of reqFields) {
957
+ const v = this.context.collectedData[key];
958
+ if (v !== undefined && v !== null) {
959
+ doneCollected[key] = v;
960
+ }
961
+ }
962
+ this.emitMetric('flow.extraction.update', {
963
+ nodeId: node.id,
964
+ collected: doneCollected,
965
+ missing: [],
966
+ });
967
+ this.emitMetric('flow.extraction.complete', { nodeId: node.id, turns: turnCount });
968
+ this.emitMetric('flow.extraction.duration', { durationMs: Date.now() - extractionStart, nodeId: node.id });
969
+ yield* this.drainMetrics();
970
+ if (node.extractionCompleteTransition) {
971
+ yield* this.transitionToGenerator(node.extractionCompleteTransition, this.context.collectedData);
972
+ if (!this.flowEnded && this.currentNodeConfig) {
973
+ if (isExtractionNode(this.currentNodeConfig)) {
974
+ yield* this.runExtractionCarryForward(this.currentNodeConfig);
975
+ }
976
+ else if (this.currentNodeConfig.autoRespond !== false) {
977
+ yield* this.runInference(false);
978
+ }
979
+ }
980
+ }
981
+ return;
982
+ }
983
+ // Compute missing REQUIRED fields only -- do not ask for optional fields.
984
+ const requiredFields = node.extractionRequiredFields
985
+ ?? Object.keys(node.extractionSchema?.shape ?? {});
986
+ const missingFields = computeMissingFields(this.context.collectedData, requiredFields);
987
+ const collectedSnapshot = {};
988
+ for (const key of requiredFields) {
989
+ const v = this.context.collectedData[key];
990
+ if (v !== undefined && v !== null) {
991
+ collectedSnapshot[key] = v;
992
+ }
993
+ }
994
+ this.emitMetric('flow.extraction.update', {
995
+ nodeId: node.id,
996
+ collected: collectedSnapshot,
997
+ missing: missingFields,
998
+ });
999
+ this.emitMetric('flow.extraction.fields.collected', {
1000
+ nodeId: node.id,
1001
+ total: requiredFields.length,
1002
+ collected: requiredFields.length - missingFields.length,
1003
+ missing: missingFields.length,
1004
+ });
1005
+ yield* this.drainMetrics();
1006
+ // If all REQUIRED fields are present but safeParse still fails (e.g., validation
1007
+ // errors like min length), check which fields have validation issues.
1008
+ if (missingFields.length === 0) {
1009
+ // All required fields exist but schema validation failed.
1010
+ // This means a field has an invalid value (wrong format, too short, etc.).
1011
+ // Auto-transition with what we have rather than looping forever.
1012
+ this.emitMetric('flow.extraction.complete', { nodeId: node.id, turns: turnCount });
1013
+ this.emitMetric('flow.extraction.duration', { durationMs: Date.now() - extractionStart, nodeId: node.id });
1014
+ if (node.extractionCompleteTransition) {
1015
+ yield* this.transitionToGenerator(node.extractionCompleteTransition, this.context.collectedData);
1016
+ if (!this.flowEnded && this.currentNodeConfig?.autoRespond !== false) {
1017
+ yield* this.runInference(false);
1018
+ }
1019
+ }
1020
+ return;
1021
+ }
1022
+ // Generate follow-up prompt asking ONLY for required missing fields.
1023
+ const promptMode = node.extractionPromptMode ?? 'llm';
1024
+ if (promptMode === 'template') {
1025
+ const prompts = missingFields.map(field => node.extractionFieldPrompts?.[field] ?? `Could you please provide your ${field}?`);
1026
+ const followUpText = prompts.join(' ');
1027
+ yield { type: 'text-delta', text: followUpText };
1028
+ this.appendMessage({ role: 'assistant', content: followUpText });
1029
+ }
1030
+ else {
1031
+ const systemPrompt = this.buildSystemPrompt(node);
1032
+ const collectedSummary = Object.entries(this.context.collectedData)
1033
+ .filter(([, v]) => v !== null && v !== undefined)
1034
+ .map(([k, v]) => ` ${k}: ${v}`)
1035
+ .join('\n');
1036
+ const extractionContext = `\n\nYou are collecting required information. ` +
1037
+ `Already collected:\n${collectedSummary || ' (nothing yet)'}\n` +
1038
+ `Still needed: ${missingFields.join(', ')}.\n` +
1039
+ `Ask ONLY for the missing fields listed above. Do not ask for anything else. Be concise.`;
1040
+ const blocks = [...systemPrompt];
1041
+ if (blocks.length > 0) {
1042
+ blocks[blocks.length - 1] = {
1043
+ ...blocks[blocks.length - 1],
1044
+ content: blocks[blocks.length - 1].content + extractionContext,
1045
+ };
1046
+ }
1047
+ const streamOptions = {
1048
+ model: this.config.model,
1049
+ system: blocks,
1050
+ abortSignal: this.config.abortSignal,
1051
+ experimental_telemetry: this.config.telemetry,
1052
+ };
1053
+ if (this.context.messages.length === 0) {
1054
+ streamOptions.prompt = renderNodePrompt(node.prompt, this.context);
1055
+ }
1056
+ else {
1057
+ streamOptions.messages = this.context.messages;
1058
+ }
1059
+ const result = streamText(streamOptions);
1060
+ let responseText = '';
1061
+ for await (const part of processAIStream(result.fullStream)) {
1062
+ yield part;
1063
+ if (part.type === 'text-delta') {
1064
+ responseText += part.text;
1065
+ }
1066
+ }
1067
+ const response = await result.response;
1068
+ for (const message of response.messages) {
1069
+ this.appendMessage(message);
1070
+ }
1071
+ yield { type: 'turn-end', metadata: { response } };
1072
+ }
1073
+ this.emitMetric('flow.extraction.duration', {
1074
+ durationMs: Date.now() - extractionStart,
1075
+ nodeId: node.id,
1076
+ });
1077
+ }
1078
+ /**
1079
+ * Check if extraction is complete by running safeParse with nullified data.
1080
+ * Fills undefined schema keys with null so .nullable() fields don't fail
1081
+ * on absent keys (undefined !== null in Zod).
1082
+ */
1083
+ isExtractionComplete(node) {
1084
+ const dataForParse = { ...this.context.collectedData };
1085
+ const schemaShape = node.extractionSchema?.shape;
1086
+ if (schemaShape) {
1087
+ for (const key of Object.keys(schemaShape)) {
1088
+ if (!(key in dataForParse)) {
1089
+ dataForParse[key] = null;
1090
+ }
1091
+ }
1092
+ }
1093
+ return node.extractionSchema.safeParse(dataForParse).success;
1094
+ }
1095
+ /**
1096
+ * Extract structured fields from the last user message against a node's schema.
1097
+ * Returns the number of NEW fields that were extracted (0 if nothing new).
1098
+ * Used both for normal extraction turns and cross-node carry-forward.
1099
+ */
1100
+ async extractFromLastUserMessage(node) {
1101
+ try {
1102
+ const lastUserMsg = this.context.messages
1103
+ .slice()
1104
+ .reverse()
1105
+ .find((m) => m.role === 'user');
1106
+ const userInput = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
1107
+ if (userInput.length > 0) {
1108
+ const nullableSchema = toNullableSchema(node.extractionSchema);
1109
+ const before = { ...this.context.collectedData };
1110
+ const extracted = await extractStructuredFields({
1111
+ model: this.config.model,
1112
+ schema: nullableSchema,
1113
+ userMessage: userInput,
1114
+ systemPrompt: node.extraction?.systemPrompt
1115
+ ?? 'Extract only facts explicitly stated in the latest user message. Return null for any field not clearly provided. Do not infer or guess.',
1116
+ abortSignal: this.config.abortSignal,
1117
+ telemetry: this.config.telemetry,
1118
+ });
1119
+ const merged = mergeExtractionData(this.context.collectedData, extracted);
1120
+ // Count how many fields are genuinely new (not already in collectedData)
1121
+ let newFields = 0;
1122
+ for (const [key, value] of Object.entries(merged)) {
1123
+ if (value !== null && value !== undefined && before[key] !== value) {
1124
+ newFields++;
1125
+ }
1126
+ }
1127
+ Object.assign(this.context.collectedData, merged);
1128
+ return newFields;
1129
+ }
1130
+ }
1131
+ catch {
1132
+ // Extraction failed; continue with what we have
1133
+ }
1134
+ return 0;
1135
+ }
1136
+ /**
1137
+ * Cross-node extraction carry-forward: when transitioning from one extraction
1138
+ * node to another, re-extract from the last user message against the new node's
1139
+ * schema. This handles the case where a user provides data for multiple schemas
1140
+ * in a single message (e.g., incident details AND vehicle details in one turn).
1141
+ *
1142
+ * Stops the cascade when zero new fields are extracted -- the user message has
1143
+ * no data for this schema, so further hops would waste LLM calls.
1144
+ */
1145
+ async *runExtractionCarryForward(nextNode) {
1146
+ // Try to extract from the last user message against the new schema.
1147
+ const newFieldCount = await this.extractFromLastUserMessage(nextNode);
1148
+ // If we extracted nothing new, stop cascading. The user message has no data
1149
+ // for this schema. Ask the user for the missing information instead of
1150
+ // burning LLM calls on downstream nodes.
1151
+ if (newFieldCount === 0) {
1152
+ if (nextNode.autoRespond !== false) {
1153
+ yield* this.runInference(false);
1154
+ }
1155
+ return;
1156
+ }
1157
+ // If carry-forward satisfied the schema, continue the cascade.
1158
+ if (this.isExtractionComplete(nextNode)) {
1159
+ this.emitMetric('flow.extraction.complete', { nodeId: nextNode.id, turns: 0 });
1160
+ if (nextNode.extractionCompleteTransition) {
1161
+ yield* this.transitionToGenerator(nextNode.extractionCompleteTransition, this.context.collectedData);
1162
+ if (!this.flowEnded && this.currentNodeConfig) {
1163
+ if (isExtractionNode(this.currentNodeConfig)) {
1164
+ yield* this.runExtractionCarryForward(this.currentNodeConfig);
1165
+ }
1166
+ else if (this.currentNodeConfig.autoRespond !== false) {
1167
+ yield* this.runInference(false);
1168
+ }
1169
+ }
1170
+ }
1171
+ return;
1172
+ }
1173
+ // Extracted some fields but not enough -- ask for the rest.
1174
+ if (nextNode.autoRespond !== false) {
1175
+ yield* this.runInference(false);
1176
+ }
1177
+ }
745
1178
  }
746
1179
  function isRecord(value) {
747
1180
  return typeof value === 'object' && value !== null;