@kuralle-agents/core 0.13.0 → 0.15.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 (160) hide show
  1. package/README.md +4 -4
  2. package/dist/ai-sdk/uiMessageStream.d.ts +2 -2
  3. package/dist/ai-sdk/uiMessageStream.js +42 -29
  4. package/dist/capabilities/LivePromptAssembler.d.ts +1 -1
  5. package/dist/capabilities/LivePromptAssembler.js +1 -1
  6. package/dist/capabilities/index.d.ts +1 -1
  7. package/dist/eval/EvalRunner.js +4 -4
  8. package/dist/eval/simulation.js +4 -3
  9. package/dist/events/TurnHandle.d.ts +3 -3
  10. package/dist/flow/collectUntilComplete.js +30 -7
  11. package/dist/flow/degrade.js +8 -4
  12. package/dist/flow/emitInteractive.d.ts +2 -2
  13. package/dist/flow/emitInteractive.js +6 -3
  14. package/dist/flow/extraction.d.ts +21 -2
  15. package/dist/flow/extraction.js +62 -7
  16. package/dist/flow/reduceTransition.d.ts +2 -2
  17. package/dist/flow/reduceTransition.js +7 -3
  18. package/dist/flow/runFlow.d.ts +11 -0
  19. package/dist/flow/runFlow.js +68 -12
  20. package/dist/foundation/AgentStateController.d.ts +1 -1
  21. package/dist/foundation/ToolExecutor.d.ts +2 -2
  22. package/dist/foundation/index.d.ts +0 -11
  23. package/dist/foundation/index.js +0 -5
  24. package/dist/index.d.ts +9 -14
  25. package/dist/index.js +5 -6
  26. package/dist/outcomes/streamPart.d.ts +2 -2
  27. package/dist/outcomes/streamPart.js +2 -3
  28. package/dist/prompts/PromptBuilder.d.ts +3 -3
  29. package/dist/prompts/PromptBuilder.js +6 -6
  30. package/dist/prompts/index.d.ts +1 -1
  31. package/dist/prompts/types.d.ts +7 -2
  32. package/dist/runtime/InMemoryRetrievalCache.d.ts +1 -1
  33. package/dist/runtime/KnowledgeProvider.d.ts +3 -2
  34. package/dist/runtime/KnowledgeProvider.js +54 -30
  35. package/dist/runtime/Runtime.d.ts +9 -1
  36. package/dist/runtime/Runtime.js +123 -30
  37. package/dist/runtime/TraceRecorder.d.ts +22 -4
  38. package/dist/runtime/TraceRecorder.js +65 -29
  39. package/dist/runtime/buildAgentToolSurface.js +1 -1
  40. package/dist/runtime/channels/TextDriver.js +12 -9
  41. package/dist/runtime/channels/executeModelTool.d.ts +6 -0
  42. package/dist/runtime/channels/executeModelTool.js +70 -10
  43. package/dist/runtime/channels/extractionTurn.js +1 -1
  44. package/dist/runtime/channels/index.d.ts +0 -3
  45. package/dist/runtime/channels/index.js +0 -6
  46. package/dist/runtime/channels/streaming/hostControlSpeak.js +5 -1
  47. package/dist/runtime/channels/streaming/speakGated.js +33 -12
  48. package/dist/runtime/citations/index.d.ts +3 -3
  49. package/dist/runtime/closeRun.js +3 -2
  50. package/dist/runtime/controlFlowSignal.d.ts +24 -0
  51. package/dist/runtime/controlFlowSignal.js +29 -0
  52. package/dist/runtime/ctx.d.ts +3 -4
  53. package/dist/runtime/ctx.js +39 -5
  54. package/dist/runtime/durable/RunStore.js +1 -1
  55. package/dist/runtime/grounding/gather.d.ts +1 -1
  56. package/dist/runtime/grounding/inMemoryKnowledge.d.ts +1 -1
  57. package/dist/runtime/grounding/knowledge.d.ts +1 -1
  58. package/dist/runtime/hostLoop.js +39 -10
  59. package/dist/runtime/index.d.ts +1 -2
  60. package/dist/runtime/index.js +1 -1
  61. package/dist/runtime/outcomeMarking.d.ts +2 -2
  62. package/dist/runtime/outcomeMarking.js +2 -1
  63. package/dist/runtime/policies/agentTurn.d.ts +1 -1
  64. package/dist/runtime/runHookSafely.d.ts +3 -0
  65. package/dist/runtime/runHookSafely.js +19 -0
  66. package/dist/scheduler/index.d.ts +2 -2
  67. package/dist/scheduler/index.js +1 -1
  68. package/dist/services/TracingService.js +2 -3
  69. package/dist/skills/collectSkills.d.ts +11 -3
  70. package/dist/skills/collectSkills.js +51 -8
  71. package/dist/skills/compositeSkillStore.d.ts +26 -0
  72. package/dist/skills/compositeSkillStore.js +54 -0
  73. package/dist/skills/defineSkill.d.ts +21 -0
  74. package/dist/skills/defineSkill.js +26 -0
  75. package/dist/skills/fsSkillStore.d.ts +3 -0
  76. package/dist/skills/fsSkillStore.js +79 -0
  77. package/dist/skills/index.d.ts +4 -0
  78. package/dist/skills/index.js +4 -0
  79. package/dist/skills/parseSkillFrontmatter.d.ts +14 -0
  80. package/dist/skills/parseSkillFrontmatter.js +183 -0
  81. package/dist/skills/wireAgentSkills.d.ts +2 -1
  82. package/dist/skills/wireAgentSkills.js +2 -2
  83. package/dist/testing/mocks.d.ts +3 -3
  84. package/dist/testing/mocks.js +7 -3
  85. package/dist/tools/controlResults.d.ts +14 -0
  86. package/dist/tools/controlResults.js +15 -0
  87. package/dist/tools/effect/ToolExecutor.d.ts +6 -0
  88. package/dist/tools/effect/ToolExecutor.js +71 -35
  89. package/dist/tools/effect/defineTool.d.ts +9 -0
  90. package/dist/tools/effect/defineTool.js +1 -0
  91. package/dist/tools/effect/errors.d.ts +2 -5
  92. package/dist/tools/effect/errors.js +2 -5
  93. package/dist/tools/fs/createFsTool.js +2 -1
  94. package/dist/tools/index.d.ts +0 -1
  95. package/dist/tools/index.js +0 -1
  96. package/dist/types/effectTool.d.ts +2 -0
  97. package/dist/types/flow.d.ts +1 -1
  98. package/dist/types/guardrails.d.ts +5 -0
  99. package/dist/types/hooks.d.ts +2 -2
  100. package/dist/types/index.d.ts +25 -16
  101. package/dist/types/index.js +8 -13
  102. package/dist/types/knowledge.d.ts +213 -0
  103. package/dist/types/processors.d.ts +0 -58
  104. package/dist/types/run-context.d.ts +3 -7
  105. package/dist/types/runtime.d.ts +3 -74
  106. package/dist/types/session.d.ts +1 -1
  107. package/dist/types/skills.d.ts +15 -1
  108. package/dist/types/stream.d.ts +133 -71
  109. package/dist/types/stream.js +34 -1
  110. package/dist/types/trace.d.ts +2 -0
  111. package/guides/AGENTS.md +3 -3
  112. package/guides/FLOWS.md +1 -1
  113. package/guides/GETTING_STARTED.md +1 -1
  114. package/guides/RUNTIME.md +20 -15
  115. package/guides/TOOLS.md +1 -1
  116. package/package.json +3 -13
  117. package/dist/foundation/AgentDefinition.d.ts +0 -17
  118. package/dist/foundation/ConversationEventLog.d.ts +0 -71
  119. package/dist/foundation/ConversationEventLog.js +0 -1
  120. package/dist/foundation/ConversationState.d.ts +0 -33
  121. package/dist/foundation/ConversationState.js +0 -1
  122. package/dist/foundation/DefaultConversationEventLog.d.ts +0 -27
  123. package/dist/foundation/DefaultConversationEventLog.js +0 -205
  124. package/dist/foundation/DefaultConversationState.d.ts +0 -36
  125. package/dist/foundation/DefaultConversationState.js +0 -103
  126. package/dist/foundation/DefaultToolExecutor.d.ts +0 -57
  127. package/dist/foundation/DefaultToolExecutor.js +0 -132
  128. package/dist/foundation/createFoundation.d.ts +0 -32
  129. package/dist/foundation/createFoundation.js +0 -33
  130. package/dist/hooks/HookRunner.d.ts +0 -37
  131. package/dist/hooks/HookRunner.js +0 -111
  132. package/dist/hooks/builtin/logging.d.ts +0 -5
  133. package/dist/hooks/builtin/logging.js +0 -98
  134. package/dist/hooks/builtin/metrics.d.ts +0 -7
  135. package/dist/hooks/builtin/metrics.js +0 -60
  136. package/dist/hooks/builtin/observability.d.ts +0 -20
  137. package/dist/hooks/builtin/observability.js +0 -572
  138. package/dist/hooks/helpers.d.ts +0 -22
  139. package/dist/hooks/helpers.js +0 -183
  140. package/dist/hooks/index.d.ts +0 -8
  141. package/dist/hooks/index.js +0 -6
  142. package/dist/realtime/RealtimeAudioClient.d.ts +0 -166
  143. package/dist/realtime/RealtimeAudioClient.js +0 -14
  144. package/dist/realtime/RealtimeSessionHandle.d.ts +0 -6
  145. package/dist/realtime/RealtimeSessionHandle.js +0 -1
  146. package/dist/realtime/index.d.ts +0 -2
  147. package/dist/realtime/index.js +0 -1
  148. package/dist/runtime/channels/VoiceDriver.d.ts +0 -31
  149. package/dist/runtime/channels/VoiceDriver.js +0 -321
  150. package/dist/runtime/channels/voiceTools.d.ts +0 -9
  151. package/dist/runtime/channels/voiceTools.js +0 -51
  152. package/dist/tools/errorHandling.d.ts +0 -35
  153. package/dist/tools/errorHandling.js +0 -145
  154. package/dist/types/voice.d.ts +0 -517
  155. package/dist/types/voice.js +0 -14
  156. package/dist/utils/isRecord.d.ts +0 -1
  157. package/dist/utils/isRecord.js +0 -3
  158. package/dist/utils/messageNormalization.d.ts +0 -2
  159. package/dist/utils/messageNormalization.js +0 -137
  160. /package/dist/{foundation/AgentDefinition.js → types/knowledge.js} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @kuralle-agents/core
2
2
 
3
- The runtime and primitives for building conversational AI agents — text and voice — with structured flows, routing, and durable tool execution.
3
+ The runtime and primitives for building conversational AI agents with structured flows, routing, and durable tool execution.
4
4
 
5
5
  ## Install
6
6
 
@@ -54,8 +54,8 @@ const runtime = createRuntime({ agents: [agent], defaultAgentId: 'support' });
54
54
 
55
55
  const handle = runtime.run({ input: 'Hello', sessionId: 'demo' });
56
56
  for await (const part of handle.events) { // events is a property, not a method
57
- if (part.type === 'text-delta') process.stdout.write(part.delta);
58
- if (part.type === 'done') console.log('\nSession:', part.sessionId);
57
+ if (part.type === 'text-delta') process.stdout.write(part.payload.delta);
58
+ if (part.type === 'done') console.log('\nSession:', part.payload.sessionId);
59
59
  }
60
60
  await handle; // resolves to TurnResult once the stream is consumed
61
61
  ```
@@ -230,7 +230,7 @@ return handle.toUIMessageStreamResponse({ sessionId: 'demo' });
230
230
 
231
231
  Kuralle orchestration events (flow telemetry, safety blocks, interactive choices) arrive as typed `data-kuralle-*` parts. Import `KuralleUIMessage` and `KuralleDataParts` for compile-time-safe `message.parts` and `useChat({ onData })` handlers.
232
232
 
233
- For non-UI consumers (curl, custom transports), use `handle.toResponseStream('sse')` to emit raw `HarnessStreamPart` JSON-SSE. Or use `@kuralle-agents/hono-server` — `POST /api/chat/sse` defaults to native `UIMessageStream`; append `?format=raw` for the legacy wire.
233
+ For non-UI consumers (curl, custom transports), use `handle.toResponseStream('sse')` to emit raw `StreamPart` JSON-SSE. Or use `@kuralle-agents/hono-server` — `POST /api/chat/sse` defaults to native `UIMessageStream`; append `?format=raw` for the legacy wire.
234
234
 
235
235
  ## Related
236
236
 
@@ -1,7 +1,7 @@
1
1
  import { type UIMessage } from 'ai';
2
2
  import type { ConversationOutcome } from '../outcomes/types.js';
3
3
  import type { ChoiceOption } from '../types/selection.js';
4
- import type { HarnessStreamPart } from '../types/stream.js';
4
+ import type { StreamPart } from '../types/stream.js';
5
5
  export type KuralleMetadata = {
6
6
  sessionId?: string;
7
7
  };
@@ -46,6 +46,6 @@ export type KuralleDataParts = {
46
46
  };
47
47
  };
48
48
  export type KuralleUIMessage = UIMessage<KuralleMetadata, KuralleDataParts>;
49
- export declare function harnessToUIMessageStream(source: AsyncIterable<HarnessStreamPart>, opts?: {
49
+ export declare function harnessToUIMessageStream(source: AsyncIterable<StreamPart>, opts?: {
50
50
  sessionId?: string;
51
51
  }): ReadableStream;
@@ -2,64 +2,64 @@ import { createUIMessageStream, generateId, } from 'ai';
2
2
  function writeHarnessPart(part, writer) {
3
3
  switch (part.type) {
4
4
  case 'text-start':
5
- writer.write({ type: 'text-start', id: part.id });
5
+ writer.write({ type: 'text-start', id: part.payload.id });
6
6
  break;
7
7
  case 'text-delta':
8
- writer.write({ type: 'text-delta', id: part.id, delta: part.delta });
8
+ writer.write({ type: 'text-delta', id: part.payload.id, delta: part.payload.delta });
9
9
  break;
10
10
  case 'text-end':
11
- writer.write({ type: 'text-end', id: part.id });
11
+ writer.write({ type: 'text-end', id: part.payload.id });
12
12
  break;
13
13
  case 'text-cancel':
14
- writer.write({ type: 'text-end', id: part.id });
14
+ writer.write({ type: 'text-end', id: part.payload.id });
15
15
  break;
16
16
  case 'tool-call':
17
17
  writer.write({
18
18
  type: 'tool-input-available',
19
- toolCallId: part.toolCallId ?? generateId(),
20
- toolName: part.toolName,
21
- input: part.args,
19
+ toolCallId: part.payload.toolCallId ?? generateId(),
20
+ toolName: part.payload.toolName,
21
+ input: part.payload.args,
22
22
  });
23
23
  break;
24
24
  case 'tool-result':
25
25
  writer.write({
26
26
  type: 'tool-output-available',
27
- toolCallId: part.toolCallId ?? 'unknown',
28
- output: part.result,
27
+ toolCallId: part.payload.toolCallId ?? 'unknown',
28
+ output: part.payload.result,
29
29
  });
30
30
  break;
31
31
  case 'node-enter':
32
32
  writer.write({
33
33
  type: 'data-kuralle-node',
34
- data: { event: 'enter', node: part.nodeName },
34
+ data: { event: 'enter', node: part.payload.nodeName },
35
35
  transient: true,
36
36
  });
37
37
  break;
38
38
  case 'node-exit':
39
39
  writer.write({
40
40
  type: 'data-kuralle-node',
41
- data: { event: 'exit', node: part.nodeName },
41
+ data: { event: 'exit', node: part.payload.nodeName },
42
42
  transient: true,
43
43
  });
44
44
  break;
45
45
  case 'flow-enter':
46
46
  writer.write({
47
47
  type: 'data-kuralle-flow',
48
- data: { event: 'enter', flow: part.flow },
48
+ data: { event: 'enter', flow: part.payload.flow },
49
49
  transient: true,
50
50
  });
51
51
  break;
52
52
  case 'flow-end':
53
53
  writer.write({
54
54
  type: 'data-kuralle-flow',
55
- data: { event: 'end', flow: part.flow, reason: part.reason },
55
+ data: { event: 'end', flow: part.payload.flow, reason: part.payload.reason },
56
56
  transient: true,
57
57
  });
58
58
  break;
59
59
  case 'flow-transition':
60
60
  writer.write({
61
61
  type: 'data-kuralle-flow',
62
- data: { event: 'transition', from: part.from, to: part.to },
62
+ data: { event: 'transition', from: part.payload.from, to: part.payload.to },
63
63
  transient: true,
64
64
  });
65
65
  break;
@@ -67,14 +67,18 @@ function writeHarnessPart(part, writer) {
67
67
  writer.write({
68
68
  type: 'data-kuralle-handoff',
69
69
  id: generateId(),
70
- data: { targetAgent: part.targetAgent, reason: part.reason },
70
+ data: { targetAgent: part.payload.targetAgent, reason: part.payload.reason },
71
71
  });
72
72
  break;
73
73
  case 'interactive':
74
74
  writer.write({
75
75
  type: 'data-kuralle-interactive',
76
- id: part.nodeId,
77
- data: { nodeId: part.nodeId, prompt: part.prompt, options: part.options },
76
+ id: part.payload.nodeId,
77
+ data: {
78
+ nodeId: part.payload.nodeId,
79
+ prompt: part.payload.prompt,
80
+ options: part.payload.options,
81
+ },
78
82
  });
79
83
  break;
80
84
  case 'safety-blocked':
@@ -83,9 +87,9 @@ function writeHarnessPart(part, writer) {
83
87
  id: generateId(),
84
88
  data: {
85
89
  kind: 'safety-blocked',
86
- moderator: part.moderator,
87
- rationale: part.rationale,
88
- userFacingMessage: part.userFacingMessage,
90
+ moderator: part.payload.moderator,
91
+ rationale: part.payload.rationale,
92
+ userFacingMessage: part.payload.userFacingMessage,
89
93
  },
90
94
  });
91
95
  break;
@@ -95,8 +99,8 @@ function writeHarnessPart(part, writer) {
95
99
  id: generateId(),
96
100
  data: {
97
101
  kind: 'pipeline-validation-block',
98
- rationale: part.rationale,
99
- userFacingMessage: part.userFacingMessage,
102
+ rationale: part.payload.rationale,
103
+ userFacingMessage: part.payload.userFacingMessage,
100
104
  },
101
105
  });
102
106
  break;
@@ -104,35 +108,44 @@ function writeHarnessPart(part, writer) {
104
108
  writer.write({
105
109
  type: 'data-kuralle-outcome',
106
110
  id: generateId(),
107
- data: { outcome: part.outcome },
111
+ data: { outcome: part.payload.outcome },
108
112
  });
109
113
  break;
110
114
  case 'interrupted':
111
115
  writer.write({
112
116
  type: 'data-kuralle-control',
113
- data: { event: 'interrupted', reason: part.reason },
117
+ data: { event: 'interrupted', reason: part.payload.reason },
114
118
  transient: true,
115
119
  });
116
120
  break;
117
121
  case 'paused':
118
122
  writer.write({
119
123
  type: 'data-kuralle-control',
120
- data: { event: 'paused', waitingFor: part.waitingFor },
124
+ data: { event: 'paused', waitingFor: part.payload.waitingFor },
121
125
  transient: true,
122
126
  });
123
127
  break;
124
128
  case 'custom':
125
129
  writer.write({
126
130
  type: 'data-kuralle-custom',
127
- data: { name: part.name, data: part.data },
131
+ data: { name: part.payload.name, data: part.payload.data },
128
132
  transient: true,
129
133
  });
130
134
  break;
131
135
  case 'error':
132
- throw new Error(part.error);
136
+ throw new Error(part.payload.error);
133
137
  case 'done':
134
138
  case 'turn-end':
135
139
  break;
140
+ default: {
141
+ // Client-channel exhaustiveness guard. Internal-channel parts are
142
+ // deliberately unmapped here — they never reach a UI client. But a new
143
+ // `client` variant added without a case above would be silently dropped
144
+ // on the ADR-0005 default output path, so pin the residual to internal:
145
+ // adding a client variant without a case fails this assignment.
146
+ const _internalOnly = part;
147
+ void _internalOnly;
148
+ }
136
149
  }
137
150
  }
138
151
  export function harnessToUIMessageStream(source, opts) {
@@ -146,8 +159,8 @@ export function harnessToUIMessageStream(source, opts) {
146
159
  });
147
160
  }
148
161
  for await (const part of source) {
149
- if (part.type === 'done' && part.sessionId) {
150
- doneSessionId = doneSessionId ?? part.sessionId;
162
+ if (part.type === 'done' && part.payload.sessionId) {
163
+ doneSessionId = doneSessionId ?? part.payload.sessionId;
151
164
  }
152
165
  else {
153
166
  writeHarnessPart(part, writer);
@@ -7,7 +7,7 @@
7
7
  * CapabilityHost with Runtime context (working memory, long-term memory,
8
8
  * policy injections) and structures the output per audio-model best practices.
9
9
  *
10
- * Adapters (Gemini, OpenAI Realtime, LiveKit) can extend or replace
10
+ * Adapters can extend or replace
11
11
  * the default with backend-specific voice rules and guardrails.
12
12
  *
13
13
  * Prompt structure follows Google's Live API best practices and
@@ -7,7 +7,7 @@
7
7
  * CapabilityHost with Runtime context (working memory, long-term memory,
8
8
  * policy injections) and structures the output per audio-model best practices.
9
9
  *
10
- * Adapters (Gemini, OpenAI Realtime, LiveKit) can extend or replace
10
+ * Adapters can extend or replace
11
11
  * the default with backend-specific voice rules and guardrails.
12
12
  *
13
13
  * Prompt structure follows Google's Live API best practices and
@@ -1,7 +1,7 @@
1
1
  import type { ZodTypeAny } from 'zod';
2
2
  /**
3
3
  * Backend-agnostic tool declaration. Produced by capabilities,
4
- * consumed by adapters (Gemini, AI SDK, LiveKit).
4
+ * consumed by adapters (AI SDK and others).
5
5
  */
6
6
  export interface ToolDeclaration<TInput = unknown, TOutput = unknown> {
7
7
  name: string;
@@ -25,16 +25,16 @@ export class EvalRunner {
25
25
  const handle = runtime.run({ input: turn.input, sessionId });
26
26
  for await (const part of handle.events) {
27
27
  if (part.type === 'text-delta') {
28
- response += part.delta;
28
+ response += part.payload.delta;
29
29
  }
30
30
  if (part.type === 'tool-call') {
31
- toolsCalled.push(part.toolName);
31
+ toolsCalled.push(part.payload.toolName);
32
32
  }
33
33
  if (part.type === 'flow-transition') {
34
- transitions.push({ from: part.from, to: part.to });
34
+ transitions.push({ from: part.payload.from, to: part.payload.to });
35
35
  }
36
36
  if (part.type === 'done') {
37
- sessionId = part.sessionId;
37
+ sessionId = part.payload.sessionId;
38
38
  }
39
39
  }
40
40
  await handle;
@@ -66,10 +66,11 @@ export async function simulateConversation(options) {
66
66
  let reply = '';
67
67
  for await (const part of handle.events) {
68
68
  if (part.type === 'text-delta')
69
- reply += part.delta;
69
+ reply += part.payload.delta;
70
70
  if (part.type === 'tool-call')
71
- toolsCalled.push(part.toolName);
72
- if (part.type === 'escalation' || (part.type === 'handoff' && part.targetAgent === 'human')) {
71
+ toolsCalled.push(part.payload.toolName);
72
+ if (part.type === 'escalation' ||
73
+ (part.type === 'handoff' && part.payload.targetAgent === 'human')) {
73
74
  escalated = true;
74
75
  }
75
76
  }
@@ -1,7 +1,7 @@
1
- import type { HarnessStreamPart, TurnHandle } from '../types/stream.js';
1
+ import type { StreamPart, TurnHandle } from '../types/stream.js';
2
2
  export interface EventBus {
3
- emit(part: HarnessStreamPart): void;
4
- events(): AsyncIterable<HarnessStreamPart>;
3
+ emit(part: StreamPart): void;
4
+ events(): AsyncIterable<StreamPart>;
5
5
  close(): void;
6
6
  }
7
7
  export declare function createEventBus(): EventBus;
@@ -3,7 +3,7 @@ import { persistTurnUsageFromTurn } from '../runtime/turnTokenUsage.js';
3
3
  import { hasPendingUserInput } from '../runtime/channels/inputBuffer.js';
4
4
  import { userInputToText } from '../runtime/userInput.js';
5
5
  import { resolveCollectExtractionNode } from './nodeBuilders.js';
6
- import { computeMissingFields, createExtractionSubmitTool, getCollectData, incrementCollectTurns, emitExtractionTelemetry, mergeTurnExtraction, projectCollectData, schemaSatisfied, } from './extraction.js';
6
+ import { computeMissingFields, createExtractionSubmitTool, getCollectData, incrementCollectTurns, emitExtractionTelemetry, mergeTurnExtraction, projectCollectData, schemaSatisfied, invalidCollectFields, setCollectData, } from './extraction.js';
7
7
  import { normalizeTransition } from './normalizeTransition.js';
8
8
  function appendAssistantMessage(run, text) {
9
9
  if (!text.trim()) {
@@ -50,8 +50,31 @@ export async function collectUntilComplete(node, run, driver, ctx, options) {
50
50
  const turns = incrementCollectTurns(run.state, node.id);
51
51
  const maxTurns = node.maxTurns ?? 10;
52
52
  if (turns > maxTurns) {
53
- const data = projectCollectData(node, run.state);
54
- return normalizeTransition(await node.onComplete(data, run.state));
53
+ // Running out of turns is not the same as finishing. Calling onComplete here with
54
+ // required fields still missing hands the rest of the flow a half-filled record and
55
+ // lets an action node act on it — for an intake SOP that means creating a record
56
+ // against whatever happened to be in state. Complete only if the node is genuinely
57
+ // satisfied; otherwise this is a failure to collect, and a human should see it.
58
+ if (schemaSatisfied(node, run.state)) {
59
+ const data = projectCollectData(node, run.state);
60
+ return normalizeTransition(await node.onComplete(data, run.state));
61
+ }
62
+ const missing = computeMissingFields(node, getCollectData(run.state, node.id));
63
+ return normalizeTransition({
64
+ escalate: `Could not collect ${missing.join(', ')} for "${node.id}" after ${maxTurns} turns.`,
65
+ });
66
+ }
67
+ // Drop any collected value the schema rejects before working out what to ask for.
68
+ // Without this a value that is present-but-invalid is neither missing (so it is never
69
+ // re-asked) nor satisfying (so the node never completes) — the loop would spin to
70
+ // maxTurns. Purging turns an invalid value back into a missing one, which the existing
71
+ // ask/extract machinery already knows how to chase.
72
+ const collected = getCollectData(run.state, node.id);
73
+ const invalid = invalidCollectFields(node, collected);
74
+ if (invalid.length > 0) {
75
+ for (const field of invalid)
76
+ delete collected[field];
77
+ setCollectData(run.state, node.id, collected);
55
78
  }
56
79
  const missingBefore = computeMissingFields(node, getCollectData(run.state, node.id));
57
80
  const submitTool = createExtractionSubmitTool(node, missingBefore, {
@@ -121,10 +144,10 @@ export function emitCollectAsk(node, run, ctx) {
121
144
  return;
122
145
  }
123
146
  const id = crypto.randomUUID();
124
- ctx.emit({ type: 'text-start', id });
125
- ctx.emit({ type: 'text-delta', id, delta: text });
126
- ctx.emit({ type: 'text-end', id });
127
- ctx.emit({ type: 'turn-end' });
147
+ ctx.emit({ channel: 'client', type: 'text-start', payload: { id } });
148
+ ctx.emit({ channel: 'client', type: 'text-delta', payload: { id, delta: text } });
149
+ ctx.emit({ channel: 'client', type: 'text-end', payload: { id } });
150
+ ctx.emit({ channel: 'internal', type: 'turn-end', payload: {} });
128
151
  appendAssistantMessage(run, text);
129
152
  }
130
153
  function mergeExtractionFromTurn(node, run, turn, ctx) {
@@ -7,9 +7,9 @@ export function appendSafeAssistantMessage(run, ctx, text = SAFE_DEGRADED_MESSAG
7
7
  const message = { role: 'assistant', content: text };
8
8
  run.messages = [...run.messages, message];
9
9
  const id = crypto.randomUUID();
10
- ctx.emit({ type: 'text-start', id });
11
- ctx.emit({ type: 'text-delta', id, delta: text });
12
- ctx.emit({ type: 'text-end', id });
10
+ ctx.emit({ channel: 'client', type: 'text-start', payload: { id } });
11
+ ctx.emit({ channel: 'client', type: 'text-delta', payload: { id, delta: text } });
12
+ ctx.emit({ channel: 'client', type: 'text-end', payload: { id } });
13
13
  }
14
14
  export async function degradeFlowError(flow, registry, run, driver, ctx, dispatchNode) {
15
15
  appendSafeAssistantMessage(run, ctx);
@@ -29,6 +29,10 @@ export async function degradeFlowError(flow, registry, run, driver, ctx, dispatc
29
29
  // Fall through to graceful end if the escalate node also fails.
30
30
  }
31
31
  }
32
- ctx.emit({ type: 'flow-end', flow: flow.name, reason: 'error_degraded' });
32
+ ctx.emit({
33
+ channel: 'internal',
34
+ type: 'flow-end',
35
+ payload: { flow: flow.name, reason: 'error_degraded' },
36
+ });
33
37
  return { kind: 'ended', reason: 'error_degraded' };
34
38
  }
@@ -1,3 +1,3 @@
1
1
  import type { FlowNode, FlowState } from '../types/flow.js';
2
- import type { HarnessStreamPart } from '../types/stream.js';
3
- export declare function emitInteractiveOnNodeEnter(node: FlowNode, state: FlowState, emit: (part: HarnessStreamPart) => void): void;
2
+ import type { StreamPart } from '../types/stream.js';
3
+ export declare function emitInteractiveOnNodeEnter(node: FlowNode, state: FlowState, emit: (part: StreamPart) => void): void;
@@ -35,9 +35,12 @@ export function emitInteractiveOnNodeEnter(node, state, emit) {
35
35
  return;
36
36
  }
37
37
  emit({
38
+ channel: 'internal',
38
39
  type: 'interactive',
39
- nodeId: node.id,
40
- options: node.choices,
41
- prompt: interactivePrompt(node, state),
40
+ payload: {
41
+ nodeId: node.id,
42
+ options: node.choices,
43
+ prompt: interactivePrompt(node, state),
44
+ },
42
45
  });
43
46
  }
@@ -1,10 +1,28 @@
1
+ import type { StandardSchemaV1 } from '../types/standard-schema.js';
1
2
  import type { CollectNode, FlowState } from '../types/flow.js';
2
3
  import type { Tool } from '../types/effectTool.js';
3
- import type { HarnessStreamPart } from '../types/stream.js';
4
+ import type { StreamPart } from '../types/stream.js';
4
5
  export declare function resetCollect(state: FlowState, nodeId: string): void;
5
6
  export declare function getCollectData(state: FlowState, nodeId: string): Record<string, unknown>;
7
+ export declare function setCollectData(state: FlowState, nodeId: string, data: Record<string, unknown>): void;
6
8
  export declare function incrementCollectTurns(state: FlowState, nodeId: string): number;
7
9
  export declare function computeMissingFields(node: CollectNode, data: Record<string, unknown>): string[];
10
+ /**
11
+ * Fields whose collected value is present but does NOT satisfy the node's schema.
12
+ *
13
+ * Completion used to be presence-only: any non-empty value passed, so a model that invented
14
+ * an enum member (`urgency: "high"` against `z.enum(['emergency','urgent','routine'])`) or
15
+ * supplied the wrong primitive completed the node and the flow acted on it. The schema was
16
+ * only ever used to derive key names, never to check a value.
17
+ *
18
+ * Only validates synchronously — StandardSchema permits an async validator, and the
19
+ * completion gate is sync. Zod is sync, which covers every schema in this repo; an async
20
+ * validator degrades to the old presence-only behaviour rather than blocking.
21
+ *
22
+ * NOTE this catches SHAPE, not REFERENT. `unitId: "12B"` is a perfectly valid string; that a
23
+ * unit by that id does not exist is a domain fact only a tool boundary can know.
24
+ */
25
+ export declare function invalidCollectFields(node: CollectNode, data: Record<string, unknown>): string[];
8
26
  export declare function schemaSatisfied(node: CollectNode, state: FlowState): boolean;
9
27
  export declare function projectCollectData(node: CollectNode, state: FlowState): unknown;
10
28
  export declare function mergeExtractionData(current: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown>;
@@ -20,4 +38,5 @@ export declare function mergeTurnExtraction(node: CollectNode, state: FlowState,
20
38
  name: string;
21
39
  result: unknown;
22
40
  }>): MergeTurnExtractionResult;
23
- export declare function emitExtractionTelemetry(node: CollectNode, state: FlowState, incoming: Record<string, unknown>, emit: (part: HarnessStreamPart) => void): void;
41
+ export declare function emitExtractionTelemetry(node: CollectNode, state: FlowState, incoming: Record<string, unknown>, emit: (part: StreamPart) => void): void;
42
+ export declare function inferRequiredFields(schema: StandardSchemaV1): string[];
@@ -17,7 +17,7 @@ export function getCollectData(state, nodeId) {
17
17
  }
18
18
  return {};
19
19
  }
20
- function setCollectData(state, nodeId, data) {
20
+ export function setCollectData(state, nodeId, data) {
21
21
  state[collectDataKey(nodeId)] = data;
22
22
  }
23
23
  function getCollectTurns(state, nodeId) {
@@ -33,9 +33,58 @@ export function computeMissingFields(node, data) {
33
33
  const required = node.required ?? inferRequiredFields(node.schema);
34
34
  return required.filter((field) => !fieldPopulated(data[field]));
35
35
  }
36
+ /**
37
+ * Fields whose collected value is present but does NOT satisfy the node's schema.
38
+ *
39
+ * Completion used to be presence-only: any non-empty value passed, so a model that invented
40
+ * an enum member (`urgency: "high"` against `z.enum(['emergency','urgent','routine'])`) or
41
+ * supplied the wrong primitive completed the node and the flow acted on it. The schema was
42
+ * only ever used to derive key names, never to check a value.
43
+ *
44
+ * Only validates synchronously — StandardSchema permits an async validator, and the
45
+ * completion gate is sync. Zod is sync, which covers every schema in this repo; an async
46
+ * validator degrades to the old presence-only behaviour rather than blocking.
47
+ *
48
+ * NOTE this catches SHAPE, not REFERENT. `unitId: "12B"` is a perfectly valid string; that a
49
+ * unit by that id does not exist is a domain fact only a tool boundary can know.
50
+ */
51
+ export function invalidCollectFields(node, data) {
52
+ const validate = node.schema['~standard']?.validate;
53
+ if (typeof validate !== 'function')
54
+ return [];
55
+ let result;
56
+ try {
57
+ result = validate(data);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ if (result instanceof Promise)
63
+ return [];
64
+ const issues = result
65
+ ?.issues;
66
+ if (!issues?.length)
67
+ return [];
68
+ const fields = new Set();
69
+ for (const issue of issues) {
70
+ const first = issue.path?.[0];
71
+ const key = typeof first === 'string'
72
+ ? first
73
+ : typeof first?.key === 'string'
74
+ ? (first.key)
75
+ : undefined;
76
+ // Only report fields the node actually collected. An issue on a field that was never
77
+ // supplied is a missing-field problem, which computeMissingFields already owns.
78
+ if (key && key in data)
79
+ fields.add(key);
80
+ }
81
+ return [...fields];
82
+ }
36
83
  export function schemaSatisfied(node, state) {
37
84
  const data = getCollectData(state, node.id);
38
- return computeMissingFields(node, data).length === 0;
85
+ if (computeMissingFields(node, data).length > 0)
86
+ return false;
87
+ return invalidCollectFields(node, data).length === 0;
39
88
  }
40
89
  export function projectCollectData(node, state) {
41
90
  // Hand onComplete EVERY field the node collected (all schema keys present in
@@ -129,16 +178,22 @@ export function emitExtractionTelemetry(node, state, incoming, emit) {
129
178
  }
130
179
  }
131
180
  emit({
181
+ channel: 'internal',
132
182
  type: 'custom',
133
- name: 'flow.extraction.submission',
134
- data: { node: node.id, fieldsAccepted, fieldsRejected },
183
+ payload: {
184
+ name: 'flow.extraction.submission',
185
+ data: { node: node.id, fieldsAccepted, fieldsRejected },
186
+ },
135
187
  });
136
188
  const collected = getCollectData(state, node.id);
137
189
  const missing = computeMissingFields(node, collected);
138
190
  emit({
191
+ channel: 'internal',
139
192
  type: 'custom',
140
- name: 'flow.extraction.update',
141
- data: { nodeId: node.id, collected, missing },
193
+ payload: {
194
+ name: 'flow.extraction.update',
195
+ data: { nodeId: node.id, collected, missing },
196
+ },
142
197
  });
143
198
  }
144
199
  function fieldPopulated(value) {
@@ -150,7 +205,7 @@ function fieldPopulated(value) {
150
205
  }
151
206
  return true;
152
207
  }
153
- function inferRequiredFields(schema) {
208
+ export function inferRequiredFields(schema) {
154
209
  const zodSchema = schema;
155
210
  if (typeof zodSchema?.shape === 'object') {
156
211
  return Object.keys(zodSchema.shape);
@@ -1,7 +1,7 @@
1
1
  import type { LanguageModel } from 'ai';
2
2
  import type { Flow, FlowNode } from '../types/flow.js';
3
3
  import type { RunState } from '../runtime/durable/types.js';
4
- import type { HarnessStreamPart } from '../types/stream.js';
4
+ import type { StreamPart } from '../types/stream.js';
5
5
  export interface ReduceTransitionInput {
6
6
  fromNodeId: string;
7
7
  toNode: FlowNode;
@@ -9,7 +9,7 @@ export interface ReduceTransitionInput {
9
9
  flow: Flow;
10
10
  model: LanguageModel;
11
11
  data?: Record<string, unknown>;
12
- emit: (part: HarnessStreamPart) => void;
12
+ emit: (part: StreamPart) => void;
13
13
  abortSignal?: AbortSignal;
14
14
  }
15
15
  export declare function reduceTransition(input: ReduceTransitionInput): Promise<void>;
@@ -8,9 +8,13 @@ function resolveNodeContext(toNode, flow) {
8
8
  }
9
9
  export async function reduceTransition(input) {
10
10
  const { fromNodeId, toNode, run, flow, model, data, emit, abortSignal } = input;
11
- emit({ type: 'node-exit', nodeName: fromNodeId });
12
- emit({ type: 'flow-transition', from: fromNodeId, to: toNode.id });
13
- emit({ type: 'node-enter', nodeName: toNode.id });
11
+ emit({ channel: 'internal', type: 'node-exit', payload: { nodeName: fromNodeId } });
12
+ emit({
13
+ channel: 'internal',
14
+ type: 'flow-transition',
15
+ payload: { from: fromNodeId, to: toNode.id },
16
+ });
17
+ emit({ channel: 'internal', type: 'node-enter', payload: { nodeName: toNode.id } });
14
18
  emitInteractiveOnNodeEnter(toNode, run.state, emit);
15
19
  await applyContextStrategy({
16
20
  strategy: resolveNodeContext(toNode, flow),
@@ -16,4 +16,15 @@ export type FlowResult = {
16
16
  export declare class FlowOscillationError extends Error {
17
17
  constructor(from: string, to: string);
18
18
  }
19
+ /**
20
+ * Drop everything a previous run of `flow` collected. Namespaced cache keys AND the
21
+ * un-namespaced copies `reduceTransition` promotes onto `run.state` via Object.assign.
22
+ *
23
+ * Clearing only the cache was not enough. The promoted fields are a plain merge, and
24
+ * `projectCollectData` omits any field the new extraction did not supply — so an optional
25
+ * field answered on report #1 (say `accessNotes: "key under the mat"`) and left blank on
26
+ * report #2 survives the merge and is read by the next action node as if it belonged to
27
+ * report #2. A work order then ships another unit's access instructions.
28
+ */
29
+ export declare function clearFlowCollectCache(state: Record<string, unknown>, flow: Flow): void;
19
30
  export declare function runFlow(flow: Flow, run: RunState, driver: ChannelDriver, ctx: RunContext, agent?: AgentConfig): Promise<FlowResult>;