@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
@@ -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,17 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { isControlFlowSignal } from '../../controlFlowSignal.js';
2
3
  import { SentenceAggregator } from './SentenceAggregator.js';
3
4
  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 });
5
+ ctx.emit({ channel: 'client', type: 'text-start', payload: { id } });
6
+ ctx.emit({ channel: 'client', type: 'text-delta', payload: { id, delta: text } });
7
+ ctx.emit({ channel: 'client', type: 'text-end', payload: { id } });
7
8
  }
8
9
  async function emitBlockedSafeMessage(ctx, turnId, started, outcome) {
9
10
  if (started) {
10
11
  ctx.emit({
12
+ channel: 'client',
11
13
  type: 'text-cancel',
12
- id: turnId,
13
- reason: outcome.reason ?? 'blocked',
14
+ payload: { id: turnId, reason: outcome.reason ?? 'blocked' },
14
15
  });
15
16
  }
16
17
  const safeId = randomUUID();
@@ -31,8 +32,14 @@ export async function speakGated(args) {
31
32
  }
32
33
  }
33
34
  catch (err) {
35
+ // A suspend unwinds through here on its way to the host loop. It is not a failure and
36
+ // must not reach the user as one — the same rule executeModelToolCall and runFlow
37
+ // already apply. This third path was missed, so an approval pause still surfaced as
38
+ // "error: Run suspended waiting for __approval" mid-conversation.
39
+ if (isControlFlowSignal(err))
40
+ throw err;
34
41
  const message = err instanceof Error ? err.message : String(err);
35
- ctx.emit({ type: 'error', error: message });
42
+ ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
36
43
  throw err instanceof Error ? err : new Error(message);
37
44
  }
38
45
  const decision = await runGate(full, true);
@@ -48,13 +55,17 @@ export async function speakGated(args) {
48
55
  let emitted = '';
49
56
  const openOnce = () => {
50
57
  if (!started) {
51
- ctx.emit({ type: 'text-start', id: turnId });
58
+ ctx.emit({ channel: 'client', type: 'text-start', payload: { id: turnId } });
52
59
  started = true;
53
60
  }
54
61
  };
55
62
  const emitCleared = (chunk) => {
56
63
  openOnce();
57
- ctx.emit({ type: 'text-delta', id: turnId, delta: chunk });
64
+ ctx.emit({
65
+ channel: 'client',
66
+ type: 'text-delta',
67
+ payload: { id: turnId, delta: chunk },
68
+ });
58
69
  emitted += chunk;
59
70
  };
60
71
  const gateSentence = async (sentence, final) => {
@@ -69,7 +80,11 @@ export async function speakGated(args) {
69
80
  for await (const { delta } of source) {
70
81
  if (mode === 'token') {
71
82
  openOnce();
72
- ctx.emit({ type: 'text-delta', id: turnId, delta });
83
+ ctx.emit({
84
+ channel: 'client',
85
+ type: 'text-delta',
86
+ payload: { id: turnId, delta },
87
+ });
73
88
  emitted += delta;
74
89
  continue;
75
90
  }
@@ -88,15 +103,21 @@ export async function speakGated(args) {
88
103
  }
89
104
  }
90
105
  if (started) {
91
- ctx.emit({ type: 'text-end', id: turnId });
106
+ ctx.emit({ channel: 'client', type: 'text-end', payload: { id: turnId } });
92
107
  }
93
108
  return { text: emitted };
94
109
  }
95
110
  catch (err) {
111
+ if (isControlFlowSignal(err))
112
+ throw err;
96
113
  const message = err instanceof Error ? err.message : String(err);
97
- ctx.emit({ type: 'error', error: message });
114
+ ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
98
115
  if (started) {
99
- ctx.emit({ type: 'text-cancel', id: turnId, reason: message });
116
+ ctx.emit({
117
+ channel: 'client',
118
+ type: 'text-cancel',
119
+ payload: { id: turnId, reason: message },
120
+ });
100
121
  }
101
122
  throw err instanceof Error ? err : new Error(message);
102
123
  }
@@ -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;
@@ -0,0 +1,29 @@
1
+ import { SuspendError } from './durable/RunStore.js';
2
+ import { ToolApprovalDeniedError } from '../tools/effect/errors.js';
3
+ /**
4
+ * The run is not finished: stop the turn, keep the journal, resume when a signal arrives.
5
+ * It unwinds the stack like an error but is not a failure, so it must never be reported to
6
+ * the model as a tool error, shown to the user as an error, or journaled as one.
7
+ *
8
+ * Only `SuspendError` qualifies. An approval *denial* is deliberately not a signal — see
9
+ * `isApprovalDenial`.
10
+ */
11
+ export function isControlFlowSignal(error) {
12
+ return error instanceof SuspendError;
13
+ }
14
+ /**
15
+ * A human answered "no". Neither a failure nor a suspend — the opposite of a suspend, in
16
+ * fact: a suspend defers the decision, a denial resolves it.
17
+ *
18
+ * Two rules follow, and they differ by who is driving the call:
19
+ *
20
+ * - It must never be degraded into "something went wrong on my side". Nothing went wrong;
21
+ * saying so would be a lie to the user.
22
+ * - On the **model** path it becomes a tool result, because there is no author code to
23
+ * catch it and the agent needs to be able to tell the user the request was declined.
24
+ * On the **flow** path it keeps propagating, because an `action` node's author chose to
25
+ * call the tool and can catch it or branch on `ctx.approve()` instead.
26
+ */
27
+ export function isApprovalDenial(error) {
28
+ return error instanceof ToolApprovalDeniedError;
29
+ }
@@ -1,7 +1,7 @@
1
1
  import type { LanguageModel } from 'ai';
2
2
  import type { Session } from '../types/session.js';
3
- import type { EffectToolExecutor, HookRunner, MemoryService, AutoRetrieveProvider, RunContext } from '../types/run-context.js';
4
- import type { HarnessStreamPart } from '../types/stream.js';
3
+ import type { EffectToolExecutor, MemoryService, AutoRetrieveProvider, RunContext } from '../types/run-context.js';
4
+ import type { StreamPart } from '../types/stream.js';
5
5
  import type { RefinementCapability } from '../capabilities/RefinementCapability.js';
6
6
  import type { ValidationCapability } from '../capabilities/ValidationCapability.js';
7
7
  import type { InputProcessor, OutputProcessor } from '../types/processors.js';
@@ -19,7 +19,6 @@ export interface CtxDeps {
19
19
  runStore: RunStore;
20
20
  steps: StepRecord[];
21
21
  toolExecutor: EffectToolExecutor;
22
- hookRunner?: HookRunner;
23
22
  model: LanguageModel;
24
23
  controlModel?: LanguageModel;
25
24
  outOfBandControl?: boolean;
@@ -34,7 +33,7 @@ export interface CtxDeps {
34
33
  bargeIn?: AbortSignal;
35
34
  abortSignal?: AbortSignal;
36
35
  clock?: EffectClock;
37
- emit?: (part: HarnessStreamPart) => void;
36
+ emit?: (part: StreamPart) => void;
38
37
  }
39
38
  export declare function createRunContext(deps: CtxDeps): Promise<RunContext>;
40
39
  export {};
@@ -14,6 +14,22 @@ function makeCtx(deps) {
14
14
  const emit = deps.emit ?? (() => { });
15
15
  // Mutable holder: handoff reassigns runCtx.toolExecutor; ctx.tool must see the swap.
16
16
  const toolExecutorHolder = { executor: deps.toolExecutor };
17
+ let pendingAppendTail = Promise.resolve();
18
+ const serializePendingAppend = (operation) => {
19
+ const previous = pendingAppendTail;
20
+ let release;
21
+ pendingAppendTail = new Promise((resolve) => {
22
+ release = resolve;
23
+ });
24
+ return previous.then(async () => {
25
+ try {
26
+ return await operation();
27
+ }
28
+ finally {
29
+ release();
30
+ }
31
+ });
32
+ };
17
33
  const consumeCallsite = () => {
18
34
  const site = String(effectOrdinal);
19
35
  effectOrdinal += 1;
@@ -85,9 +101,28 @@ function makeCtx(deps) {
85
101
  }
86
102
  }
87
103
  const isRetry = hit?.status === 'running';
88
- const stepIndex = options?.index ?? hit?.index ?? steps.length;
104
+ const stepIndex = options?.index ?? hit?.index;
89
105
  if (!isRetry) {
90
- await appendPendingStep(key, kind, name, stepIndex);
106
+ const append = async () => {
107
+ let index = stepIndex;
108
+ if (index === undefined) {
109
+ if (deps.runStore.reserveSteps) {
110
+ index = (await deps.runStore.reserveSteps(deps.runState.runId, 1))[0];
111
+ }
112
+ else {
113
+ // ctx.tool owns ordinal assignment: use the store's atomic reservation when available,
114
+ // and serialize legacy stores that do not expose reserveSteps within this context.
115
+ index = steps.length;
116
+ }
117
+ }
118
+ await appendPendingStep(key, kind, name, index);
119
+ };
120
+ if (stepIndex === undefined && !deps.runStore.reserveSteps) {
121
+ await serializePendingAppend(append);
122
+ }
123
+ else {
124
+ await append();
125
+ }
91
126
  }
92
127
  let result;
93
128
  try {
@@ -131,7 +166,7 @@ function makeCtx(deps) {
131
166
  deps.runState.status = 'paused';
132
167
  deps.runState.updatedAt = Date.now();
133
168
  await deps.runStore.putRunState(deps.runState);
134
- emit({ type: 'paused', waitingFor: signalName });
169
+ emit({ channel: 'internal', type: 'paused', payload: { waitingFor: signalName } });
135
170
  throw new SuspendError(signalName);
136
171
  };
137
172
  const pauseEffect = async (signalName, meta) => {
@@ -160,7 +195,6 @@ function makeCtx(deps) {
160
195
  set toolExecutor(executor) {
161
196
  toolExecutorHolder.executor = executor;
162
197
  },
163
- hookRunner: deps.hookRunner ?? {},
164
198
  model: deps.model,
165
199
  controlModel: deps.controlModel ?? deps.model,
166
200
  outOfBandControl: deps.outOfBandControl ?? false,
@@ -223,7 +257,7 @@ function makeCtx(deps) {
223
257
  toolCtx: options?.toolCtx,
224
258
  });
225
259
  if (def?.replay === false) {
226
- const auditKey = `${key}:${steps.length}`;
260
+ const auditKey = `${key}:${steps.length}:${options?.index ?? callsite}`;
227
261
  return replayOrExecute(auditKey, 'tool', name, executeTool, { index: options?.index });
228
262
  }
229
263
  return replayOrExecute(key, 'tool', name, executeTool, { index: options?.index });
@@ -3,7 +3,7 @@ export class LogConflictError extends Error {
3
3
  expectedIndex;
4
4
  actualIndex;
5
5
  constructor(runId, expectedIndex, actualIndex) {
6
- super(`Log conflict for run ${runId}: expected append at index ${expectedIndex}, current length is ${actualIndex}`);
6
+ super(`Log conflict for run ${runId}: expected append at index ${expectedIndex}, current length is ${actualIndex}. For parallel durable effects, use ctx.tool directly or reserve callsites with ctx.reserveCallsites(count) before supplying explicit indices.`);
7
7
  this.name = 'LogConflictError';
8
8
  this.runId = runId;
9
9
  this.expectedIndex = expectedIndex;
@@ -1,4 +1,4 @@
1
- import type { SourceRef } from '../../types/voice.js';
1
+ import type { SourceRef } from '../../types/knowledge.js';
2
2
  import type { GatherScope, RunContext } from '../../types/run-context.js';
3
3
  export type { GatherScope } from '../../types/run-context.js';
4
4
  export interface GatherResult {
@@ -1,4 +1,4 @@
1
- import type { KnowledgeProviderConfig, KnowledgeRetrieverAdapter } from '../../types/voice.js';
1
+ import type { KnowledgeProviderConfig, KnowledgeRetrieverAdapter } from '../../types/knowledge.js';
2
2
  export interface InMemoryKnowledgeDocument {
3
3
  id?: string;
4
4
  text: string;
@@ -1,7 +1,7 @@
1
1
  import type { AgentConfig } from '../../types/agentConfig.js';
2
2
  import type { AutoRetrieveProvider } from '../../types/run-context.js';
3
3
  import type { AnyTool } from '../../types/effectTool.js';
4
- import type { KnowledgeProviderConfig } from '../../types/voice.js';
4
+ import type { KnowledgeProviderConfig } from '../../types/knowledge.js';
5
5
  import { KnowledgeProvider } from '../KnowledgeProvider.js';
6
6
  export declare function buildKnowledgeProvider(config: KnowledgeProviderConfig): KnowledgeProvider;
7
7
  export declare function buildAutoRetrieveProvider(provider: KnowledgeProvider, agent: AgentConfig): AutoRetrieveProvider | undefined;
@@ -36,7 +36,7 @@ export async function hostLoop(options) {
36
36
  return { kind: 'paused' };
37
37
  }
38
38
  if (error instanceof LimitsExceededError) {
39
- ctx.emit({ type: 'error', error: error.message });
39
+ ctx.emit({ channel: 'client', type: 'error', payload: { error: error.message } });
40
40
  return { kind: 'ended', reason: error.message };
41
41
  }
42
42
  throw error;
@@ -80,10 +80,19 @@ async function runActiveFlow(flow, run, driver, ctx, agent) {
80
80
  await ctx.runStore.putRunState(run);
81
81
  return { kind: 'turnComplete' };
82
82
  }
83
- const completed = run.state.__completedFlows;
84
- const completedFlows = Array.isArray(completed) ? completed : [];
85
- if (!completedFlows.includes(flow.name)) {
86
- run.state.__completedFlows = [...completedFlows, flow.name];
83
+ // A flow that ended because a node threw is NOT completed. Marking it so made the
84
+ // failure permanent for the turn: `select` and the host-control tools both exclude a
85
+ // completed flow from re-entry, so a failed intake could not be retried even though the
86
+ // error was recoverable (a mistyped unit id, a transient tool fault). Observed live —
87
+ // a session held __completedFlows: ["raise_work_order"] with an errored journal step
88
+ // and no work order to show for it.
89
+ const degraded = result.kind === 'ended' && result.reason === 'error_degraded';
90
+ if (!degraded) {
91
+ const completed = run.state.__completedFlows;
92
+ const completedFlows = Array.isArray(completed) ? completed : [];
93
+ if (!completedFlows.includes(flow.name)) {
94
+ run.state.__completedFlows = [...completedFlows, flow.name];
95
+ }
87
96
  }
88
97
  run.activeFlow = undefined;
89
98
  run.activeNode = undefined;
@@ -160,7 +169,11 @@ function guardVerdictToTelemetryVerdict(verdict) {
160
169
  return 'keep';
161
170
  }
162
171
  function emitHostGuardTelemetry(ctx, data) {
163
- ctx.emit({ type: 'custom', name: 'host-guard', data });
172
+ ctx.emit({
173
+ channel: 'internal',
174
+ type: 'custom',
175
+ payload: { name: 'host-guard', data },
176
+ });
164
177
  }
165
178
  function guardVerdictToControl(verdict, agent) {
166
179
  const selection = verdictToSelection(verdict, agent);
@@ -174,7 +187,11 @@ function guardVerdictToControl(verdict, agent) {
174
187
  }
175
188
  async function executeHostControl(agent, run, driver, ctx, control) {
176
189
  if (!control) {
177
- ctx.emit({ type: 'error', error: 'No valid host control target resolved' });
190
+ ctx.emit({
191
+ channel: 'client',
192
+ type: 'error',
193
+ payload: { error: 'No valid host control target resolved' },
194
+ });
178
195
  return { kind: 'ended', reason: 'dispatch_failed' };
179
196
  }
180
197
  if (control.type === 'enterFlow') {
@@ -182,18 +199,30 @@ async function executeHostControl(agent, run, driver, ctx, control) {
182
199
  if (flow) {
183
200
  return await runActiveFlow(flow, run, driver, ctx, agent);
184
201
  }
185
- ctx.emit({ type: 'error', error: `Flow not found: ${control.flowName}` });
202
+ ctx.emit({
203
+ channel: 'client',
204
+ type: 'error',
205
+ payload: { error: `Flow not found: ${control.flowName}` },
206
+ });
186
207
  return { kind: 'ended', reason: 'flow_not_found' };
187
208
  }
188
209
  if (control.type === 'handoff') {
189
- ctx.emit({ type: 'handoff', targetAgent: control.target, reason: control.reason });
210
+ ctx.emit({
211
+ channel: 'internal',
212
+ type: 'handoff',
213
+ payload: { targetAgent: control.target, reason: control.reason },
214
+ });
190
215
  return { kind: 'handoff', to: control.target, reason: control.reason };
191
216
  }
192
217
  if (control.type === 'end') {
193
218
  return { kind: 'ended', reason: control.reason };
194
219
  }
195
220
  if (control.type === 'escalate') {
196
- ctx.emit({ type: 'handoff', targetAgent: 'human', reason: control.reason });
221
+ // No handoff part is emitted here. Runtime owns it: it emits one for any
222
+ // target in `terminalHandoffTargets` (default ['human']) on this exact
223
+ // return value. Emitting here too produced two handoff spans per escalation
224
+ // and a meaningless `human -> human` self-edge, which then mis-attributed
225
+ // every later span in the turn to `human`.
197
226
  return { kind: 'handoff', to: 'human', reason: control.reason, category: control.category };
198
227
  }
199
228
  if (control.type === 'recover') {
@@ -3,8 +3,7 @@ export type { RuntimeLike } from './RuntimeLike.js';
3
3
  export { TraceRecorder, runOnce, type TraceRecorderOptions } from './TraceRecorder.js';
4
4
  export type { AgentSpan, AgentTrace, SpanKind } from '../types/trace.js';
5
5
  export { SessionWorkingMemory } from './WorkingMemory.js';
6
- export { TextDriver, VoiceDriver } from './channels/index.js';
7
- export type { VoiceDriverConfig } from './channels/index.js';
6
+ export { TextDriver } from './channels/index.js';
8
7
  export { setPendingUserInput, consumePendingUserInput, consumeAllPendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './channels/index.js';
9
8
  export { userInputToText, mergeUserInputContents, hasMediaParts, transcribeAudioParts, type UserInputContent, } from './userInput.js';
10
9
  export { isAbortSignal, type InterruptionEvent, type AbortOptions, type CancellationReason, } from '../types/index.js';
@@ -1,7 +1,7 @@
1
1
  export { createRuntime, Runtime, } from './Runtime.js';
2
2
  export { TraceRecorder, runOnce } from './TraceRecorder.js';
3
3
  export { SessionWorkingMemory } from './WorkingMemory.js';
4
- export { TextDriver, VoiceDriver } from './channels/index.js';
4
+ export { TextDriver } from './channels/index.js';
5
5
  // Pending-input buffer helpers — required by custom ChannelDriver authors to
6
6
  // implement awaitUser the same FIFO-aware way the built-in drivers do (the
7
7
  // buffer is an ordered queue since 0.3.13/H3, not a single slot).
@@ -1,9 +1,9 @@
1
1
  import type { Session } from '../types/session.js';
2
2
  import type { SessionStore } from '../session/SessionStore.js';
3
3
  import type { ConversationOutcome, ConversationOutcomeMarkedBy, ConversationOutcomeRecord } from '../outcomes/types.js';
4
- import type { HarnessStreamPart } from '../types/stream.js';
4
+ import type { StreamPart } from '../types/stream.js';
5
5
  export declare function isTerminalOutcome(outcome: ConversationOutcome): boolean;
6
6
  export declare function markSessionOutcome(sessionStore: SessionStore, session: Session, outcome: ConversationOutcome, opts?: {
7
7
  reason?: string;
8
8
  markedBy?: ConversationOutcomeMarkedBy;
9
- }, emit?: (part: HarnessStreamPart) => void): Promise<ConversationOutcomeRecord>;
9
+ }, emit?: (part: StreamPart) => void): Promise<ConversationOutcomeRecord>;
@@ -22,8 +22,9 @@ export async function markSessionOutcome(sessionStore, session, outcome, opts =
22
22
  await sessionStore.save(session);
23
23
  if (emit) {
24
24
  emit({
25
+ channel: 'client',
25
26
  type: 'conversation-outcome',
26
- outcome: record.outcome,
27
+ payload: { outcome: record.outcome },
27
28
  });
28
29
  }
29
30
  return record;