@kuralle-agents/core 0.13.0 → 0.14.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 (158) 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 +4 -4
  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 +2 -2
  15. package/dist/flow/extraction.js +10 -4
  16. package/dist/flow/reduceTransition.d.ts +2 -2
  17. package/dist/flow/reduceTransition.js +7 -3
  18. package/dist/flow/runFlow.js +34 -12
  19. package/dist/foundation/AgentStateController.d.ts +1 -1
  20. package/dist/foundation/ToolExecutor.d.ts +2 -2
  21. package/dist/foundation/index.d.ts +0 -11
  22. package/dist/foundation/index.js +0 -5
  23. package/dist/index.d.ts +9 -14
  24. package/dist/index.js +5 -6
  25. package/dist/outcomes/streamPart.d.ts +2 -2
  26. package/dist/outcomes/streamPart.js +2 -3
  27. package/dist/prompts/PromptBuilder.d.ts +3 -3
  28. package/dist/prompts/PromptBuilder.js +6 -6
  29. package/dist/prompts/index.d.ts +1 -1
  30. package/dist/prompts/types.d.ts +7 -2
  31. package/dist/runtime/InMemoryRetrievalCache.d.ts +1 -1
  32. package/dist/runtime/KnowledgeProvider.d.ts +3 -2
  33. package/dist/runtime/KnowledgeProvider.js +54 -30
  34. package/dist/runtime/Runtime.d.ts +3 -1
  35. package/dist/runtime/Runtime.js +84 -30
  36. package/dist/runtime/TraceRecorder.d.ts +22 -4
  37. package/dist/runtime/TraceRecorder.js +65 -29
  38. package/dist/runtime/buildAgentToolSurface.js +1 -1
  39. package/dist/runtime/channels/TextDriver.js +12 -9
  40. package/dist/runtime/channels/executeModelTool.d.ts +6 -0
  41. package/dist/runtime/channels/executeModelTool.js +70 -10
  42. package/dist/runtime/channels/extractionTurn.js +1 -1
  43. package/dist/runtime/channels/index.d.ts +0 -3
  44. package/dist/runtime/channels/index.js +0 -6
  45. package/dist/runtime/channels/streaming/hostControlSpeak.js +5 -1
  46. package/dist/runtime/channels/streaming/speakGated.js +24 -12
  47. package/dist/runtime/citations/index.d.ts +3 -3
  48. package/dist/runtime/closeRun.js +3 -2
  49. package/dist/runtime/controlFlowSignal.d.ts +24 -0
  50. package/dist/runtime/controlFlowSignal.js +29 -0
  51. package/dist/runtime/ctx.d.ts +3 -4
  52. package/dist/runtime/ctx.js +39 -5
  53. package/dist/runtime/durable/RunStore.js +1 -1
  54. package/dist/runtime/grounding/gather.d.ts +1 -1
  55. package/dist/runtime/grounding/inMemoryKnowledge.d.ts +1 -1
  56. package/dist/runtime/grounding/knowledge.d.ts +1 -1
  57. package/dist/runtime/hostLoop.js +26 -6
  58. package/dist/runtime/index.d.ts +1 -2
  59. package/dist/runtime/index.js +1 -1
  60. package/dist/runtime/outcomeMarking.d.ts +2 -2
  61. package/dist/runtime/outcomeMarking.js +2 -1
  62. package/dist/runtime/policies/agentTurn.d.ts +1 -1
  63. package/dist/runtime/runHookSafely.d.ts +3 -0
  64. package/dist/runtime/runHookSafely.js +19 -0
  65. package/dist/scheduler/index.d.ts +2 -2
  66. package/dist/scheduler/index.js +1 -1
  67. package/dist/services/TracingService.js +2 -3
  68. package/dist/skills/collectSkills.d.ts +11 -3
  69. package/dist/skills/collectSkills.js +51 -8
  70. package/dist/skills/compositeSkillStore.d.ts +26 -0
  71. package/dist/skills/compositeSkillStore.js +54 -0
  72. package/dist/skills/defineSkill.d.ts +21 -0
  73. package/dist/skills/defineSkill.js +26 -0
  74. package/dist/skills/fsSkillStore.d.ts +3 -0
  75. package/dist/skills/fsSkillStore.js +79 -0
  76. package/dist/skills/index.d.ts +4 -0
  77. package/dist/skills/index.js +4 -0
  78. package/dist/skills/parseSkillFrontmatter.d.ts +14 -0
  79. package/dist/skills/parseSkillFrontmatter.js +183 -0
  80. package/dist/skills/wireAgentSkills.d.ts +2 -1
  81. package/dist/skills/wireAgentSkills.js +2 -2
  82. package/dist/testing/mocks.d.ts +3 -3
  83. package/dist/testing/mocks.js +7 -3
  84. package/dist/tools/controlResults.d.ts +14 -0
  85. package/dist/tools/controlResults.js +15 -0
  86. package/dist/tools/effect/ToolExecutor.d.ts +6 -0
  87. package/dist/tools/effect/ToolExecutor.js +71 -35
  88. package/dist/tools/effect/defineTool.d.ts +9 -0
  89. package/dist/tools/effect/defineTool.js +1 -0
  90. package/dist/tools/effect/errors.d.ts +2 -5
  91. package/dist/tools/effect/errors.js +2 -5
  92. package/dist/tools/index.d.ts +0 -1
  93. package/dist/tools/index.js +0 -1
  94. package/dist/types/effectTool.d.ts +2 -0
  95. package/dist/types/flow.d.ts +1 -1
  96. package/dist/types/guardrails.d.ts +5 -0
  97. package/dist/types/hooks.d.ts +2 -2
  98. package/dist/types/index.d.ts +25 -16
  99. package/dist/types/index.js +8 -13
  100. package/dist/types/knowledge.d.ts +213 -0
  101. package/dist/types/processors.d.ts +0 -58
  102. package/dist/types/run-context.d.ts +3 -7
  103. package/dist/types/runtime.d.ts +3 -74
  104. package/dist/types/session.d.ts +1 -1
  105. package/dist/types/skills.d.ts +15 -1
  106. package/dist/types/stream.d.ts +133 -71
  107. package/dist/types/stream.js +34 -1
  108. package/dist/types/trace.d.ts +2 -0
  109. package/guides/AGENTS.md +3 -3
  110. package/guides/FLOWS.md +1 -1
  111. package/guides/GETTING_STARTED.md +1 -1
  112. package/guides/RUNTIME.md +20 -15
  113. package/guides/TOOLS.md +1 -1
  114. package/package.json +3 -13
  115. package/dist/foundation/AgentDefinition.d.ts +0 -17
  116. package/dist/foundation/ConversationEventLog.d.ts +0 -71
  117. package/dist/foundation/ConversationEventLog.js +0 -1
  118. package/dist/foundation/ConversationState.d.ts +0 -33
  119. package/dist/foundation/ConversationState.js +0 -1
  120. package/dist/foundation/DefaultConversationEventLog.d.ts +0 -27
  121. package/dist/foundation/DefaultConversationEventLog.js +0 -205
  122. package/dist/foundation/DefaultConversationState.d.ts +0 -36
  123. package/dist/foundation/DefaultConversationState.js +0 -103
  124. package/dist/foundation/DefaultToolExecutor.d.ts +0 -57
  125. package/dist/foundation/DefaultToolExecutor.js +0 -132
  126. package/dist/foundation/createFoundation.d.ts +0 -32
  127. package/dist/foundation/createFoundation.js +0 -33
  128. package/dist/hooks/HookRunner.d.ts +0 -37
  129. package/dist/hooks/HookRunner.js +0 -111
  130. package/dist/hooks/builtin/logging.d.ts +0 -5
  131. package/dist/hooks/builtin/logging.js +0 -98
  132. package/dist/hooks/builtin/metrics.d.ts +0 -7
  133. package/dist/hooks/builtin/metrics.js +0 -60
  134. package/dist/hooks/builtin/observability.d.ts +0 -20
  135. package/dist/hooks/builtin/observability.js +0 -572
  136. package/dist/hooks/helpers.d.ts +0 -22
  137. package/dist/hooks/helpers.js +0 -183
  138. package/dist/hooks/index.d.ts +0 -8
  139. package/dist/hooks/index.js +0 -6
  140. package/dist/realtime/RealtimeAudioClient.d.ts +0 -166
  141. package/dist/realtime/RealtimeAudioClient.js +0 -14
  142. package/dist/realtime/RealtimeSessionHandle.d.ts +0 -6
  143. package/dist/realtime/RealtimeSessionHandle.js +0 -1
  144. package/dist/realtime/index.d.ts +0 -2
  145. package/dist/realtime/index.js +0 -1
  146. package/dist/runtime/channels/VoiceDriver.d.ts +0 -31
  147. package/dist/runtime/channels/VoiceDriver.js +0 -321
  148. package/dist/runtime/channels/voiceTools.d.ts +0 -9
  149. package/dist/runtime/channels/voiceTools.js +0 -51
  150. package/dist/tools/errorHandling.d.ts +0 -35
  151. package/dist/tools/errorHandling.js +0 -145
  152. package/dist/types/voice.d.ts +0 -517
  153. package/dist/types/voice.js +0 -14
  154. package/dist/utils/isRecord.d.ts +0 -1
  155. package/dist/utils/isRecord.js +0 -3
  156. package/dist/utils/messageNormalization.d.ts +0 -2
  157. package/dist/utils/messageNormalization.js +0 -137
  158. /package/dist/{foundation/AgentDefinition.js → types/knowledge.js} +0 -0
@@ -1,9 +1,10 @@
1
1
  import type { TurnResult } from '../types/channel.js';
2
- import type { HarnessStreamPart, TurnHandle } from '../types/stream.js';
2
+ import type { StreamPart, TurnHandle } from '../types/stream.js';
3
3
  import type { AgentSpan, AgentTrace } from '../types/trace.js';
4
4
  import type { RunOptions } from './Runtime.js';
5
5
  export interface TraceRecorderOptions {
6
6
  sessionId?: string;
7
+ agentId?: string;
7
8
  input?: unknown;
8
9
  onSpan?: (span: AgentSpan) => void;
9
10
  }
@@ -16,8 +17,10 @@ export declare class TraceRecorder {
16
17
  private readonly toolCallIds;
17
18
  private readonly emitted;
18
19
  private readonly onSpan?;
20
+ private currentAgentId?;
19
21
  constructor(options?: TraceRecorderOptions);
20
- record(part: HarnessStreamPart): void;
22
+ setInitiatingAgent(agentId: string): void;
23
+ record(part: StreamPart): void;
21
24
  finish(result: TurnResult): AgentTrace;
22
25
  private openSpan;
23
26
  private takeToolSpan;
@@ -28,6 +31,21 @@ export declare class TraceRecorder {
28
31
  private close;
29
32
  private emitSpan;
30
33
  }
31
- export declare function runOnce(runtime: {
34
+ /**
35
+ * What the standalone {@link runOnce} needs from a runtime. `run` is required;
36
+ * the optional accessors let it resolve the same agent `Runtime.runOnce` would.
37
+ *
38
+ * It must NOT call `runtime.runOnce` — that method delegates here, so delegating
39
+ * back is infinite recursion.
40
+ */
41
+ export interface RunOnceRuntime {
32
42
  run(opts: RunOptions): TurnHandle;
33
- }, opts: RunOptions): Promise<AgentTrace>;
43
+ getSessionStore?(): {
44
+ get(id: string): Promise<{
45
+ activeAgentId?: string;
46
+ currentAgent?: string;
47
+ } | null>;
48
+ };
49
+ getDefaultAgentId?(): string;
50
+ }
51
+ export declare function runOnce(runtime: RunOnceRuntime, opts: RunOptions): Promise<AgentTrace>;
@@ -7,11 +7,13 @@ export class TraceRecorder {
7
7
  toolCallIds = new Map();
8
8
  emitted = new Set();
9
9
  onSpan;
10
+ currentAgentId;
10
11
  constructor(options = {}) {
11
12
  const startedAt = Date.now();
12
13
  const sessionId = options.sessionId ?? '';
13
14
  const traceId = crypto.randomUUID().replaceAll('-', '');
14
15
  this.onSpan = options.onSpan;
16
+ this.currentAgentId = options.agentId;
15
17
  this.root = {
16
18
  traceId,
17
19
  spanId: newSpanId(),
@@ -21,6 +23,7 @@ export class TraceRecorder {
21
23
  status: 'ok',
22
24
  attributes: {
23
25
  sessionId,
26
+ ...(options.agentId ? { agentId: options.agentId } : {}),
24
27
  ...(options.input !== undefined ? { input: toJsonValue(options.input) } : {}),
25
28
  },
26
29
  };
@@ -35,22 +38,26 @@ export class TraceRecorder {
35
38
  startedAt,
36
39
  };
37
40
  }
41
+ setInitiatingAgent(agentId) {
42
+ this.currentAgentId = agentId;
43
+ this.root.attributes.agentId = agentId;
44
+ }
38
45
  record(part) {
39
46
  try {
40
47
  const at = Date.now();
41
48
  switch (part.type) {
42
49
  case 'text-delta':
43
- this.trace.answer += part.delta;
50
+ this.trace.answer += part.payload.delta;
44
51
  break;
45
52
  case 'flow-enter':
46
53
  this.closeNode(at);
47
54
  this.closeFlow(at);
48
55
  this.currentFlow = this.openSpan({
49
- name: `flow:${part.flow}`,
56
+ name: `flow:${part.payload.flow}`,
50
57
  kind: 'flow',
51
58
  parentSpanId: this.root.spanId,
52
59
  at,
53
- attributes: { activeFlow: part.flow },
60
+ attributes: { activeFlow: part.payload.flow },
54
61
  });
55
62
  break;
56
63
  case 'flow-end':
@@ -60,13 +67,13 @@ export class TraceRecorder {
60
67
  case 'node-enter':
61
68
  this.closeNode(at);
62
69
  this.currentNode = this.openSpan({
63
- name: `node:${part.nodeName}`,
70
+ name: `node:${part.payload.nodeName}`,
64
71
  kind: 'node',
65
72
  parentSpanId: this.currentFlow?.spanId ?? this.root.spanId,
66
73
  at,
67
74
  attributes: {
68
75
  ...(this.currentFlow ? { activeFlow: activeFlowName(this.currentFlow) } : {}),
69
- nodeId: part.nodeName,
76
+ nodeId: part.payload.nodeName,
70
77
  },
71
78
  });
72
79
  break;
@@ -75,37 +82,40 @@ export class TraceRecorder {
75
82
  break;
76
83
  case 'tool-call': {
77
84
  const span = this.openSpan({
78
- name: `tool:${part.toolName}`,
85
+ name: `tool:${part.payload.toolName}`,
79
86
  kind: 'tool',
80
87
  parentSpanId: this.currentNode?.spanId ?? this.root.spanId,
81
88
  at,
82
89
  attributes: {
83
90
  ...(this.currentFlow ? { activeFlow: activeFlowName(this.currentFlow) } : {}),
84
91
  ...(this.currentNode ? { nodeId: nodeName(this.currentNode) } : {}),
85
- toolName: part.toolName,
86
- input: toJsonValue(part.args),
92
+ toolName: part.payload.toolName,
93
+ input: toJsonValue(part.payload.args),
87
94
  },
88
95
  });
89
- this.toolCallIds.set(span, part.toolCallId);
96
+ this.toolCallIds.set(span, part.payload.toolCallId);
90
97
  this.openTools.push(span);
91
98
  this.trace.usedTool = true;
92
- this.trace.toolCalls.push({ name: part.toolName, args: toJsonValue(part.args) });
99
+ this.trace.toolCalls.push({
100
+ name: part.payload.toolName,
101
+ args: toJsonValue(part.payload.args),
102
+ });
93
103
  break;
94
104
  }
95
105
  case 'tool-result': {
96
106
  this.trace.usedTool = true;
97
- const result = toJsonValue(part.result);
98
- this.trace.toolResults.push({ name: part.toolName, result });
99
- const span = this.takeToolSpan(part.toolName, part.toolCallId) ??
107
+ const result = toJsonValue(part.payload.result);
108
+ this.trace.toolResults.push({ name: part.payload.toolName, result });
109
+ const span = this.takeToolSpan(part.payload.toolName, part.payload.toolCallId) ??
100
110
  this.openSpan({
101
- name: `tool:${part.toolName}`,
111
+ name: `tool:${part.payload.toolName}`,
102
112
  kind: 'tool',
103
113
  parentSpanId: this.currentNode?.spanId ?? this.root.spanId,
104
114
  at,
105
115
  attributes: {
106
116
  ...(this.currentFlow ? { activeFlow: activeFlowName(this.currentFlow) } : {}),
107
117
  ...(this.currentNode ? { nodeId: nodeName(this.currentNode) } : {}),
108
- toolName: part.toolName,
118
+ toolName: part.payload.toolName,
109
119
  },
110
120
  });
111
121
  span.attributes.output = result;
@@ -114,35 +124,43 @@ export class TraceRecorder {
114
124
  break;
115
125
  }
116
126
  case 'handoff': {
127
+ const handoffFrom = this.currentAgentId;
117
128
  const span = this.openSpan({
118
- name: `handoff:${part.targetAgent}`,
129
+ name: `handoff:${part.payload.targetAgent}`,
119
130
  kind: 'handoff',
120
131
  parentSpanId: this.root.spanId,
121
132
  at,
122
- attributes: { handoffTo: part.targetAgent },
133
+ attributes: {
134
+ ...(handoffFrom ? { handoffFrom } : {}),
135
+ handoffTo: part.payload.targetAgent,
136
+ },
123
137
  });
124
138
  span.endTime = at;
125
139
  this.emitSpan(span);
140
+ this.currentAgentId = part.payload.targetAgent;
126
141
  break;
127
142
  }
128
143
  case 'error': {
129
144
  const span = this.openTools.at(-1) ?? this.currentNode ?? this.currentFlow ?? this.root;
130
145
  span.status = 'error';
131
- span.attributes.error = part.error;
146
+ span.attributes.error = part.payload.error;
132
147
  this.root.status = 'error';
133
- this.root.attributes.error = part.error;
148
+ this.root.attributes.error = part.payload.error;
134
149
  break;
135
150
  }
136
151
  case 'done':
137
- this.setSessionId(part.sessionId);
152
+ this.setSessionId(part.payload.sessionId);
138
153
  this.root.attributes.output = this.trace.answer;
139
- if (part.usage) {
140
- if (typeof part.usage.inputTokens === 'number')
141
- this.root.attributes.tokensIn = part.usage.inputTokens;
142
- if (typeof part.usage.outputTokens === 'number')
143
- this.root.attributes.tokensOut = part.usage.outputTokens;
144
- if (typeof part.usage.contextTokens === 'number')
145
- this.root.attributes.contextTokens = part.usage.contextTokens;
154
+ if (part.payload.usage) {
155
+ if (typeof part.payload.usage.inputTokens === 'number') {
156
+ this.root.attributes.tokensIn = part.payload.usage.inputTokens;
157
+ }
158
+ if (typeof part.payload.usage.outputTokens === 'number') {
159
+ this.root.attributes.tokensOut = part.payload.usage.outputTokens;
160
+ }
161
+ if (typeof part.payload.usage.contextTokens === 'number') {
162
+ this.root.attributes.contextTokens = part.payload.usage.contextTokens;
163
+ }
146
164
  }
147
165
  this.close(at);
148
166
  break;
@@ -174,7 +192,11 @@ export class TraceRecorder {
174
192
  kind: args.kind,
175
193
  startTime: args.at,
176
194
  status: 'ok',
177
- attributes: { sessionId: this.trace.sessionId, ...args.attributes },
195
+ attributes: {
196
+ sessionId: this.trace.sessionId,
197
+ ...(this.currentAgentId ? { agentId: this.currentAgentId } : {}),
198
+ ...args.attributes,
199
+ },
178
200
  };
179
201
  this.trace.spans.push(span);
180
202
  return span;
@@ -239,8 +261,22 @@ export class TraceRecorder {
239
261
  }
240
262
  }
241
263
  export async function runOnce(runtime, opts) {
264
+ // Resolve attribution the same way Runtime.runOnce does: caller's agentId
265
+ // first, then persisted state, then the runtime default. Without this the
266
+ // standalone helper produced spans with no `agentId` and handoff spans with
267
+ // no `handoffFrom` — the exact feature it is documented to provide.
268
+ let agentId = opts.agentId;
269
+ if (!agentId && opts.sessionId && runtime.getSessionStore) {
270
+ const existing = await runtime.getSessionStore().get(opts.sessionId);
271
+ agentId = existing?.activeAgentId ?? existing?.currentAgent;
272
+ }
273
+ agentId ??= runtime.getDefaultAgentId?.();
242
274
  const handle = runtime.run(opts);
243
- const recorder = new TraceRecorder({ sessionId: opts.sessionId, input: opts.input });
275
+ const recorder = new TraceRecorder({
276
+ sessionId: opts.sessionId,
277
+ agentId,
278
+ input: opts.input,
279
+ });
244
280
  for await (const part of handle.events) {
245
281
  recorder.record(part);
246
282
  }
@@ -28,7 +28,7 @@ export async function buildAgentToolSurface(agent, session, deps) {
28
28
  let skillPrompt;
29
29
  let skillTools = {};
30
30
  if (agent.skills) {
31
- const wired = await wireAgentSkills(agent);
31
+ const wired = await wireAgentSkills(agent, resolvedWorkspace?.fs);
32
32
  if (wired) {
33
33
  skillTools = wired.tools;
34
34
  Object.assign(executorTools, wired.tools);
@@ -29,16 +29,19 @@ export class TextDriver {
29
29
  if (!preTurn.proceed) {
30
30
  const blocked = preTurn.blockedMessage ?? 'Input blocked by guardrails';
31
31
  ctx.emit({
32
+ channel: 'internal',
32
33
  type: 'safety-blocked',
33
- moderator: preTurn.blockedBy ?? 'input-guardrails',
34
- rationale: preTurn.blockedReason ?? 'input blocked',
35
- userFacingMessage: blocked,
34
+ payload: {
35
+ moderator: preTurn.blockedBy ?? 'input-guardrails',
36
+ rationale: preTurn.blockedReason ?? 'input blocked',
37
+ userFacingMessage: blocked,
38
+ },
36
39
  });
37
40
  const id = crypto.randomUUID();
38
- ctx.emit({ type: 'text-start', id });
39
- ctx.emit({ type: 'text-delta', id, delta: blocked });
40
- ctx.emit({ type: 'text-end', id });
41
- ctx.emit({ type: 'turn-end' });
41
+ ctx.emit({ channel: 'client', type: 'text-start', payload: { id } });
42
+ ctx.emit({ channel: 'client', type: 'text-delta', payload: { id, delta: blocked } });
43
+ ctx.emit({ channel: 'client', type: 'text-end', payload: { id } });
44
+ ctx.emit({ channel: 'internal', type: 'turn-end', payload: {} });
42
45
  return { text: blocked, toolResults: [] };
43
46
  }
44
47
  const scope = resolveNodeGatherScope(replyNode, ctx.runState.state, ctx.runState.messages);
@@ -75,7 +78,7 @@ export class TextDriver {
75
78
  if (part.type === 'error') {
76
79
  const err = part.error;
77
80
  const message = err instanceof Error ? err.message : String(err);
78
- ctx.emit({ type: 'error', error: message });
81
+ ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
79
82
  throw err instanceof Error ? err : new Error(message);
80
83
  }
81
84
  }
@@ -160,7 +163,7 @@ export class TextDriver {
160
163
  if (turnUsage && turnUsage.totalTokens > 0) {
161
164
  out.usage = turnUsage;
162
165
  }
163
- ctx.emit({ type: 'turn-end' });
166
+ ctx.emit({ channel: 'internal', type: 'turn-end', payload: {} });
164
167
  return out;
165
168
  }
166
169
  // Non-speaking field extraction for collect nodes (shared helper so text and
@@ -11,6 +11,12 @@ export interface ModelToolCallOutcome {
11
11
  result: unknown;
12
12
  control?: TurnControl;
13
13
  failed: boolean;
14
+ /**
15
+ * Set when the call unwound with a control-flow signal rather than a failure. Held as a
16
+ * value so a parallel batch can still finalize its siblings; the dispatcher rethrows it
17
+ * once the batch has settled. Never surfaced to the model.
18
+ */
19
+ signal?: unknown;
14
20
  }
15
21
  export declare function executeModelToolCall(ctx: RunContext, call: ModelToolCall, localTools?: Record<string, AnyTool>, durableOpts?: {
16
22
  callsite?: string;
@@ -1,7 +1,8 @@
1
1
  import { classifyControl } from '../../flow/classifyControl.js';
2
- import { toolErrorResult } from '../../tools/controlResults.js';
2
+ import { toolDeniedResult, toolErrorResult } from '../../tools/controlResults.js';
3
3
  import { idempotencyKey, logicalRunId } from '../durable/idempotency.js';
4
4
  import { findStepByKey } from '../durable/replay.js';
5
+ import { isApprovalDenial, isControlFlowSignal } from '../controlFlowSignal.js';
5
6
  export async function executeModelToolCall(ctx, call, localTools, durableOpts) {
6
7
  try {
7
8
  const localTool = localTools?.[call.toolName];
@@ -24,30 +25,79 @@ export async function executeModelToolCall(ctx, call, localTools, durableOpts) {
24
25
  return { result: toolResult, control: classifyControl(toolResult), failed: false };
25
26
  }
26
27
  catch (error) {
28
+ if (isControlFlowSignal(error)) {
29
+ // A suspend. Not a failure, so no client-facing error and no tool result: returning it
30
+ // as a value keeps this function's never-rejects contract (which `Promise.all` below
31
+ // depends on) while letting the dispatcher rethrow it once the batch has settled.
32
+ return { result: undefined, failed: true, signal: error };
33
+ }
34
+ if (isApprovalDenial(error)) {
35
+ // A human declined. The model asked for this call, so the model is told — otherwise
36
+ // the turn dies and the user never hears why. No client error part: nothing broke.
37
+ return { result: toolDeniedResult(error.toolName, error.by), failed: true };
38
+ }
27
39
  const message = error instanceof Error ? error.message : String(error);
28
- ctx.emit({ type: 'error', error: message });
40
+ ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
29
41
  return { result: toolErrorResult(error), failed: true };
30
42
  }
31
43
  }
32
44
  function isParallelSafeTool(def) {
33
45
  return def?.parallelSafe === true || def?.replay === false;
34
46
  }
47
+ /**
48
+ * Runs `task` over every item, at most `limit` at a time, preserving result order.
49
+ *
50
+ * Unbounded `Promise.all` over a model-emitted batch lets the model decide how many
51
+ * sockets, subprocesses, or rate-limited API calls open at once. `limit` is a ceiling on
52
+ * that; omitting it keeps the previous unbounded behaviour.
53
+ *
54
+ * `task` is expected not to reject (see the caller's contract). If one does, the rejection
55
+ * propagates and in-flight siblings still run to completion — they are not cancelled, so
56
+ * their journal writes complete rather than being abandoned half-done.
57
+ */
58
+ async function runWithConcurrency(items, task, limit) {
59
+ if (limit === undefined || limit <= 0 || limit >= items.length) {
60
+ return Promise.all(items.map((item) => task(item)));
61
+ }
62
+ const results = new Array(items.length);
63
+ let next = 0;
64
+ const worker = async () => {
65
+ while (next < items.length) {
66
+ const index = next++;
67
+ results[index] = await task(items[index]);
68
+ }
69
+ };
70
+ await Promise.all(Array.from({ length: limit }, () => worker()));
71
+ return results;
72
+ }
35
73
  export async function dispatchModelToolCalls(ctx, toolCalls, localTools, onEach) {
74
+ /** Resolves to a control-flow signal when the call suspended, otherwise `undefined`. */
36
75
  const runOne = async (call, durableOpts) => {
37
76
  ctx.emit({
77
+ channel: 'internal',
38
78
  type: 'tool-call',
39
- toolName: call.toolName,
40
- args: call.input,
41
- toolCallId: call.toolCallId,
79
+ payload: {
80
+ toolName: call.toolName,
81
+ args: call.input,
82
+ toolCallId: call.toolCallId,
83
+ },
42
84
  });
43
85
  const outcome = await executeModelToolCall(ctx, call, localTools, durableOpts);
86
+ if (outcome.signal !== undefined) {
87
+ // Suspended: no result exists, so nothing may reach the model or the transcript.
88
+ return outcome.signal;
89
+ }
44
90
  onEach({ call, outcome });
45
91
  ctx.emit({
92
+ channel: 'internal',
46
93
  type: 'tool-result',
47
- toolName: call.toolName,
48
- result: outcome.result,
49
- toolCallId: call.toolCallId,
94
+ payload: {
95
+ toolName: call.toolName,
96
+ result: outcome.result,
97
+ toolCallId: call.toolCallId,
98
+ },
50
99
  });
100
+ return undefined;
51
101
  };
52
102
  const runParallel = async (parallel) => {
53
103
  const callsites = ctx.reserveCallsites(parallel.length);
@@ -76,12 +126,22 @@ export async function dispatchModelToolCalls(ctx, toolCalls, localTools, onEach)
76
126
  const index = needsIndex ? indices?.[unresolvedCursor++] : hit?.index;
77
127
  return { call, callsite: callsites[i], index };
78
128
  });
79
- await Promise.all(assignments.map(({ call, callsite, index }) => runOne(call, { callsite, index })));
129
+ // executeModelToolCall never rejects tool errors resolve with failed: true and control-flow
130
+ // signals resolve as outcome.signal — so no sibling can be abandoned mid-flight with its
131
+ // finalizeStep unawaited. A suspend is rethrown only after every sibling has settled: you
132
+ // cannot cancel an in-flight promise, so failing fast here would let the work happen anyway
133
+ // while the journal recorded it as still running.
134
+ const signals = await runWithConcurrency(assignments, ({ call, callsite, index }) => runOne(call, { callsite, index }), ctx.limits?.maxToolConcurrency);
135
+ const suspended = signals.find((signal) => signal !== undefined);
136
+ if (suspended !== undefined)
137
+ throw suspended;
80
138
  };
81
139
  for (let cursor = 0; cursor < toolCalls.length;) {
82
140
  const call = toolCalls[cursor];
83
141
  if (!isParallelSafeTool(localTools[call.toolName])) {
84
- await runOne(call);
142
+ const signal = await runOne(call);
143
+ if (signal !== undefined)
144
+ throw signal;
85
145
  cursor += 1;
86
146
  continue;
87
147
  }
@@ -36,7 +36,7 @@ export async function runSilentExtraction(node, ctx, model, maxSteps) {
36
36
  if (part.type === 'error') {
37
37
  const err = part.error;
38
38
  const message = err instanceof Error ? err.message : String(err);
39
- ctx.emit({ type: 'error', error: message });
39
+ ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
40
40
  throw err instanceof Error ? err : new Error(message);
41
41
  }
42
42
  }
@@ -1,7 +1,4 @@
1
1
  export type { ChannelDriver } from '../../types/channel.js';
2
2
  export { TextDriver, buildNodePrompt } from './TextDriver.js';
3
3
  export type { TextDriverConfig } from './TextDriver.js';
4
- export { VoiceDriver } from './VoiceDriver.js';
5
- export type { VoiceDriverConfig } from './VoiceDriver.js';
6
- export { resolveVoiceGeminiTools, v2ToolsToGemini } from './voiceTools.js';
7
4
  export { setPendingUserInput, consumePendingUserInput, consumeAllPendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './inputBuffer.js';
@@ -1,8 +1,2 @@
1
1
  export { TextDriver, buildNodePrompt } from './TextDriver.js';
2
- // PAUSED: the realtime VoiceDriver is not on the primary (text) path and is not
3
- // re-exported from the package's headline API. It stays here for the realtime
4
- // stack (`@kuralle-agents/realtime-audio`) via the `/runtime` subpath. Text is
5
- // the primary primitive; cascaded voice runs over text (see livekit-plugin).
6
- export { VoiceDriver } from './VoiceDriver.js';
7
- export { resolveVoiceGeminiTools, v2ToolsToGemini } from './voiceTools.js';
8
2
  export { setPendingUserInput, consumePendingUserInput, consumeAllPendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './inputBuffer.js';
@@ -72,7 +72,11 @@ export async function speakWithHostControl(args) {
72
72
  toolControl = getToolControl();
73
73
  if (toolControl) {
74
74
  if (spoken.text) {
75
- ctx.emit({ type: 'text-cancel', id: turnId, reason: 'host-control' });
75
+ ctx.emit({
76
+ channel: 'client',
77
+ type: 'text-cancel',
78
+ payload: { id: turnId, reason: 'host-control' },
79
+ });
76
80
  }
77
81
  return { text: '', control: toolControl };
78
82
  }
@@ -1,16 +1,16 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { SentenceAggregator } from './SentenceAggregator.js';
3
3
  function emitMessage(ctx, id, text) {
4
- ctx.emit({ type: 'text-start', id });
5
- ctx.emit({ type: 'text-delta', id, delta: text });
6
- ctx.emit({ type: 'text-end', id });
4
+ ctx.emit({ channel: 'client', type: 'text-start', payload: { id } });
5
+ ctx.emit({ channel: 'client', type: 'text-delta', payload: { id, delta: text } });
6
+ ctx.emit({ channel: 'client', type: 'text-end', payload: { id } });
7
7
  }
8
8
  async function emitBlockedSafeMessage(ctx, turnId, started, outcome) {
9
9
  if (started) {
10
10
  ctx.emit({
11
+ channel: 'client',
11
12
  type: 'text-cancel',
12
- id: turnId,
13
- reason: outcome.reason ?? 'blocked',
13
+ payload: { id: turnId, reason: outcome.reason ?? 'blocked' },
14
14
  });
15
15
  }
16
16
  const safeId = randomUUID();
@@ -32,7 +32,7 @@ export async function speakGated(args) {
32
32
  }
33
33
  catch (err) {
34
34
  const message = err instanceof Error ? err.message : String(err);
35
- ctx.emit({ type: 'error', error: message });
35
+ ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
36
36
  throw err instanceof Error ? err : new Error(message);
37
37
  }
38
38
  const decision = await runGate(full, true);
@@ -48,13 +48,17 @@ export async function speakGated(args) {
48
48
  let emitted = '';
49
49
  const openOnce = () => {
50
50
  if (!started) {
51
- ctx.emit({ type: 'text-start', id: turnId });
51
+ ctx.emit({ channel: 'client', type: 'text-start', payload: { id: turnId } });
52
52
  started = true;
53
53
  }
54
54
  };
55
55
  const emitCleared = (chunk) => {
56
56
  openOnce();
57
- ctx.emit({ type: 'text-delta', id: turnId, delta: chunk });
57
+ ctx.emit({
58
+ channel: 'client',
59
+ type: 'text-delta',
60
+ payload: { id: turnId, delta: chunk },
61
+ });
58
62
  emitted += chunk;
59
63
  };
60
64
  const gateSentence = async (sentence, final) => {
@@ -69,7 +73,11 @@ export async function speakGated(args) {
69
73
  for await (const { delta } of source) {
70
74
  if (mode === 'token') {
71
75
  openOnce();
72
- ctx.emit({ type: 'text-delta', id: turnId, delta });
76
+ ctx.emit({
77
+ channel: 'client',
78
+ type: 'text-delta',
79
+ payload: { id: turnId, delta },
80
+ });
73
81
  emitted += delta;
74
82
  continue;
75
83
  }
@@ -88,15 +96,19 @@ export async function speakGated(args) {
88
96
  }
89
97
  }
90
98
  if (started) {
91
- ctx.emit({ type: 'text-end', id: turnId });
99
+ ctx.emit({ channel: 'client', type: 'text-end', payload: { id: turnId } });
92
100
  }
93
101
  return { text: emitted };
94
102
  }
95
103
  catch (err) {
96
104
  const message = err instanceof Error ? err.message : String(err);
97
- ctx.emit({ type: 'error', error: message });
105
+ ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
98
106
  if (started) {
99
- ctx.emit({ type: 'text-cancel', id: turnId, reason: message });
107
+ ctx.emit({
108
+ channel: 'client',
109
+ type: 'text-cancel',
110
+ payload: { id: turnId, reason: message },
111
+ });
100
112
  }
101
113
  throw err instanceof Error ? err : new Error(message);
102
114
  }
@@ -1,15 +1,15 @@
1
- import type { AgentKnowledgeOverrides, HarnessStreamPart, KnowledgeRetrievalResult, RetrievalCacheAdapter, SourceRef } from '../../types/index.js';
1
+ import type { AgentKnowledgeOverrides, StreamPart, KnowledgeRetrievalResult, RetrievalCacheAdapter, SourceRef } from '../../types/index.js';
2
2
  export interface CitationRetrievalProvider {
3
3
  retrieve(query: string, cache: RetrievalCacheAdapter | undefined, agentOverrides?: AgentKnowledgeOverrides, isVoice?: boolean): Promise<{
4
4
  results: KnowledgeRetrievalResult[];
5
5
  citations?: SourceRef[];
6
- events: HarnessStreamPart[];
6
+ events: StreamPart[];
7
7
  }>;
8
8
  }
9
9
  export interface CitationRetrievalResult {
10
10
  results: KnowledgeRetrievalResult[];
11
11
  citations: SourceRef[];
12
- events: HarnessStreamPart[];
12
+ events: StreamPart[];
13
13
  }
14
14
  export declare function retrieveWithCitations(provider: CitationRetrievalProvider, query: string, cache: RetrievalCacheAdapter | undefined, agentOverrides?: AgentKnowledgeOverrides, isVoice?: boolean): Promise<CitationRetrievalResult>;
15
15
  export declare function normalizeCitations(results: KnowledgeRetrievalResult[], nativeCitations?: readonly SourceRef[]): SourceRef[];
@@ -1,6 +1,7 @@
1
1
  import { isTerminalOutcome, markSessionOutcome } from './outcomeMarking.js';
2
2
  import { mutateSessionWithRetry } from '../session/utils.js';
3
3
  import { syncPendingUserInput } from './channels/inputBuffer.js';
4
+ import { runHookSafely } from './runHookSafely.js';
4
5
  export async function closeRun(options) {
5
6
  const { session, runState, runStore, sessionStore, hooks, ctx } = options;
6
7
  runState.updatedAt = Date.now();
@@ -16,10 +17,10 @@ export async function closeRun(options) {
16
17
  outcomeRecord = await markSessionOutcome(sessionStore, session, options.terminalOutcome, { reason: options.outcomeReason, markedBy: 'hook' }, ctx.emit);
17
18
  }
18
19
  if (outcomeRecord && isTerminalOutcome(outcomeRecord.outcome)) {
19
- await hooks?.onConversationEnd?.({
20
+ await runHookSafely('onConversationEnd', () => hooks?.onConversationEnd?.({
20
21
  session,
21
22
  outcome: outcomeRecord,
22
- });
23
+ }));
23
24
  }
24
25
  await mutateSessionWithRetry(sessionStore, session.id, (latest) => {
25
26
  latest.currentAgent = runState.activeAgentId;
@@ -0,0 +1,24 @@
1
+ import { ToolApprovalDeniedError } from '../tools/effect/errors.js';
2
+ /**
3
+ * The run is not finished: stop the turn, keep the journal, resume when a signal arrives.
4
+ * It unwinds the stack like an error but is not a failure, so it must never be reported to
5
+ * the model as a tool error, shown to the user as an error, or journaled as one.
6
+ *
7
+ * Only `SuspendError` qualifies. An approval *denial* is deliberately not a signal — see
8
+ * `isApprovalDenial`.
9
+ */
10
+ export declare function isControlFlowSignal(error: unknown): boolean;
11
+ /**
12
+ * A human answered "no". Neither a failure nor a suspend — the opposite of a suspend, in
13
+ * fact: a suspend defers the decision, a denial resolves it.
14
+ *
15
+ * Two rules follow, and they differ by who is driving the call:
16
+ *
17
+ * - It must never be degraded into "something went wrong on my side". Nothing went wrong;
18
+ * saying so would be a lie to the user.
19
+ * - On the **model** path it becomes a tool result, because there is no author code to
20
+ * catch it and the agent needs to be able to tell the user the request was declined.
21
+ * On the **flow** path it keeps propagating, because an `action` node's author chose to
22
+ * call the tool and can catch it or branch on `ctx.approve()` instead.
23
+ */
24
+ export declare function isApprovalDenial(error: unknown): error is ToolApprovalDeniedError;