@vinhnt-sdk/core 0.1.1 → 0.1.3

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 (179) hide show
  1. package/README.md +194 -12
  2. package/dist/agent/agent-factory.d.ts +11 -0
  3. package/dist/agent/agent-factory.d.ts.map +1 -1
  4. package/dist/agent/agent-factory.js +11 -2
  5. package/dist/agent/agent-factory.js.map +1 -1
  6. package/dist/agent/agent-registry.d.ts +3 -0
  7. package/dist/agent/agent-registry.d.ts.map +1 -1
  8. package/dist/agent/agent-registry.js.map +1 -1
  9. package/dist/agent/create-agent-tool.d.ts +13 -13
  10. package/dist/agent/create-agent-tool.d.ts.map +1 -1
  11. package/dist/agent/delegate-batch-tool.d.ts.map +1 -1
  12. package/dist/agent/delegate-batch-tool.js +1 -1
  13. package/dist/agent/delegate-batch-tool.js.map +1 -1
  14. package/dist/agent/delegate-tool.js +1 -1
  15. package/dist/agent/delegate-tool.js.map +1 -1
  16. package/dist/agent/execution-engine.d.ts +1 -1
  17. package/dist/agent/execution-engine.d.ts.map +1 -1
  18. package/dist/agent/spawn-agent-tool.d.ts +10 -10
  19. package/dist/agent/spawn-agent-tool.d.ts.map +1 -1
  20. package/dist/event-bus/bridge.d.ts +2 -3
  21. package/dist/event-bus/bridge.d.ts.map +1 -1
  22. package/dist/event-bus/bridge.js +12 -5
  23. package/dist/event-bus/bridge.js.map +1 -1
  24. package/dist/event-bus/global-bus.d.ts +8 -0
  25. package/dist/event-bus/global-bus.d.ts.map +1 -1
  26. package/dist/event-bus/global-bus.js +48 -0
  27. package/dist/event-bus/global-bus.js.map +1 -1
  28. package/dist/event-bus/in-memory-bus.d.ts +14 -0
  29. package/dist/event-bus/in-memory-bus.d.ts.map +1 -1
  30. package/dist/event-bus/in-memory-bus.js +53 -0
  31. package/dist/event-bus/in-memory-bus.js.map +1 -1
  32. package/dist/event-bus/types.d.ts +17 -0
  33. package/dist/event-bus/types.d.ts.map +1 -1
  34. package/dist/fakes/fake-approval-store.d.ts +2 -2
  35. package/dist/fakes/fake-approval-store.d.ts.map +1 -1
  36. package/dist/fakes/fake-approval-store.js +28 -6
  37. package/dist/fakes/fake-approval-store.js.map +1 -1
  38. package/dist/fakes/fake-model.d.ts.map +1 -1
  39. package/dist/fakes/fake-model.js +2 -1
  40. package/dist/fakes/fake-model.js.map +1 -1
  41. package/dist/fakes/fake-store.d.ts +4 -1
  42. package/dist/fakes/fake-store.d.ts.map +1 -1
  43. package/dist/fakes/fake-store.js +33 -0
  44. package/dist/fakes/fake-store.js.map +1 -1
  45. package/dist/index.d.ts +232 -109
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +191 -66
  48. package/dist/index.js.map +1 -1
  49. package/dist/kernel/approval-handler.d.ts +1 -1
  50. package/dist/kernel/approval-handler.d.ts.map +1 -1
  51. package/dist/kernel/approval-handler.js +5 -1
  52. package/dist/kernel/approval-handler.js.map +1 -1
  53. package/dist/kernel/circuit-breaker.d.ts +19 -1
  54. package/dist/kernel/circuit-breaker.d.ts.map +1 -1
  55. package/dist/kernel/circuit-breaker.js +54 -10
  56. package/dist/kernel/circuit-breaker.js.map +1 -1
  57. package/dist/kernel/kernel-error.d.ts +3 -2
  58. package/dist/kernel/kernel-error.d.ts.map +1 -1
  59. package/dist/kernel/kernel-error.js +5 -6
  60. package/dist/kernel/kernel-error.js.map +1 -1
  61. package/dist/kernel/kernel-session.d.ts +13 -5
  62. package/dist/kernel/kernel-session.d.ts.map +1 -1
  63. package/dist/kernel/kernel-session.js +71 -13
  64. package/dist/kernel/kernel-session.js.map +1 -1
  65. package/dist/kernel/kernel-types.d.ts +82 -9
  66. package/dist/kernel/kernel-types.d.ts.map +1 -1
  67. package/dist/kernel/kernel-types.js.map +1 -1
  68. package/dist/kernel/kernel.d.ts +94 -20
  69. package/dist/kernel/kernel.d.ts.map +1 -1
  70. package/dist/kernel/kernel.js +591 -70
  71. package/dist/kernel/kernel.js.map +1 -1
  72. package/dist/kernel/model-caller.d.ts +30 -2
  73. package/dist/kernel/model-caller.d.ts.map +1 -1
  74. package/dist/kernel/model-caller.js +55 -20
  75. package/dist/kernel/model-caller.js.map +1 -1
  76. package/dist/kernel/permission-gate.d.ts +6 -4
  77. package/dist/kernel/permission-gate.d.ts.map +1 -1
  78. package/dist/kernel/permission-gate.js +16 -6
  79. package/dist/kernel/permission-gate.js.map +1 -1
  80. package/dist/kernel/run-context.d.ts +26 -0
  81. package/dist/kernel/run-context.d.ts.map +1 -0
  82. package/dist/kernel/run-context.js +13 -0
  83. package/dist/kernel/run-context.js.map +1 -0
  84. package/dist/kernel/run-loop.d.ts +35 -14
  85. package/dist/kernel/run-loop.d.ts.map +1 -1
  86. package/dist/kernel/run-loop.js +295 -87
  87. package/dist/kernel/run-loop.js.map +1 -1
  88. package/dist/kernel/self-correction.d.ts +2 -2
  89. package/dist/kernel/self-correction.d.ts.map +1 -1
  90. package/dist/kernel/self-correction.js +3 -2
  91. package/dist/kernel/self-correction.js.map +1 -1
  92. package/dist/kernel/state-machine.d.ts +2 -0
  93. package/dist/kernel/state-machine.d.ts.map +1 -1
  94. package/dist/kernel/state-machine.js +2 -0
  95. package/dist/kernel/state-machine.js.map +1 -1
  96. package/dist/kernel/step-executor.d.ts +11 -3
  97. package/dist/kernel/step-executor.d.ts.map +1 -1
  98. package/dist/kernel/step-executor.js +54 -24
  99. package/dist/kernel/step-executor.js.map +1 -1
  100. package/dist/kernel/sub-agent-runner.d.ts +10 -7
  101. package/dist/kernel/sub-agent-runner.d.ts.map +1 -1
  102. package/dist/kernel/sub-agent-runner.js +105 -31
  103. package/dist/kernel/sub-agent-runner.js.map +1 -1
  104. package/dist/kernel/tool-context-builder.d.ts +7 -2
  105. package/dist/kernel/tool-context-builder.d.ts.map +1 -1
  106. package/dist/kernel/tool-context-builder.js +23 -6
  107. package/dist/kernel/tool-context-builder.js.map +1 -1
  108. package/dist/kernel/tool-error-router.d.ts +1 -1
  109. package/dist/kernel/tool-error-router.d.ts.map +1 -1
  110. package/dist/kernel/tool-error-router.js +4 -3
  111. package/dist/kernel/tool-error-router.js.map +1 -1
  112. package/dist/logger.d.ts +5 -0
  113. package/dist/logger.d.ts.map +1 -1
  114. package/dist/logger.js +3 -0
  115. package/dist/logger.js.map +1 -1
  116. package/dist/model.d.ts +3 -1
  117. package/dist/model.d.ts.map +1 -1
  118. package/dist/model.js +2 -0
  119. package/dist/model.js.map +1 -1
  120. package/dist/plugin/manager.d.ts +3 -1
  121. package/dist/plugin/manager.d.ts.map +1 -1
  122. package/dist/plugin/manager.js +51 -3
  123. package/dist/plugin/manager.js.map +1 -1
  124. package/dist/plugin/plugin-file-loader.js +2 -2
  125. package/dist/plugin/plugin-file-loader.js.map +1 -1
  126. package/dist/plugin.d.ts +50 -4
  127. package/dist/plugin.d.ts.map +1 -1
  128. package/dist/plugin.js +14 -1
  129. package/dist/plugin.js.map +1 -1
  130. package/dist/session/null-store.d.ts +1 -0
  131. package/dist/session/null-store.d.ts.map +1 -1
  132. package/dist/session/null-store.js +7 -0
  133. package/dist/session/null-store.js.map +1 -1
  134. package/dist/session/run-coordinator.d.ts +9 -3
  135. package/dist/session/run-coordinator.d.ts.map +1 -1
  136. package/dist/session/run-coordinator.js +4 -0
  137. package/dist/session/run-coordinator.js.map +1 -1
  138. package/dist/skill/create-skill-tool.d.ts +5 -4
  139. package/dist/skill/create-skill-tool.d.ts.map +1 -1
  140. package/dist/skill/create-skill-tool.js +8 -5
  141. package/dist/skill/create-skill-tool.js.map +1 -1
  142. package/dist/skill/skill-def-registry.d.ts +6 -0
  143. package/dist/skill/skill-def-registry.d.ts.map +1 -1
  144. package/dist/skill/skill-def-registry.js +4 -0
  145. package/dist/skill/skill-def-registry.js.map +1 -1
  146. package/dist/system-context/sources/instructions-source.js +1 -1
  147. package/dist/system-context/sources/instructions-source.js.map +1 -1
  148. package/dist/tool/bridge.d.ts +7 -1
  149. package/dist/tool/bridge.d.ts.map +1 -1
  150. package/dist/tool/bridge.js +5 -0
  151. package/dist/tool/bridge.js.map +1 -1
  152. package/dist/tool/builtin-provider.d.ts +35 -0
  153. package/dist/tool/builtin-provider.d.ts.map +1 -0
  154. package/dist/tool/builtin-provider.js +75 -0
  155. package/dist/tool/builtin-provider.js.map +1 -0
  156. package/dist/tool/index.d.ts +21 -23
  157. package/dist/tool/index.d.ts.map +1 -1
  158. package/dist/tool/index.js +19 -18
  159. package/dist/tool/index.js.map +1 -1
  160. package/dist/tool/provider-helpers.d.ts.map +1 -1
  161. package/dist/tool/provider-helpers.js +3 -2
  162. package/dist/tool/provider-helpers.js.map +1 -1
  163. package/dist/tool/runtime.d.ts +23 -2
  164. package/dist/tool/runtime.d.ts.map +1 -1
  165. package/dist/tool/runtime.js +20 -16
  166. package/dist/tool/runtime.js.map +1 -1
  167. package/dist/tracer.d.ts +5 -0
  168. package/dist/tracer.d.ts.map +1 -1
  169. package/dist/tracer.js +4 -0
  170. package/dist/tracer.js.map +1 -1
  171. package/dist/workflow/agent-graph.js +3 -3
  172. package/dist/workflow/agent-graph.js.map +1 -1
  173. package/dist/workflow/workflow-helpers.js +2 -2
  174. package/dist/workflow/workflow-helpers.js.map +1 -1
  175. package/dist/workspace.d.ts +7 -0
  176. package/dist/workspace.d.ts.map +1 -1
  177. package/dist/workspace.js +4 -0
  178. package/dist/workspace.js.map +1 -1
  179. package/package.json +18 -62
@@ -1,9 +1,23 @@
1
- import { KernelError } from "./kernel-error.js";
2
- import { evaluateStopConditions } from "./termination.js";
3
- import { buildJudgeMessages, parseJudgeVerdict } from "./termination.js";
1
+ import { getTextContent, COMPACTION_SUMMARY_PREFIX } from "@vinhnt-sdk/schema";
2
+ import { KernelError } from "@vinhnt-sdk/step-executor";
3
+ import { evaluateStopConditions, buildJudgeMessages, parseJudgeVerdict } from "@vinhnt-sdk/step-executor";
4
+ /** Index of the head `system` message — the first one. All system instructions
5
+ * (identity, agent systemPrompt, context baseline/reconciled updates) must live
6
+ * in that single message so providers never see mid-conversation `system`
7
+ * messages (RV-40). */
8
+ function headSystemIndex(messages) {
9
+ return messages.findIndex((m) => m.role === "system");
10
+ }
4
11
  async function initializeSystemContext(systemContext, messages) {
5
12
  const sc = await systemContext.initialize();
6
13
  if (sc.baseline) {
14
+ const idx = headSystemIndex(messages);
15
+ if (idx >= 0) {
16
+ const updated = [...messages];
17
+ const head = updated[idx];
18
+ updated[idx] = { ...head, content: `${getTextContent(head.content)}\n\n${sc.baseline}` };
19
+ return { messages: updated, contextEpochActive: true, didChange: true };
20
+ }
7
21
  return { messages: [{ role: "system", content: sc.baseline }, ...messages], contextEpochActive: true, didChange: true };
8
22
  }
9
23
  return { messages, contextEpochActive: true, didChange: false };
@@ -11,13 +25,25 @@ async function initializeSystemContext(systemContext, messages) {
11
25
  async function reconcileSystemContext(systemContext, messages, step) {
12
26
  const result = await systemContext.reconcile();
13
27
  if (result.type === "updated" && result.update) {
28
+ // Merge the update into the head system message rather than appending a new
29
+ // mid-conversation `system` message that providers may reject or mis-handle.
30
+ const idx = headSystemIndex(messages);
31
+ if (idx >= 0) {
32
+ const updated = [...messages];
33
+ const head = updated[idx];
34
+ updated[idx] = { ...head, content: `${getTextContent(head.content)}\n\n${result.update}` };
35
+ return { messages: updated, contextEpochActive: true, didChange: true };
36
+ }
37
+ // No head system message (run without an agent) — keep the update rather than drop it.
14
38
  return { messages: [...messages, { role: "system", content: result.update }], contextEpochActive: true, didChange: true };
15
39
  }
16
40
  if (result.type === "replaced" && step > 0) {
17
- const idx = messages.findIndex((m) => m.role === "system");
41
+ // Replaced: refresh the head system message content in place.
42
+ const idx = headSystemIndex(messages);
18
43
  const updated = [...messages];
19
44
  if (idx >= 0) {
20
- updated[idx] = { role: "system", content: result.systemContext.baseline };
45
+ const head = updated[idx];
46
+ updated[idx] = { ...head, content: result.systemContext.baseline };
21
47
  }
22
48
  else {
23
49
  updated.unshift({ role: "system", content: result.systemContext.baseline });
@@ -26,12 +52,12 @@ async function reconcileSystemContext(systemContext, messages, step) {
26
52
  }
27
53
  return { messages, contextEpochActive: true, didChange: false };
28
54
  }
29
- async function maybeCompact(messages, runModel, deps, signal, emitEvent) {
55
+ async function maybeCompact(messages, runModel, deps, signal, sessionId, emitEvent) {
30
56
  if (!deps.compactor)
31
57
  return { messages, didCompact: false };
32
58
  let shouldCompact = true;
33
59
  if (runModel.countTokens) {
34
- const estimatedInput = messages.reduce((sum, m) => sum + runModel.countTokens(m.content), 0);
60
+ const estimatedInput = messages.reduce((sum, m) => sum + runModel.countTokens(getTextContent(m.content)), 0);
35
61
  const contextWindow = runModel.contextLimit ?? deps.maxTokens * 4;
36
62
  const ratio = deps.compactionThreshold ?? 0.75;
37
63
  const threshold = Math.floor(contextWindow * ratio);
@@ -49,14 +75,46 @@ async function maybeCompact(messages, runModel, deps, signal, emitEvent) {
49
75
  originalCount: compacted.summary.originalMessageCount,
50
76
  compressedCount: compacted.summary.compressedMessageCount,
51
77
  });
78
+ // RV-15 durable compaction: persist the summary as a durable marker so that
79
+ // a restart / resume rebuilds the compacted context instead of the raw
80
+ // transcript. Best-effort — a store failure must NOT roll back the run or
81
+ // the compaction (the in-memory compacted context still applies).
82
+ if (sessionId) {
83
+ const summaryText = compacted.summary.summary
84
+ ? `${COMPACTION_SUMMARY_PREFIX}${compacted.summary.summary}`
85
+ : `${COMPACTION_SUMMARY_PREFIX}Compressed ${compacted.summary.originalMessageCount} → ${compacted.summary.compressedMessageCount} messages`;
86
+ try {
87
+ await deps.addSessionMessage(sessionId, "system", summaryText);
88
+ }
89
+ catch (err) {
90
+ if (typeof console !== "undefined") {
91
+ console.warn("[run-loop] Failed to persist compaction summary:", err instanceof Error ? err.message : String(err));
92
+ }
93
+ }
94
+ }
52
95
  return { messages: [...compacted.messages], didCompact: true };
53
96
  }
54
97
  return { messages, didCompact: false };
55
98
  }
56
- async function compactOnOverflow(messages, compactor, step, signal) {
99
+ async function compactOnOverflow(messages, compactor, step, signal, sessionId, addSessionMessage) {
57
100
  if (step <= 0)
58
101
  throw new Error("Cannot compact on step 0 — context overflow should not occur on first step");
59
102
  const compacted = await compactor.compact(messages, signal);
103
+ // RV-15: overflow compaction is also durable — persist a summary marker so a
104
+ // restart rebuilds the compacted context. Best-effort, never rolls back.
105
+ if (compacted.summary.compressedMessageCount < compacted.summary.originalMessageCount && sessionId) {
106
+ const summaryText = compacted.summary.summary
107
+ ? `${COMPACTION_SUMMARY_PREFIX}${compacted.summary.summary}`
108
+ : `${COMPACTION_SUMMARY_PREFIX}Compressed ${compacted.summary.originalMessageCount} → ${compacted.summary.compressedMessageCount} messages`;
109
+ try {
110
+ await addSessionMessage(sessionId, "system", summaryText);
111
+ }
112
+ catch (err) {
113
+ if (typeof console !== "undefined") {
114
+ console.warn("[run-loop] Failed to persist overflow compaction summary:", err instanceof Error ? err.message : String(err));
115
+ }
116
+ }
117
+ }
60
118
  return [...compacted.messages];
61
119
  }
62
120
  function isContextOverflowError(msg) {
@@ -72,15 +130,10 @@ async function processStep(deps, input) {
72
130
  runAbort.signal.addEventListener("abort", onRunAbort, { once: true });
73
131
  try {
74
132
  if (runAbort.signal.aborted) {
75
- return {
76
- ...input, completed: true, toolCallCount: 0,
77
- messages: input.messages,
78
- step: input.step,
79
- lastStepToolOutcomes: [],
80
- };
81
- }
82
- if (!deps.permissionGate.checkMaxSteps(input.step, deps.currentAgent)) {
83
- throw new KernelError("max_steps_exceeded", `Agent max steps exceeded (${input.step})`);
133
+ // RV-7: an abort landing between the loop guard and the step start must
134
+ // not silently report `succeeded` — surface it so the run-loop catch can
135
+ // converge on the single `cancelled` terminal outcome.
136
+ throw new DOMException("Run cancelled", "AbortError");
84
137
  }
85
138
  if (!deps.permissionGate.checkMaxTokens(input.totalInputTokens, input.totalOutputTokens, deps.currentAgent)) {
86
139
  throw new KernelError("max_tokens_exceeded", `Agent max tokens exceeded (${input.totalInputTokens + input.totalOutputTokens})`);
@@ -89,14 +142,26 @@ async function processStep(deps, input) {
89
142
  if (deps.thinkingBudget > 0) {
90
143
  await deps.modelCaller.doThinkingStep(messages, input.step, runId, ctx, stepTimeoutController.signal);
91
144
  }
92
- if (runModel.countTokens) {
93
- input.totalInputTokens += messages.reduce((sum, m) => sum + runModel.countTokens(m.content), 0);
94
- }
95
145
  let response;
96
146
  try {
97
- response = await deps.circuitBreaker.call(() => deps.modelCaller.callModelStream(messages, input.step, runId, ctx, stepTimeoutController.signal, deps.currentAgent?.permissions?.maxTokens, input.disableTools));
147
+ response = await deps.circuitBreaker.call(() => deps.modelCaller.callModelStream(messages, input.step, runId, ctx, stepTimeoutController.signal, deps.currentAgent?.permissions?.maxTokens, input.disableTools), stepTimeoutController.signal);
98
148
  }
99
149
  catch (err) {
150
+ if (stepTimeoutController.signal.aborted && !runAbort.signal.aborted) {
151
+ // Step-level timeout: the step fails but the run continues to the next step.
152
+ return {
153
+ messages,
154
+ step: input.step,
155
+ runId,
156
+ totalInputTokens: input.totalInputTokens,
157
+ totalOutputTokens: input.totalOutputTokens,
158
+ finalOutput: input.finalOutput,
159
+ completed: false,
160
+ toolCallCount: 0,
161
+ lastStepToolOutcomes: [],
162
+ stepFailed: { reason: "timeout", error: `Model call timed out after ${deps.stepTimeout}ms` },
163
+ };
164
+ }
100
165
  const circuitErr = err;
101
166
  if (circuitErr?.constructor?.name === "CircuitBreakerOpenError") {
102
167
  throw new KernelError("model_unavailable", err.message, err);
@@ -104,15 +169,28 @@ async function processStep(deps, input) {
104
169
  const errMsg = err instanceof Error ? err.message : String(err);
105
170
  if (isContextOverflowError(errMsg) && deps.compactor && input.step > 0) {
106
171
  const compactor = deps.compactor;
107
- messages = await compactOnOverflow(messages, compactor, input.step, stepTimeoutController.signal);
172
+ messages = await compactOnOverflow(messages, compactor, input.step, stepTimeoutController.signal, sessionId, deps.addSessionMessage);
108
173
  runSessionState?.resetMessages(messages);
109
- response = await deps.circuitBreaker.call(() => deps.modelCaller.callModelStream(messages, input.step, runId, ctx, stepTimeoutController.signal, deps.currentAgent?.permissions?.maxTokens));
174
+ response = await deps.circuitBreaker.call(() => deps.modelCaller.callModelStream(messages, input.step, runId, ctx, stepTimeoutController.signal, deps.currentAgent?.permissions?.maxTokens), stepTimeoutController.signal);
110
175
  }
111
176
  else {
112
177
  throw err;
113
178
  }
114
179
  }
115
- if (runModel.countTokens) {
180
+ // RV-42: single authoritative token accounting — prefer the provider's usage
181
+ // (now surfaced on ModelResponse), fall back to local countTokens.
182
+ const usageIn = response.usage?.promptTokens;
183
+ const usageOut = response.usage?.completionTokens;
184
+ if (usageIn !== undefined && usageIn > 0) {
185
+ input.totalInputTokens += usageIn;
186
+ }
187
+ else if (runModel.countTokens) {
188
+ input.totalInputTokens += messages.reduce((sum, m) => sum + runModel.countTokens(getTextContent(m.content)), 0);
189
+ }
190
+ if (usageOut !== undefined && usageOut > 0) {
191
+ input.totalOutputTokens += usageOut;
192
+ }
193
+ else if (runModel.countTokens) {
116
194
  input.totalOutputTokens += runModel.countTokens(response.content);
117
195
  }
118
196
  const toolCalls = response.toolCalls ?? [];
@@ -149,6 +227,27 @@ async function processStep(deps, input) {
149
227
  };
150
228
  }
151
229
  const { toolCallCount, selfCorrectTokens, toolResults } = await deps.stepExecutor.executeToolCalls(toolCalls.map((tc) => ({ toolId: tc.id, toolName: tc.name, args: tc.args })), messages, input.step, runId, ctx, stepTimeoutController, sessionId, runModel);
230
+ if (stepTimeoutController.signal.aborted && !runAbort.signal.aborted) {
231
+ // Step timed out during tool execution — surface an error for any tool call
232
+ // that never got a response so the conversation stays coherent for the model.
233
+ for (const tc of toolCalls) {
234
+ if (!messages.some((m) => m.role === "tool" && m.toolCallId === tc.id)) {
235
+ messages.push({ role: "tool", toolCallId: tc.id, content: `Error: Step timed out after ${deps.stepTimeout}ms` });
236
+ }
237
+ }
238
+ return {
239
+ messages,
240
+ step: input.step,
241
+ runId,
242
+ totalInputTokens: input.totalInputTokens,
243
+ totalOutputTokens: input.totalOutputTokens,
244
+ finalOutput: input.finalOutput,
245
+ completed: false,
246
+ toolCallCount,
247
+ lastStepToolOutcomes: toolResults,
248
+ stepFailed: { reason: "timeout", error: `Tool execution timed out after ${deps.stepTimeout}ms` },
249
+ };
250
+ }
152
251
  // Accumulate self-correction tokens into run totals
153
252
  input.totalInputTokens += selfCorrectTokens.input;
154
253
  input.totalOutputTokens += selfCorrectTokens.output;
@@ -156,19 +255,23 @@ async function processStep(deps, input) {
156
255
  runSessionState.step = input.step + 1;
157
256
  runSessionState.toolCallCount += toolCallCount;
158
257
  }
159
- // Snapshot run state
160
- deps.store.saveSnapshot(runId, {
161
- step: input.step + 1,
162
- model: runModel.model,
163
- totalInputTokens: input.totalInputTokens,
164
- totalOutputTokens: input.totalOutputTokens,
165
- finalOutput: input.finalOutput,
166
- sessionId,
167
- }).catch((err) => {
258
+ // Snapshot run state — awaited so a write failure is observed before the
259
+ // step is reported complete, preventing silent state loss (RV-33).
260
+ try {
261
+ await deps.store.saveSnapshot(runId, {
262
+ step: input.step + 1,
263
+ model: runModel.model,
264
+ totalInputTokens: input.totalInputTokens,
265
+ totalOutputTokens: input.totalOutputTokens,
266
+ finalOutput: input.finalOutput,
267
+ sessionId,
268
+ });
269
+ }
270
+ catch (err) {
168
271
  if (typeof console !== "undefined") {
169
272
  console.warn("[run-loop] Failed to save snapshot:", err instanceof Error ? err.message : String(err));
170
273
  }
171
- });
274
+ }
172
275
  return {
173
276
  messages,
174
277
  step: input.step,
@@ -190,7 +293,7 @@ async function processStep(deps, input) {
190
293
  // Run loop
191
294
  // ---------------------------------------------------------------------------
192
295
  export async function runLoop(deps, input) {
193
- const { prompt, runId, ctx, runAbort, sessionId, userContentParts, runModel, runSessionState, addSessionMessage, emitEvent, setState, updateSessionOnComplete, emitFail } = input;
296
+ const { prompt, runId, ctx, runAbort, sessionId, userContentParts, runModel, runSessionState, addSessionMessage, emitEvent, setState, emitCompleted, emitFail } = input;
194
297
  const startTime = Date.now();
195
298
  let totalInputTokens = 0;
196
299
  let totalOutputTokens = 0;
@@ -198,53 +301,123 @@ export async function runLoop(deps, input) {
198
301
  let step = 0;
199
302
  let finalOutput = "";
200
303
  let contextEpochActive = false;
201
- const effectiveMaxSteps = deps.termination?.maxSteps ?? deps.maxSteps;
304
+ // Real system head (identity + agent systemPrompt) sent as a proper `system`
305
+ // message at the head of the conversation instead of being flattened into the
306
+ // user turn (RV-40).
307
+ const systemHead = input.systemPrompt
308
+ ? [{ role: "system", content: input.systemPrompt }]
309
+ : [];
310
+ // Single max-steps authority: resolve the run's step budget from every
311
+ // applicable source (config default, termination policy, agent permission)
312
+ // once, then enforce it in exactly one place — the loop bound below.
313
+ const runMaxSteps = Math.min(deps.termination?.maxSteps ?? Number.POSITIVE_INFINITY, deps.currentAgent?.permissions?.maxSteps ?? Number.POSITIVE_INFINITY, deps.maxSteps);
202
314
  const emitEvt = (type, data) => emitEvent({
203
315
  id: crypto.randomUUID(), runId, type,
204
316
  occurredAt: new Date().toISOString(), traceId: ctx.traceId,
205
317
  data,
206
318
  });
319
+ const saveFinalSnapshot = async (status) => {
320
+ try {
321
+ await deps.store.saveSnapshot(runId, {
322
+ step: step + 1,
323
+ model: runModel.model,
324
+ totalInputTokens,
325
+ totalOutputTokens,
326
+ finalOutput,
327
+ sessionId,
328
+ status,
329
+ });
330
+ }
331
+ catch (err) {
332
+ if (typeof console !== "undefined") {
333
+ console.warn("[run-loop] Failed to save final snapshot:", err instanceof Error ? err.message : String(err));
334
+ }
335
+ }
336
+ };
337
+ // RV-7: exactly ONE terminal cancelled outcome regardless of abort timing.
338
+ // Every cancellation path funnels through this single block (loop guard,
339
+ // step-start abort, in-flight AbortError, and terminal-emission guards), so a
340
+ // run cancelled at any moment reports `cancelled` exactly once — never a
341
+ // silent `succeeded` or a confusing `failed`.
342
+ const cancelRun = async (steps) => {
343
+ await emitFail(runId, ctx, "Run cancelled", steps, sessionId, totalInputTokens, totalOutputTokens, Date.now() - startTime, true);
344
+ await deps.saga.rollbackAll();
345
+ setState(runId, "cancelled");
346
+ if (runSessionState)
347
+ runSessionState.isRunning = false;
348
+ await saveFinalSnapshot("cancelled");
349
+ return { totalSteps: steps, status: "cancelled" };
350
+ };
207
351
  try {
208
352
  setState(runId, "running");
353
+ // Re-queue any persisted-but-un-promoted inputs before the first step drains (RV-21).
354
+ if (deps.beforeRun) {
355
+ await deps.beforeRun(runId);
356
+ }
209
357
  const startedModel = runModel.model;
210
358
  const startedAgentName = deps.currentAgent?.profile.name;
211
359
  const startedAgentId = deps.currentAgent?.id;
212
360
  // Skip run.started event when resuming from durable storage
213
361
  if (!input.resume) {
362
+ // Audit + session record keep the full effective prompt (system head +
363
+ // user prompt); the messages sent to the model split them into a real
364
+ // `system` head + `user` turn (RV-40).
365
+ const effectivePrompt = [input.systemPrompt, prompt].filter(Boolean).join("\n\n");
214
366
  await emitEvt("run.started", {
215
- prompt,
367
+ prompt: effectivePrompt,
216
368
  ...(startedModel ? { model: startedModel } : {}),
217
369
  ...(startedAgentName ? { agentName: startedAgentName } : {}),
218
370
  ...(startedAgentId ? { agentId: startedAgentId } : {}),
219
371
  });
220
372
  await deps.pluginManager?.fireHook("onRunStarted", { runId, prompt });
221
373
  const currentModel = runModel.model;
222
- await addSessionMessage(sessionId, "user", prompt, { ...(currentModel ? { model: currentModel } : {}) });
374
+ await addSessionMessage(sessionId, "user", effectivePrompt, { ...(currentModel ? { model: currentModel } : {}) });
223
375
  messages = [
376
+ ...systemHead,
224
377
  userContentParts?.length
225
- ? { role: "user", content: prompt, contentParts: userContentParts }
378
+ ? { role: "user", content: userContentParts }
226
379
  : { role: "user", content: prompt },
227
380
  ];
228
381
  }
229
- // When resuming, messages already contain the full conversation history
230
- if (input.resume && runSessionState && runSessionState.messages.length > 0) {
231
- messages = [...runSessionState.messages];
382
+ // On resume, rebuild from persisted history (never double-append it) and keep
383
+ // the current steering prompt as the latest user turn. On a continuation run
384
+ // the conversation history must come BEFORE the fresh prompt so the
385
+ // transcript stays chronologically ordered (the prompt was seeded above).
386
+ if (input.resume) {
387
+ if (runSessionState && runSessionState.messages.length > 0) {
388
+ messages = [...runSessionState.messages];
389
+ }
390
+ if (prompt)
391
+ messages.push({ role: "user", content: prompt });
392
+ // Persisted history predates the RV-40 system head — re-inject it so a
393
+ // resumed run still sends a real `system` message to the model.
394
+ if (systemHead.length > 0 && !messages.some((m) => m.role === "system")) {
395
+ messages = [...systemHead, ...messages];
396
+ }
232
397
  }
233
- if (runSessionState && runSessionState.messages.length > 0) {
234
- messages.push(...runSessionState.messages);
398
+ else if (runSessionState && runSessionState.messages.length > 0) {
399
+ messages = [
400
+ ...systemHead,
401
+ ...runSessionState.messages,
402
+ userContentParts?.length
403
+ ? { role: "user", content: userContentParts }
404
+ : { role: "user", content: prompt },
405
+ ];
235
406
  }
236
- step = runSessionState?.step ?? 0;
237
- for (step = 0; step < effectiveMaxSteps; step++) {
407
+ // Continue from the restored step counter so max-steps accounting and any
408
+ // step-relative logic stay continuous across a resume.
409
+ const startedStep = runSessionState?.step ?? 0;
410
+ for (step = startedStep; step < runMaxSteps; step++) {
238
411
  if (runAbort.signal.aborted) {
239
- await emitFail(runId, ctx, "Run cancelled", step, sessionId, totalInputTokens, totalOutputTokens);
240
- await deps.saga.rollbackAll();
241
- setState(runId, "cancelled");
242
- return;
412
+ return cancelRun(step);
243
413
  }
244
414
  const drainedInputs = deps.stateMachine.drainInputs(runId);
245
415
  for (const text of drainedInputs) {
246
416
  messages.push({ role: "user", content: text });
247
417
  }
418
+ if (drainedInputs.length > 0 && deps.onInputsDrained) {
419
+ await deps.onInputsDrained(runId, drainedInputs);
420
+ }
248
421
  if (deps.systemContext) {
249
422
  if (!contextEpochActive) {
250
423
  const result = await initializeSystemContext(deps.systemContext, messages);
@@ -262,7 +435,7 @@ export async function runLoop(deps, input) {
262
435
  const compactResult = await maybeCompact(messages, {
263
436
  ...(runModel.countTokens ? { countTokens: runModel.countTokens } : {}),
264
437
  ...(runModel.contextLimit !== undefined ? { contextLimit: runModel.contextLimit } : {}),
265
- }, deps, runAbort.signal, (type, data) => emitEvt(type, data));
438
+ }, deps, runAbort.signal, sessionId, (type, data) => emitEvt(type, data));
266
439
  if (compactResult.didCompact) {
267
440
  messages = compactResult.messages;
268
441
  runSessionState?.resetMessages(compactResult.messages);
@@ -270,13 +443,13 @@ export async function runLoop(deps, input) {
270
443
  contextEpochActive = false;
271
444
  }
272
445
  }
273
- if (step >= effectiveMaxSteps - 1) {
446
+ if (step >= runMaxSteps - 1) {
274
447
  messages.push({
275
448
  role: "system",
276
- content: `[You have reached the maximum number of steps (${effectiveMaxSteps}). This is your final opportunity to respond. Do NOT call any tools. Provide a comprehensive summary and any final output.]`,
449
+ content: `[You have reached the maximum number of steps (${runMaxSteps}). This is your final opportunity to respond. Do NOT call any tools. Provide a comprehensive summary and any final output.]`,
277
450
  });
278
451
  }
279
- const onLastStep = step >= effectiveMaxSteps - 1;
452
+ const onLastStep = step >= runMaxSteps - 1;
280
453
  const stepResult = await processStep(deps, {
281
454
  messages, step, runId, ctx, runAbort,
282
455
  ...(sessionId !== undefined ? { sessionId } : {}),
@@ -289,8 +462,14 @@ export async function runLoop(deps, input) {
289
462
  totalInputTokens = stepResult.totalInputTokens;
290
463
  totalOutputTokens = stepResult.totalOutputTokens;
291
464
  finalOutput = stepResult.finalOutput;
292
- await emitEvt("step.completed", { step, toolCallCount: stepResult.toolCallCount });
293
- await deps.pluginManager?.fireHook("onStepCompleted", { step, toolCallCount: stepResult.toolCallCount });
465
+ if (stepResult.stepFailed) {
466
+ await emitEvt("step.failed", { step, reason: stepResult.stepFailed.reason, ...(stepResult.stepFailed.error ? { error: stepResult.stepFailed.error } : {}) });
467
+ await deps.pluginManager?.fireHook("onStepFailed", { step, reason: stepResult.stepFailed.reason, ...(stepResult.stepFailed.error ? { error: stepResult.stepFailed.error } : {}) });
468
+ }
469
+ else {
470
+ await emitEvt("step.completed", { step, toolCallCount: stepResult.toolCallCount });
471
+ await deps.pluginManager?.fireHook("onStepCompleted", { step, toolCallCount: stepResult.toolCallCount });
472
+ }
294
473
  if (stepResult.completed) {
295
474
  finalOutput = stepResult.finalOutput;
296
475
  break;
@@ -305,11 +484,13 @@ export async function runLoop(deps, input) {
305
484
  (budget.output !== undefined && totalOutputTokens > budget.output) ||
306
485
  (budget.total !== undefined && total > budget.total);
307
486
  if (over) {
487
+ if (runAbort.signal.aborted)
488
+ return cancelRun(step + 1);
308
489
  const reason = `Token budget exceeded (${total} tokens)`;
309
490
  await emitFail(runId, ctx, reason, step, sessionId, totalInputTokens, totalOutputTokens);
310
491
  await deps.saga.rollbackAll();
311
492
  setState(runId, "failed");
312
- return;
493
+ return { totalSteps: step + 1, status: "failed" };
313
494
  }
314
495
  }
315
496
  const stopCtx = {
@@ -349,13 +530,19 @@ export async function runLoop(deps, input) {
349
530
  }
350
531
  }
351
532
  if (stopReason) {
352
- await emitEvt("run.completed", {
353
- status: "succeeded", output: finalOutput, totalSteps: step + 1,
354
- durationMs: Date.now() - startTime,
355
- stopCondition: stopReason,
356
- ...(totalInputTokens > 0 ? { inputTokens: totalInputTokens } : {}),
357
- ...(totalOutputTokens > 0 ? { outputTokens: totalOutputTokens } : {}),
358
- });
533
+ if (runAbort.signal.aborted)
534
+ return cancelRun(step + 1);
535
+ await emitCompleted({
536
+ id: crypto.randomUUID(), runId, type: "run.completed",
537
+ occurredAt: new Date().toISOString(), traceId: ctx.traceId,
538
+ data: {
539
+ status: "succeeded", output: finalOutput, totalSteps: step + 1,
540
+ durationMs: Date.now() - startTime,
541
+ stopCondition: stopReason,
542
+ ...(totalInputTokens > 0 ? { inputTokens: totalInputTokens } : {}),
543
+ ...(totalOutputTokens > 0 ? { outputTokens: totalOutputTokens } : {}),
544
+ },
545
+ }, sessionId, runId, totalInputTokens, totalOutputTokens, "succeeded");
359
546
  await deps.pluginManager?.fireHook("onRunCompleted", { status: "succeeded", output: finalOutput, stopCondition: stopReason });
360
547
  setState(runId, "completed");
361
548
  if (runSessionState) {
@@ -363,20 +550,26 @@ export async function runLoop(deps, input) {
363
550
  runSessionState.step = step + 1;
364
551
  runSessionState.isRunning = false;
365
552
  }
366
- await updateSessionOnComplete(sessionId, runId, totalInputTokens, totalOutputTokens, step + 1, "succeeded");
367
- return;
553
+ await saveFinalSnapshot("succeeded");
554
+ return { totalSteps: step + 1, status: "succeeded" };
368
555
  }
369
556
  }
370
557
  }
371
558
  const durationMs = Date.now() - startTime;
372
- if (step >= effectiveMaxSteps) {
559
+ if (step >= runMaxSteps) {
560
+ if (runAbort.signal.aborted)
561
+ return cancelRun(step + 1);
373
562
  if (finalOutput) {
374
- await emitEvt("run.completed", {
375
- status: "succeeded", output: finalOutput, totalSteps: step + 1,
376
- durationMs,
377
- ...(totalInputTokens > 0 ? { inputTokens: totalInputTokens } : {}),
378
- ...(totalOutputTokens > 0 ? { outputTokens: totalOutputTokens } : {}),
379
- });
563
+ await emitCompleted({
564
+ id: crypto.randomUUID(), runId, type: "run.completed",
565
+ occurredAt: new Date().toISOString(), traceId: ctx.traceId,
566
+ data: {
567
+ status: "succeeded", output: finalOutput, totalSteps: step + 1,
568
+ durationMs,
569
+ ...(totalInputTokens > 0 ? { inputTokens: totalInputTokens } : {}),
570
+ ...(totalOutputTokens > 0 ? { outputTokens: totalOutputTokens } : {}),
571
+ },
572
+ }, sessionId, runId, totalInputTokens, totalOutputTokens, "succeeded");
380
573
  await deps.pluginManager?.fireHook("onRunCompleted", { status: "succeeded", output: finalOutput });
381
574
  if (deps.sessionStore && deps.sessionTitleGenerator && sessionId) {
382
575
  const session = await deps.sessionStore.getSession(sessionId).catch(() => null);
@@ -391,24 +584,30 @@ export async function runLoop(deps, input) {
391
584
  }
392
585
  }
393
586
  }
394
- if (sessionId) {
395
- await updateSessionOnComplete(sessionId, runId, totalInputTokens, totalOutputTokens, step + 1, "succeeded");
396
- }
397
587
  setState(runId, "completed");
398
- return;
588
+ await saveFinalSnapshot("succeeded");
589
+ return { totalSteps: step + 1, status: "succeeded" };
399
590
  }
400
- await emitFail(runId, ctx, `Exceeded max steps (${effectiveMaxSteps})`, step, sessionId, totalInputTokens, totalOutputTokens, durationMs);
591
+ if (runAbort.signal.aborted)
592
+ return cancelRun(step + 1);
593
+ await emitFail(runId, ctx, `Exceeded max steps (${runMaxSteps})`, step, sessionId, totalInputTokens, totalOutputTokens, durationMs);
401
594
  await deps.saga.rollbackAll();
402
595
  setState(runId, "failed");
403
- return;
596
+ return { totalSteps: step, status: "failed" };
404
597
  }
405
598
  else {
406
- await emitEvt("run.completed", {
407
- status: "succeeded", output: finalOutput, totalSteps: step + 1,
408
- durationMs,
409
- ...(totalInputTokens > 0 ? { inputTokens: totalInputTokens } : {}),
410
- ...(totalOutputTokens > 0 ? { outputTokens: totalOutputTokens } : {}),
411
- });
599
+ if (runAbort.signal.aborted)
600
+ return cancelRun(step + 1);
601
+ await emitCompleted({
602
+ id: crypto.randomUUID(), runId, type: "run.completed",
603
+ occurredAt: new Date().toISOString(), traceId: ctx.traceId,
604
+ data: {
605
+ status: "succeeded", output: finalOutput, totalSteps: step + 1,
606
+ durationMs,
607
+ ...(totalInputTokens > 0 ? { inputTokens: totalInputTokens } : {}),
608
+ ...(totalOutputTokens > 0 ? { outputTokens: totalOutputTokens } : {}),
609
+ },
610
+ }, sessionId, runId, totalInputTokens, totalOutputTokens, "succeeded");
412
611
  await deps.pluginManager?.fireHook("onRunCompleted", { status: "succeeded", output: finalOutput });
413
612
  if (deps.sessionStore && deps.sessionTitleGenerator && sessionId) {
414
613
  const session = await deps.sessionStore.getSession(sessionId).catch(() => null);
@@ -430,16 +629,25 @@ export async function runLoop(deps, input) {
430
629
  runSessionState.step = step + 1;
431
630
  runSessionState.isRunning = false;
432
631
  }
433
- await updateSessionOnComplete(sessionId, runId, totalInputTokens, totalOutputTokens, step + 1, "succeeded");
632
+ await saveFinalSnapshot("succeeded");
633
+ return { totalSteps: step + 1, status: "succeeded" };
434
634
  }
435
635
  catch (err) {
636
+ // A cancellation that surfaced as an abort (e.g. the model call rejecting
637
+ // with AbortError, or the step-start guard) must converge on the single
638
+ // `cancelled` terminal outcome — never a confusing `failed`.
639
+ if (runAbort.signal.aborted) {
640
+ return cancelRun(step + 1);
641
+ }
436
642
  const errorMsg = err instanceof Error ? err.message : String(err);
437
643
  const failDurationMs = Date.now() - startTime;
438
- // emitFail already emits run.completed(failed) and calls saga.rollbackAll()
644
+ // emitFail already emits run.completed(failed); roll back the per-run saga explicitly.
645
+ await deps.saga.rollbackAll();
439
646
  await emitFail(runId, ctx, errorMsg, step, sessionId, totalInputTokens, totalOutputTokens, failDurationMs);
440
647
  setState(runId, "failed");
441
648
  if (runSessionState)
442
649
  runSessionState.isRunning = false;
650
+ return { totalSteps: step + 1, status: "failed" };
443
651
  }
444
652
  finally {
445
653
  deps.saga.clear();