@osolmaz/pi-workflows 0.12.1 → 0.13.1

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 (233) hide show
  1. package/README.md +59 -33
  2. package/dist/builtins/autodoc.workflow.d.ts +194 -7
  3. package/dist/builtins/autodoc.workflow.js +156 -30
  4. package/dist/builtins/autodoc.workflow.js.map +1 -1
  5. package/dist/builtins/autoimplement-command-batches.d.ts +1 -0
  6. package/dist/builtins/autoimplement-command-batches.js +29 -31
  7. package/dist/builtins/autoimplement-command-batches.js.map +1 -1
  8. package/dist/builtins/autoimplement.workflow.d.ts +1619 -97
  9. package/dist/builtins/autoimplement.workflow.js +416 -153
  10. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  11. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  12. package/dist/builtins/autoplan.workflow.js +200 -29
  13. package/dist/builtins/autoplan.workflow.js.map +1 -1
  14. package/dist/builtins/catalog.js +7 -5
  15. package/dist/builtins/catalog.js.map +1 -1
  16. package/dist/builtins/change-verification.workflow.d.ts +110 -0
  17. package/dist/builtins/change-verification.workflow.js +860 -0
  18. package/dist/builtins/change-verification.workflow.js.map +1 -0
  19. package/dist/builtins/index.d.ts +2 -1
  20. package/dist/builtins/index.js +1 -0
  21. package/dist/builtins/index.js.map +1 -1
  22. package/dist/builtins/monitor.workflow.d.ts +56 -21
  23. package/dist/builtins/monitor.workflow.js +499 -217
  24. package/dist/builtins/monitor.workflow.js.map +1 -1
  25. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  26. package/dist/builtins/plain-summary.workflow.js +134 -0
  27. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  28. package/dist/builtins/plan-change.workflow.d.ts +527 -26
  29. package/dist/builtins/plan-change.workflow.js +26 -0
  30. package/dist/builtins/plan-change.workflow.js.map +1 -1
  31. package/dist/builtins/sanity-check.workflow.d.ts +45 -3
  32. package/dist/builtins/sanity-check.workflow.js +45 -7
  33. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  34. package/dist/builtins/workspace-preparation.workflow.d.ts +75 -0
  35. package/dist/builtins/workspace-preparation.workflow.js +498 -0
  36. package/dist/builtins/workspace-preparation.workflow.js.map +1 -0
  37. package/dist/controllers/effects.d.ts +3 -2
  38. package/dist/controllers/effects.js +8 -1
  39. package/dist/controllers/effects.js.map +1 -1
  40. package/dist/controllers/index.d.ts +1 -1
  41. package/dist/controllers/index.js +1 -1
  42. package/dist/controllers/index.js.map +1 -1
  43. package/dist/controllers/manager.d.ts +1 -0
  44. package/dist/controllers/manager.js +41 -17
  45. package/dist/controllers/manager.js.map +1 -1
  46. package/dist/controllers/sqlite.d.ts +107 -85
  47. package/dist/controllers/sqlite.js +1611 -1486
  48. package/dist/controllers/sqlite.js.map +1 -1
  49. package/dist/controllers/store.d.ts +9 -8
  50. package/dist/controllers/store.js +1 -31
  51. package/dist/controllers/store.js.map +1 -1
  52. package/dist/controllers/types.d.ts +3 -0
  53. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  54. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  55. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  56. package/dist/controllers/workflows.d.ts +2 -2
  57. package/dist/controllers/workflows.js +11 -10
  58. package/dist/controllers/workflows.js.map +1 -1
  59. package/dist/extension/controller-host.js +2 -2
  60. package/dist/extension/controller-host.js.map +1 -1
  61. package/dist/extension/decision-channels.d.ts +2 -0
  62. package/dist/extension/decision-channels.js +311 -118
  63. package/dist/extension/decision-channels.js.map +1 -1
  64. package/dist/extension/executor.d.ts +9 -2
  65. package/dist/extension/executor.js +90 -6
  66. package/dist/extension/executor.js.map +1 -1
  67. package/dist/extension/herdr-viewer.d.ts +0 -1
  68. package/dist/extension/herdr-viewer.js +0 -4
  69. package/dist/extension/herdr-viewer.js.map +1 -1
  70. package/dist/extension/index.js +156 -117
  71. package/dist/extension/index.js.map +1 -1
  72. package/dist/extension/recorder.d.ts +3 -10
  73. package/dist/extension/recorder.js +13 -22
  74. package/dist/extension/recorder.js.map +1 -1
  75. package/dist/extension/step-message.d.ts +7 -2
  76. package/dist/extension/step-message.js +94 -2
  77. package/dist/extension/step-message.js.map +1 -1
  78. package/dist/extension/widget.js +6 -0
  79. package/dist/extension/widget.js.map +1 -1
  80. package/dist/host/rpc-executor.d.ts +3 -0
  81. package/dist/host/rpc-executor.js +2 -0
  82. package/dist/host/rpc-executor.js.map +1 -1
  83. package/dist/host/runner.d.ts +3 -6
  84. package/dist/host/runner.js +83 -32
  85. package/dist/host/runner.js.map +1 -1
  86. package/dist/render/graph-render.js +14 -8
  87. package/dist/render/graph-render.js.map +1 -1
  88. package/dist/state/database.d.ts +35 -0
  89. package/dist/state/database.js +287 -0
  90. package/dist/state/database.js.map +1 -0
  91. package/dist/state/index.d.ts +4 -0
  92. package/dist/state/index.js +5 -0
  93. package/dist/state/index.js.map +1 -0
  94. package/dist/state/json.d.ts +6 -0
  95. package/dist/state/json.js +38 -0
  96. package/dist/state/json.js.map +1 -0
  97. package/dist/state/mutation.d.ts +57 -0
  98. package/dist/state/mutation.js +255 -0
  99. package/dist/state/mutation.js.map +1 -0
  100. package/dist/state/schema.d.ts +6 -0
  101. package/dist/state/schema.js +473 -0
  102. package/dist/state/schema.js.map +1 -0
  103. package/dist/viewer/cli.d.ts +2 -2
  104. package/dist/viewer/cli.js +142 -122
  105. package/dist/viewer/cli.js.map +1 -1
  106. package/dist/viewer/render.d.ts +4 -4
  107. package/dist/viewer/render.js +2 -17
  108. package/dist/viewer/render.js.map +1 -1
  109. package/dist/viewer/session-reducer.d.ts +1 -1
  110. package/dist/viewer/session-reducer.js +1 -1
  111. package/dist/viewer/tui.d.ts +3 -3
  112. package/dist/viewer/tui.js +15 -13
  113. package/dist/viewer/tui.js.map +1 -1
  114. package/dist/viewer/watch.d.ts +2 -5
  115. package/dist/viewer/watch.js +13 -13
  116. package/dist/viewer/watch.js.map +1 -1
  117. package/dist/workflows/composition.js +12 -2
  118. package/dist/workflows/composition.js.map +1 -1
  119. package/dist/workflows/definition.d.ts +6 -2
  120. package/dist/workflows/definition.js +17 -0
  121. package/dist/workflows/definition.js.map +1 -1
  122. package/dist/workflows/engine.d.ts +6 -6
  123. package/dist/workflows/engine.js +183 -103
  124. package/dist/workflows/engine.js.map +1 -1
  125. package/dist/workflows/errors.d.ts +3 -3
  126. package/dist/workflows/errors.js +3 -3
  127. package/dist/workflows/human-decision.d.ts +29 -4
  128. package/dist/workflows/human-decision.js +547 -283
  129. package/dist/workflows/human-decision.js.map +1 -1
  130. package/dist/workflows/index.d.ts +4 -5
  131. package/dist/workflows/index.js +3 -4
  132. package/dist/workflows/index.js.map +1 -1
  133. package/dist/workflows/schema.js +17 -1
  134. package/dist/workflows/schema.js.map +1 -1
  135. package/dist/workflows/store.d.ts +83 -103
  136. package/dist/workflows/store.js +902 -951
  137. package/dist/workflows/store.js.map +1 -1
  138. package/dist/workflows/types.d.ts +55 -81
  139. package/docs/CONTROLLERS.md +10 -8
  140. package/docs/DEFERRED_TURNS.md +2 -2
  141. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  142. package/docs/HUMAN_DECISIONS.md +13 -14
  143. package/docs/MONITOR.md +179 -231
  144. package/docs/SQLITE_STATE.md +219 -0
  145. package/docs/WORKFLOW_COMPOSITION.md +27 -19
  146. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  147. package/docs/WORKFLOW_UPDATES.md +10 -9
  148. package/docs/development.md +9 -9
  149. package/docs/live-replay-protocol.md +28 -37
  150. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  151. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  152. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  153. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +1 -1
  154. package/docs/plans/2026-08-21-sanity-check-plan.md +30 -16
  155. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  156. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  157. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  158. package/docs/plans/2026-08-24-change-scoped-verification-plan.md +419 -0
  159. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  160. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  161. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  162. package/docs/session-event-journal.md +63 -434
  163. package/docs/tui-viewer.md +13 -15
  164. package/docs/workflows.md +111 -62
  165. package/examples/workflows/plain-summary.workflow.ts +1 -0
  166. package/herdr-plugin.toml +1 -1
  167. package/package.json +1 -1
  168. package/plugins/herdr/viewer.mjs +1 -11
  169. package/skills/autodoc/SKILL.md +7 -0
  170. package/skills/autoimplement/SKILL.md +4 -0
  171. package/skills/autoplan/SKILL.md +5 -4
  172. package/skills/monitor/SKILL.md +91 -163
  173. package/skills/pi-workflows/SKILL.md +7 -7
  174. package/skills/sanity-check/SKILL.md +1 -1
  175. package/src/builtins/autodoc.workflow.ts +184 -33
  176. package/src/builtins/autoimplement-command-batches.ts +39 -33
  177. package/src/builtins/autoimplement.workflow.ts +483 -175
  178. package/src/builtins/autoplan.workflow.ts +290 -40
  179. package/src/builtins/catalog.ts +7 -5
  180. package/src/builtins/change-verification.workflow.ts +1143 -0
  181. package/src/builtins/index.ts +7 -1
  182. package/src/builtins/monitor.workflow.ts +667 -250
  183. package/src/builtins/plain-summary.workflow.ts +185 -0
  184. package/src/builtins/plan-change.workflow.ts +35 -0
  185. package/src/builtins/sanity-check.workflow.ts +62 -7
  186. package/src/builtins/workspace-preparation.workflow.ts +668 -0
  187. package/src/controllers/effects.ts +7 -0
  188. package/src/controllers/index.ts +0 -6
  189. package/src/controllers/manager.ts +72 -32
  190. package/src/controllers/sqlite.ts +2420 -1948
  191. package/src/controllers/store.ts +17 -38
  192. package/src/controllers/types.ts +3 -0
  193. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  194. package/src/controllers/workflows.ts +45 -21
  195. package/src/extension/controller-host.ts +1 -2
  196. package/src/extension/decision-channels.ts +427 -144
  197. package/src/extension/executor.ts +106 -6
  198. package/src/extension/herdr-viewer.ts +0 -5
  199. package/src/extension/index.ts +180 -132
  200. package/src/extension/recorder.ts +13 -22
  201. package/src/extension/step-message.ts +106 -4
  202. package/src/extension/widget.ts +8 -0
  203. package/src/host/rpc-executor.ts +4 -0
  204. package/src/host/runner.ts +93 -41
  205. package/src/render/graph-render.ts +11 -4
  206. package/src/state/database.ts +375 -0
  207. package/src/state/index.ts +29 -0
  208. package/src/state/json.ts +42 -0
  209. package/src/state/mutation.ts +435 -0
  210. package/src/state/schema.ts +475 -0
  211. package/src/viewer/cli.ts +151 -144
  212. package/src/viewer/render.ts +12 -24
  213. package/src/viewer/session-reducer.ts +1 -1
  214. package/src/viewer/tui.ts +18 -17
  215. package/src/viewer/watch.ts +13 -17
  216. package/src/workflows/composition.ts +12 -3
  217. package/src/workflows/definition.ts +36 -3
  218. package/src/workflows/engine.ts +212 -116
  219. package/src/workflows/errors.ts +3 -3
  220. package/src/workflows/human-decision.ts +795 -340
  221. package/src/workflows/index.ts +15 -19
  222. package/src/workflows/schema.ts +19 -1
  223. package/src/workflows/store.ts +1295 -1163
  224. package/src/workflows/types.ts +61 -81
  225. package/dist/workflows/artifacts.d.ts +0 -40
  226. package/dist/workflows/artifacts.js +0 -155
  227. package/dist/workflows/artifacts.js.map +0 -1
  228. package/dist/workflows/migrate-sources.d.ts +0 -42
  229. package/dist/workflows/migrate-sources.js +0 -133
  230. package/dist/workflows/migrate-sources.js.map +0 -1
  231. package/docs/run-bundles.md +0 -481
  232. package/src/workflows/artifacts.ts +0 -188
  233. package/src/workflows/migrate-sources.ts +0 -178
@@ -1,6 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import { resolveArtifacts } from "./artifacts.js";
4
3
  import {
5
4
  compileWorkflowDefinition,
6
5
  compositionMetadata,
@@ -29,12 +28,14 @@ import {
29
28
  WorkflowRunStore,
30
29
  createDefinitionSnapshot,
31
30
  createRunId,
32
- readRunBundle,
33
31
  } from "./store.js";
34
32
  import type {
35
33
  ResolvedHumanDecision,
34
+ AgentExpectedOutput,
36
35
  AgentNodeDefinition,
37
36
  AgentStepExecutor,
37
+ AssistantMessageOutput,
38
+ AssistantMessageReceipt,
38
39
  ActionNodeDefinition,
39
40
  CheckpointNodeDefinition,
40
41
  ConversationRange,
@@ -71,6 +72,7 @@ type NodeExecution = {
71
72
  output: unknown;
72
73
  promptText: string | null;
73
74
  action?: WorkflowActionReceipt;
75
+ assistantMessage?: AssistantMessageReceipt;
74
76
  conversation?: ConversationRange;
75
77
  };
76
78
 
@@ -89,11 +91,17 @@ type NodeAttempt = {
89
91
  error?: unknown;
90
92
  };
91
93
 
94
+ type ResumedNodeAttempt = {
95
+ nodeId: string;
96
+ attemptId: string;
97
+ startedAt: string;
98
+ };
99
+
92
100
  /**
93
101
  * Executes a workflow graph step by step. Agent steps are delegated to the
94
102
  * configured executor; compute/action/checkpoint nodes run inline. Every
95
- * state transition is persisted to the run bundle before the engine moves on,
96
- * so a live viewer can follow along by watching the bundle directory.
103
+ * state transition is persisted to the SQLite run state before the engine moves on,
104
+ * so a live viewer can follow committed SQLite events.
97
105
  */
98
106
  export class WorkflowEngine {
99
107
  private readonly executor: AgentStepExecutor;
@@ -107,7 +115,7 @@ export class WorkflowEngine {
107
115
  private activeAbort: AbortController | null = null;
108
116
  private activeAttempt:
109
117
  | {
110
- runDir: string;
118
+ runId: string;
111
119
  state: WorkflowRunState;
112
120
  nodeId: string;
113
121
  attemptId: string;
@@ -124,7 +132,7 @@ export class WorkflowEngine {
124
132
  constructor(options: WorkflowEngineOptions) {
125
133
  this.executor = options.executor;
126
134
  this.notificationSink = options.notificationSink;
127
- this.store = options.store ?? new WorkflowRunStore(options.outputRoot);
135
+ this.store = options.store ?? new WorkflowRunStore(options.databasePath);
128
136
  this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
129
137
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
130
138
  this.onEvent = options.onEvent;
@@ -132,8 +140,8 @@ export class WorkflowEngine {
132
140
  this.onRunFinishing = options.onRunFinishing;
133
141
  }
134
142
 
135
- get outputRoot(): string {
136
- return this.store.outputRoot;
143
+ get databasePath(): string {
144
+ return this.store.databasePath;
137
145
  }
138
146
 
139
147
  /** Publish a durable update for the currently active attempt without completing it. */
@@ -171,7 +179,7 @@ export class WorkflowEngine {
171
179
  }
172
180
  limiter.take();
173
181
  const { event, record } = await this.store.publishUpdate(
174
- active.runDir,
182
+ active.runId,
175
183
  active.state,
176
184
  active.nodeId,
177
185
  active.attemptId,
@@ -235,8 +243,7 @@ export class WorkflowEngine {
235
243
  ): Promise<WorkflowRunResult> {
236
244
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
237
245
  validateWorkflowDefinition(workflow);
238
- // Fail before any bundle exists so bad input cannot leave a partial run
239
- // on disk or silently change shape when state.json round-trips.
246
+ // Fail before any run row exists so bad input cannot leave partial state.
240
247
  const suppliedInput = input === undefined ? null : input;
241
248
  const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
242
249
  assertJsonSerializable(normalizedInput, "Workflow run input");
@@ -253,8 +260,8 @@ export class WorkflowEngine {
253
260
  options.workflowSource,
254
261
  options.runId,
255
262
  );
256
- const runDir = await this.store.initializeRunBundle(workflow, state);
257
- await this.persist(runDir, state, {
263
+ const runId = await this.store.initializeRun(workflow, state);
264
+ await this.persist(runId, state, {
258
265
  scope: "run",
259
266
  type: "run_started",
260
267
  payload: {
@@ -266,18 +273,18 @@ export class WorkflowEngine {
266
273
  // Awaited so anything the hook writes (e.g. a session binding and its
267
274
  // `session_bound` event) lands before node events and can never trail
268
275
  // the terminal event of a fast run.
269
- await this.onRunStarted?.(runDir, state);
276
+ await this.onRunStarted?.(runId, state);
270
277
 
271
278
  try {
272
- await this.executeGraph(workflow, state, runDir);
279
+ await this.executeGraph(workflow, state, runId);
273
280
  } catch (error) {
274
281
  if (isRunParkedError(error) || this.parked) {
275
- return { runDir, state };
282
+ return { runId, state };
276
283
  }
277
- await this.finishAfterError(runDir, state, error);
278
- return { runDir, state };
284
+ await this.finishAfterError(runId, state, error);
285
+ return { runId, state };
279
286
  }
280
- return { runDir, state };
287
+ return { runId, state };
281
288
  }
282
289
 
283
290
  /**
@@ -297,76 +304,94 @@ export class WorkflowEngine {
297
304
  this.cancelled = false;
298
305
  this.paused = false;
299
306
  this.parked = false;
300
- const bundle = await this.store.prepareRunResume(runId);
301
- const { runDir } = bundle;
302
- const state = bundle.state;
307
+ const loaded = await this.store.prepareRunResume(runId);
308
+ const state = loaded.state;
303
309
  const sourceMismatch = workflowIdentityMismatch(state, workflow, options.workflowSource);
304
310
  if (sourceMismatch && options.force !== true) {
305
311
  throw new WorkflowSourceChangedError(runId);
306
312
  }
307
313
 
308
314
  const point = this.resumePointFor(workflow, state, "wait");
309
- // A resumed run starts unpaused; the operator can pause again. The
310
- // interrupted node's stale in-flight markers go away before the resume
311
- // event so the projection matches what the engine is about to do.
315
+ const interruptedNode =
316
+ state.currentNode !== undefined ? workflow.nodes[state.currentNode] : undefined;
317
+ const resumedAttempt: ResumedNodeAttempt | undefined =
318
+ state.currentNode !== undefined &&
319
+ state.currentAttemptId !== undefined &&
320
+ state.currentNodeStartedAt !== undefined &&
321
+ interruptedNode?.nodeType === "agent" &&
322
+ assistantMessageConfig(interruptedNode) !== undefined
323
+ ? {
324
+ nodeId: state.currentNode,
325
+ attemptId: state.currentAttemptId,
326
+ startedAt: state.currentNodeStartedAt,
327
+ }
328
+ : undefined;
329
+ // A resumed run starts unpaused. Submitted and non-agent nodes discard
330
+ // stale in-flight markers and start a new attempt. Assistant-message
331
+ // nodes keep their attempt id so the origin session can adopt an already
332
+ // visible response without showing it twice.
312
333
  delete state.paused;
313
- delete state.currentNode;
314
- delete state.currentAttemptId;
315
- delete state.currentNodeStartedAt;
316
- delete state.statusDetail;
317
- await this.persist(runDir, state, {
334
+ if (resumedAttempt === undefined) {
335
+ delete state.currentNode;
336
+ delete state.currentAttemptId;
337
+ delete state.currentNodeStartedAt;
338
+ delete state.statusDetail;
339
+ }
340
+ await this.persist(runId, state, {
318
341
  scope: "run",
319
342
  type: "run_resumed",
320
343
  payload: {
321
344
  ...(point.nodeId !== null ? { resumeAt: point.nodeId } : {}),
345
+ ...(resumedAttempt !== undefined ? { resumedAttemptId: resumedAttempt.attemptId } : {}),
322
346
  replayedSteps: state.steps.length,
323
347
  ...(sourceMismatch ? { workflowSourceMismatch: true, forced: true } : {}),
324
348
  },
325
349
  });
326
- await this.onRunStarted?.(runDir, state);
350
+ await this.onRunStarted?.(runId, state);
327
351
 
328
352
  if (point.nodeId === null) {
329
353
  // The last recorded transition already finished the graph; the crash
330
354
  // happened before the terminal event was written. A finished
331
355
  // checkpoint restores its waiting gate rather than completing.
332
356
  if (point.waitingOn !== undefined) {
333
- await this.finishRun(runDir, state, "waiting", {
357
+ await this.finishRun(runId, state, "waiting", {
334
358
  waitingOn: point.waitingOn,
335
359
  finalOutput: point.lastOutput,
336
360
  });
337
361
  } else if (point.failedResult === undefined) {
338
- await this.finishRun(runDir, state, "completed", { finalOutput: point.lastOutput });
362
+ await this.finishRun(runId, state, "completed", { finalOutput: point.lastOutput });
339
363
  } else {
340
364
  const timedOut = point.failedResult.outcome === "timed_out";
341
- await this.finishRun(runDir, state, timedOut ? "timed_out" : "failed", {
365
+ await this.finishRun(runId, state, timedOut ? "timed_out" : "failed", {
342
366
  error: point.failedResult.error ?? `Workflow node failed: ${point.failedResult.nodeId}`,
343
367
  });
344
368
  }
345
- return { runDir, state };
369
+ return { runId, state };
346
370
  }
347
371
 
348
372
  try {
349
373
  await this.executeGraph(
350
374
  workflow,
351
375
  state,
352
- runDir,
376
+ runId,
353
377
  point.nodeId,
354
378
  countExecutableSteps(workflow, state.steps),
355
379
  point.lastOutput,
380
+ resumedAttempt,
356
381
  );
357
382
  } catch (error) {
358
383
  if (isRunParkedError(error) || this.parked) {
359
- return { runDir, state };
384
+ return { runId, state };
360
385
  }
361
- await this.finishAfterError(runDir, state, error);
362
- return { runDir, state };
386
+ await this.finishAfterError(runId, state, error);
387
+ return { runId, state };
363
388
  }
364
- return { runDir, state };
389
+ return { runId, state };
365
390
  }
366
391
 
367
392
  /**
368
393
  * Start a continuation run from a checkpointed parent. The new run gets a
369
- * fresh bundle and trace, carries forward the parent's outputs, results,
394
+ * fresh run and event stream, carries forward the parent's outputs, results,
370
395
  * and step accounting, and continues routing after the checkpoint.
371
396
  */
372
397
  async continueRun(
@@ -385,7 +410,7 @@ export class WorkflowEngine {
385
410
  this.cancelled = false;
386
411
  this.paused = false;
387
412
  this.parked = false;
388
- const parent = await readRunBundle(this.store.runDirFor(parentRunId));
413
+ const parent = this.store.readRun(parentRunId);
389
414
  if (parent === null) {
390
415
  throw new Error(`Cannot continue from unreadable workflow run: ${parentRunId}`);
391
416
  }
@@ -418,15 +443,15 @@ export class WorkflowEngine {
418
443
  ) {
419
444
  throw new Error("Accepted human decision does not match the waiting request");
420
445
  }
421
- const durableDecision = await new HumanDecisionStore(this.store.outputRoot).readResolved(
422
- request.decisionId,
423
- );
446
+ const durableDecision = await new HumanDecisionStore(this.store.databasePath, {
447
+ state: this.store.state,
448
+ }).readResolved(request.decisionId);
424
449
  if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
425
450
  throw new Error("Accepted human decision does not match the durable decision record");
426
451
  }
427
452
  acceptedResponse = validateHumanDecisionResponse(request, durableDecision.response);
428
453
  acceptedNodeId = request.nodeId;
429
- normalizedInput = await resolveArtifacts(parent.state.input, parent.runDir);
454
+ normalizedInput = structuredClone(parent.state.input);
430
455
  } else {
431
456
  const suppliedInput = input === undefined ? null : input;
432
457
  normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
@@ -443,20 +468,9 @@ export class WorkflowEngine {
443
468
  options.runId,
444
469
  );
445
470
  state.parentRunId = parentRunId;
446
- // Artifact references point into the parent's bundle, so carried values
447
- // are fully resolved here and re-externalized into the new bundle.
448
- state.outputs = (await resolveArtifacts(
449
- parent.state.outputs,
450
- parent.runDir,
451
- )) as WorkflowRunState["outputs"];
452
- state.results = (await resolveArtifacts(
453
- parent.state.results,
454
- parent.runDir,
455
- )) as WorkflowRunState["results"];
456
- state.steps = (await resolveArtifacts(
457
- parent.state.steps,
458
- parent.runDir,
459
- )) as WorkflowRunState["steps"];
471
+ state.outputs = structuredClone(parent.state.outputs);
472
+ state.results = structuredClone(parent.state.results);
473
+ state.steps = structuredClone(parent.state.steps);
460
474
  if (humanContract !== undefined && options.humanDecision !== undefined) {
461
475
  const receipt = {
462
476
  decisionId: options.humanDecision.decisionId,
@@ -490,8 +504,8 @@ export class WorkflowEngine {
490
504
  }
491
505
  state.carriedStepCount = state.steps.length;
492
506
 
493
- const runDir = await this.store.initializeRunBundle(workflow, state);
494
- await this.persist(runDir, state, {
507
+ const runId = await this.store.initializeRun(workflow, state);
508
+ await this.persist(runId, state, {
495
509
  scope: "run",
496
510
  type: "run_started",
497
511
  payload: {
@@ -503,31 +517,31 @@ export class WorkflowEngine {
503
517
  carriedSteps: state.steps.length,
504
518
  },
505
519
  });
506
- await this.onRunStarted?.(runDir, state);
520
+ await this.onRunStarted?.(runId, state);
507
521
 
508
522
  const point = this.resumePointFor(workflow, state, "continue");
509
523
  if (point.nodeId === null) {
510
524
  // The checkpoint was the final node; the answer completes the chain.
511
- await this.finishRun(runDir, state, "completed", { finalOutput: point.lastOutput });
512
- return { runDir, state };
525
+ await this.finishRun(runId, state, "completed", { finalOutput: point.lastOutput });
526
+ return { runId, state };
513
527
  }
514
528
  try {
515
529
  await this.executeGraph(
516
530
  workflow,
517
531
  state,
518
- runDir,
532
+ runId,
519
533
  point.nodeId,
520
534
  countExecutableSteps(workflow, state.steps),
521
535
  point.lastOutput,
522
536
  );
523
537
  } catch (error) {
524
538
  if (isRunParkedError(error) || this.parked) {
525
- return { runDir, state };
539
+ return { runId, state };
526
540
  }
527
- await this.finishAfterError(runDir, state, error);
528
- return { runDir, state };
541
+ await this.finishAfterError(runId, state, error);
542
+ return { runId, state };
529
543
  }
530
- return { runDir, state };
544
+ return { runId, state };
531
545
  }
532
546
 
533
547
  /**
@@ -583,17 +597,17 @@ export class WorkflowEngine {
583
597
  }
584
598
 
585
599
  private async finishAfterError(
586
- runDir: string,
600
+ runId: string,
587
601
  state: WorkflowRunState,
588
602
  error: unknown,
589
603
  ): Promise<void> {
590
604
  const cancelled = this.cancelled || isAbortLikeError(error);
591
605
  try {
592
- await this.finishRun(runDir, state, cancelled ? "cancelled" : "failed", {
606
+ await this.finishRun(runId, state, cancelled ? "cancelled" : "failed", {
593
607
  error: errorMessage(error),
594
608
  });
595
609
  } catch (finishError) {
596
- // A fenced-out runner must not touch the bundle, including terminal
610
+ // A fenced-out runner must not touch run state, including terminal
597
611
  // projections. Propagate the claim loss instead of the node error.
598
612
  if (isClaimLostError(finishError)) {
599
613
  throw finishError;
@@ -661,10 +675,11 @@ export class WorkflowEngine {
661
675
  private async executeGraph(
662
676
  workflow: WorkflowDefinition,
663
677
  state: WorkflowRunState,
664
- runDir: string,
678
+ runId: string,
665
679
  startNodeId: string | null = workflow.startAt,
666
680
  executedStepsBase = 0,
667
681
  initialLastOutput?: unknown,
682
+ resumedAttempt?: ResumedNodeAttempt,
668
683
  ): Promise<void> {
669
684
  const maxSteps = workflow.maxSteps ?? this.maxSteps;
670
685
  const composition = compositionMetadata(workflow);
@@ -673,7 +688,7 @@ export class WorkflowEngine {
673
688
  let lastOutput: unknown = initialLastOutput;
674
689
 
675
690
  while (currentNodeId !== null) {
676
- await this.holdWhilePaused(state, runDir);
691
+ await this.holdWhilePaused(state, runId);
677
692
  const isTransition =
678
693
  composition?.entries[currentNodeId] !== undefined ||
679
694
  composition?.exits[currentNodeId] !== undefined;
@@ -692,7 +707,16 @@ export class WorkflowEngine {
692
707
  throw new Error(`Workflow node is missing: ${currentNodeId}`);
693
708
  }
694
709
 
695
- const attempt = await this.executeNode(workflow, state, runDir, currentNodeId, node);
710
+ const activeResume = resumedAttempt?.nodeId === currentNodeId ? resumedAttempt : undefined;
711
+ resumedAttempt = undefined;
712
+ const attempt = await this.executeNode(
713
+ workflow,
714
+ state,
715
+ runId,
716
+ currentNodeId,
717
+ node,
718
+ activeResume,
719
+ );
696
720
  if (this.parked) {
697
721
  // Do not record the aborted attempt: the projection keeps the node
698
722
  // as in-flight, and resume reruns it with a fresh attempt.
@@ -701,7 +725,7 @@ export class WorkflowEngine {
701
725
  this.recordAttempt(workflow, state, attempt);
702
726
  // The terminal node event carries the output, receipt, and conversation
703
727
  // linkage so the trace alone is sufficient to reconstruct the run.
704
- await this.persist(runDir, state, {
728
+ await this.persist(runId, state, {
705
729
  scope: "node",
706
730
  type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
707
731
  nodeId: attempt.result.nodeId,
@@ -712,6 +736,9 @@ export class WorkflowEngine {
712
736
  ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
713
737
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
714
738
  ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
739
+ ...(attempt.execution?.assistantMessage !== undefined
740
+ ? { assistantMessage: attempt.execution.assistantMessage }
741
+ : {}),
715
742
  ...(attempt.execution?.conversation !== undefined
716
743
  ? { conversation: attempt.execution.conversation }
717
744
  : {}),
@@ -726,7 +753,7 @@ export class WorkflowEngine {
726
753
  const entered = composition?.entries[attempt.result.nodeId];
727
754
  if (entered !== undefined) {
728
755
  const value = attempt.result.output as { invocation?: number } | undefined;
729
- await this.persist(runDir, state, {
756
+ await this.persist(runId, state, {
730
757
  scope: "run",
731
758
  type: "include_entered",
732
759
  payload: {
@@ -739,7 +766,7 @@ export class WorkflowEngine {
739
766
  const exited = composition?.exits[attempt.result.nodeId];
740
767
  if (exited !== undefined) {
741
768
  const entrySteps = state.steps.filter((step) => step.nodeId === exited.mountPath);
742
- await this.persist(runDir, state, {
769
+ await this.persist(runId, state, {
743
770
  scope: "run",
744
771
  type: "include_exited",
745
772
  payload: {
@@ -754,7 +781,7 @@ export class WorkflowEngine {
754
781
 
755
782
  lastOutput = attempt.result.output;
756
783
  if (node.nodeType === "checkpoint") {
757
- await this.finishRun(runDir, state, "waiting", {
784
+ await this.finishRun(runId, state, "waiting", {
758
785
  waitingOn: attempt.result.nodeId,
759
786
  finalOutput: lastOutput,
760
787
  });
@@ -768,14 +795,14 @@ export class WorkflowEngine {
768
795
  );
769
796
  }
770
797
 
771
- await this.finishRun(runDir, state, "completed", { finalOutput: lastOutput });
798
+ await this.finishRun(runId, state, "completed", { finalOutput: lastOutput });
772
799
  }
773
800
 
774
801
  /**
775
802
  * Hold the run at the step boundary while a pause is in effect. Pausing
776
803
  * never interrupts a node mid-flight; it only delays the next dispatch.
777
804
  */
778
- private async holdWhilePaused(state: WorkflowRunState, runDir: string): Promise<void> {
805
+ private async holdWhilePaused(state: WorkflowRunState, runId: string): Promise<void> {
779
806
  if (this.parked) {
780
807
  throw new RunParkedError();
781
808
  }
@@ -786,7 +813,7 @@ export class WorkflowEngine {
786
813
  return;
787
814
  }
788
815
  state.paused = true;
789
- await this.persist(runDir, state, { scope: "run", type: "run_paused", payload: {} });
816
+ await this.persist(runId, state, { scope: "run", type: "run_paused", payload: {} });
790
817
  while (this.paused && !this.cancelled && !this.parked) {
791
818
  await new Promise<void>((resolve) => {
792
819
  this.wakePause = resolve;
@@ -800,7 +827,7 @@ export class WorkflowEngine {
800
827
  if (this.cancelled) {
801
828
  throw new CancelledError();
802
829
  }
803
- await this.persist(runDir, state, { scope: "run", type: "run_resumed", payload: {} });
830
+ await this.persist(runId, state, { scope: "run", type: "run_resumed", payload: {} });
804
831
  }
805
832
 
806
833
  private routeAfterFailure(
@@ -853,6 +880,9 @@ export class WorkflowEngine {
853
880
  output: attempt.result.output ?? null,
854
881
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
855
882
  ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
883
+ ...(attempt.execution?.assistantMessage !== undefined
884
+ ? { assistantMessage: attempt.execution.assistantMessage }
885
+ : {}),
856
886
  ...(attempt.execution?.conversation !== undefined
857
887
  ? { conversation: attempt.execution.conversation }
858
888
  : {}),
@@ -867,32 +897,35 @@ export class WorkflowEngine {
867
897
  private async executeNode(
868
898
  workflow: WorkflowDefinition,
869
899
  state: WorkflowRunState,
870
- runDir: string,
900
+ runId: string,
871
901
  nodeId: string,
872
902
  node: WorkflowNodeDefinition,
903
+ resumedAttempt?: ResumedNodeAttempt,
873
904
  ): Promise<NodeAttempt> {
874
- const attemptId = randomUUID();
875
- const startedAt = new Date().toISOString();
905
+ const attemptId = resumedAttempt?.attemptId ?? randomUUID();
906
+ const startedAt = resumedAttempt?.startedAt ?? new Date().toISOString();
876
907
  state.currentNode = nodeId;
877
908
  state.currentAttemptId = attemptId;
878
909
  state.currentNodeStartedAt = startedAt;
879
910
  if (node.statusDetail !== undefined) {
880
911
  state.statusDetail = node.statusDetail;
881
912
  }
882
- await this.persist(runDir, state, {
883
- scope: "node",
884
- type: "node_started",
885
- nodeId,
886
- attemptId,
887
- payload: { nodeType: node.nodeType },
888
- });
913
+ if (resumedAttempt === undefined) {
914
+ await this.persist(runId, state, {
915
+ scope: "node",
916
+ type: "node_started",
917
+ nodeId,
918
+ attemptId,
919
+ payload: { nodeType: node.nodeType },
920
+ });
921
+ }
889
922
 
890
923
  const meta: NodeExecutionMeta = { promptText: null };
891
924
  try {
892
925
  const execution = await this.runNodeWithTimeout(
893
926
  workflow,
894
927
  state,
895
- runDir,
928
+ runId,
896
929
  nodeId,
897
930
  attemptId,
898
931
  node,
@@ -955,7 +988,7 @@ export class WorkflowEngine {
955
988
  private async runNodeWithTimeout(
956
989
  workflow: WorkflowDefinition,
957
990
  state: WorkflowRunState,
958
- runDir: string,
991
+ runId: string,
959
992
  nodeId: string,
960
993
  attemptId: string,
961
994
  node: WorkflowNodeDefinition,
@@ -985,11 +1018,11 @@ export class WorkflowEngine {
985
1018
  abort.abort(new TimeoutError(timeoutMs));
986
1019
  }, timeoutMs);
987
1020
  }
988
- this.activeAttempt = { runDir, state, nodeId, attemptId, signal: abort.signal };
1021
+ this.activeAttempt = { runId, state, nodeId, attemptId, signal: abort.signal };
989
1022
  const dispatched = this.dispatchNode(
990
1023
  workflow,
991
1024
  state,
992
- runDir,
1025
+ runId,
993
1026
  nodeId,
994
1027
  attemptId,
995
1028
  node,
@@ -1006,7 +1039,7 @@ export class WorkflowEngine {
1006
1039
  const execution = await Promise.race([dispatched, abortRejection(abort.signal)]);
1007
1040
  if (execution.output === undefined) {
1008
1041
  // JSON cannot represent undefined; normalize so the in-memory state
1009
- // matches what the persisted bundle round-trips to.
1042
+ // matches what persisted canonical JSON round-trips to.
1010
1043
  execution.output = null;
1011
1044
  }
1012
1045
  assertJsonSerializable(execution.output, `Node ${nodeId} output`);
@@ -1070,7 +1103,7 @@ export class WorkflowEngine {
1070
1103
  private async dispatchNode(
1071
1104
  workflow: WorkflowDefinition,
1072
1105
  state: WorkflowRunState,
1073
- runDir: string,
1106
+ runId: string,
1074
1107
  nodeId: string,
1075
1108
  attemptId: string,
1076
1109
  node: WorkflowNodeDefinition,
@@ -1083,7 +1116,7 @@ export class WorkflowEngine {
1083
1116
  return await this.runAgentNode(
1084
1117
  workflow,
1085
1118
  state,
1086
- runDir,
1119
+ runId,
1087
1120
  nodeId,
1088
1121
  attemptId,
1089
1122
  node,
@@ -1141,7 +1174,7 @@ export class WorkflowEngine {
1141
1174
  private async runAgentNode(
1142
1175
  workflow: WorkflowDefinition,
1143
1176
  state: WorkflowRunState,
1144
- runDir: string,
1177
+ runId: string,
1145
1178
  nodeId: string,
1146
1179
  attemptId: string,
1147
1180
  node: AgentNodeDefinition,
@@ -1149,10 +1182,29 @@ export class WorkflowEngine {
1149
1182
  signal: AbortSignal,
1150
1183
  meta: NodeExecutionMeta,
1151
1184
  ): Promise<NodeExecution> {
1185
+ const assistant = assistantMessageConfig(node);
1186
+ if (assistant !== undefined) {
1187
+ if (this.executor.assistantMessageMode === "park") {
1188
+ state.statusDetail = "waiting for origin Pi session";
1189
+ await this.persist(runId, state, {
1190
+ scope: "agent",
1191
+ type: "agent_session_required",
1192
+ nodeId,
1193
+ attemptId,
1194
+ payload: { completion: "assistant" },
1195
+ });
1196
+ this.parked = true;
1197
+ throw new RunParkedError();
1198
+ }
1199
+ if (this.executor.assistantMessageMode !== "visible") {
1200
+ throw new Error("Assistant completion requires an origin Pi session");
1201
+ }
1202
+ }
1203
+
1152
1204
  const basePrompt = await node.prompt(context);
1153
1205
  if (signal.aborted) {
1154
1206
  // The node timed out or the run was cancelled while the async prompt
1155
- // builder ran; a late continuation must not write into a bundle that
1207
+ // builder ran; a late continuation must not write into a run that
1156
1208
  // may already be terminal.
1157
1209
  throw abortError(signal);
1158
1210
  }
@@ -1164,12 +1216,12 @@ export class WorkflowEngine {
1164
1216
  node.expectedOutput,
1165
1217
  );
1166
1218
  meta.promptText = prompt;
1167
- await this.persist(runDir, state, {
1219
+ await this.persist(runId, state, {
1168
1220
  scope: "agent",
1169
1221
  type: "agent_prompt_sent",
1170
1222
  nodeId,
1171
1223
  attemptId,
1172
- payload: { prompt },
1224
+ payload: { prompt, completion: assistant === undefined ? "submit" : "assistant" },
1173
1225
  });
1174
1226
 
1175
1227
  const submission = await this.executor.runAgentStep(
@@ -1179,7 +1231,11 @@ export class WorkflowEngine {
1179
1231
  workflowName: workflow.name,
1180
1232
  nodeId,
1181
1233
  attemptId,
1182
- ...(node.expectedOutput !== undefined ? { expectedOutput: node.expectedOutput } : {}),
1234
+ completion: assistant === undefined ? "submit" : "assistant",
1235
+ ...(typeof node.expectedOutput === "string"
1236
+ ? { expectedOutput: node.expectedOutput }
1237
+ : {}),
1238
+ ...(assistant?.maxChars !== undefined ? { maxOutputChars: assistant.maxChars } : {}),
1183
1239
  },
1184
1240
  prompt,
1185
1241
  ...(state.runTitle !== undefined || node.statusDetail !== undefined
@@ -1199,6 +1255,9 @@ export class WorkflowEngine {
1199
1255
  return {
1200
1256
  output: submission.output,
1201
1257
  promptText: prompt,
1258
+ ...(submission.assistantMessage !== undefined
1259
+ ? { assistantMessage: submission.assistantMessage }
1260
+ : {}),
1202
1261
  ...(submission.conversation !== undefined ? { conversation: submission.conversation } : {}),
1203
1262
  };
1204
1263
  }
@@ -1208,6 +1267,13 @@ export class WorkflowEngine {
1208
1267
  context: WorkflowNodeContext,
1209
1268
  output: unknown,
1210
1269
  ): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
1270
+ if (assistantMessageConfig(node) !== undefined) {
1271
+ return {
1272
+ ok: false,
1273
+ error:
1274
+ "This step completes with a normal assistant response. Do not submit workflow output.",
1275
+ };
1276
+ }
1211
1277
  try {
1212
1278
  const normalized = normalizeAgentOutput(output);
1213
1279
  const validated = node.validate ? await node.validate(normalized, context) : normalized;
@@ -1242,11 +1308,11 @@ export class WorkflowEngine {
1242
1308
  }
1243
1309
 
1244
1310
  private async persist(
1245
- runDir: string,
1311
+ runId: string,
1246
1312
  state: WorkflowRunState,
1247
1313
  event: WorkflowTraceEventDraft,
1248
1314
  ): Promise<void> {
1249
- const traceEvent = await this.store.writeSnapshot(runDir, state, event);
1315
+ const traceEvent = await this.store.writeSnapshot(runId, state, event);
1250
1316
  try {
1251
1317
  this.onEvent?.(traceEvent, state);
1252
1318
  } catch {
@@ -1256,7 +1322,7 @@ export class WorkflowEngine {
1256
1322
  }
1257
1323
 
1258
1324
  private async finishRun(
1259
- runDir: string,
1325
+ runId: string,
1260
1326
  state: WorkflowRunState,
1261
1327
  status: WorkflowRunState["status"],
1262
1328
  fields: { error?: string; waitingOn?: string; finalOutput?: unknown },
@@ -1265,9 +1331,9 @@ export class WorkflowEngine {
1265
1331
  status = "timed_out";
1266
1332
  }
1267
1333
  // Let observers (e.g. the session recorder) stop and drain before the
1268
- // terminal event exists, so the bundle is immutable from that point on.
1334
+ // terminal event exists, so the terminal fact is immutable from that point on.
1269
1335
  try {
1270
- await this.onRunFinishing?.(runDir, state);
1336
+ await this.onRunFinishing?.(runId, state);
1271
1337
  } catch {
1272
1338
  // Finishing the run wins over observer failures.
1273
1339
  }
@@ -1285,7 +1351,7 @@ export class WorkflowEngine {
1285
1351
  delete state.currentNode;
1286
1352
  delete state.currentAttemptId;
1287
1353
  delete state.currentNodeStartedAt;
1288
- await this.persist(runDir, state, {
1354
+ await this.persist(runId, state, {
1289
1355
  scope: "run",
1290
1356
  type: `run_${status}`,
1291
1357
  payload: {
@@ -1327,7 +1393,9 @@ async function runCheckpointNode(
1327
1393
  prompt,
1328
1394
  ...(timeout !== undefined ? { timeout } : {}),
1329
1395
  });
1330
- await new HumanDecisionStore(execution.store.outputRoot).createRequest(request);
1396
+ await new HumanDecisionStore(execution.store.databasePath, {
1397
+ state: execution.store.state,
1398
+ }).createRequest(request);
1331
1399
  return { output: request, promptText: null };
1332
1400
  }
1333
1401
  const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
@@ -1458,7 +1526,7 @@ function assertInvocationStepLimit(
1458
1526
  }
1459
1527
 
1460
1528
  /**
1461
- * Outputs are persisted to the run bundle, so they must be JSON-serializable.
1529
+ * Outputs are persisted to the SQLite run state, so they must be JSON-serializable.
1462
1530
  * Failing here turns a bad callback return value into a normal node failure
1463
1531
  * instead of corrupting the run state.
1464
1532
  */
@@ -1527,6 +1595,18 @@ function normalizeAgentOutput(output: unknown): unknown {
1527
1595
  }
1528
1596
  }
1529
1597
 
1598
+ function assistantMessageOutput(
1599
+ expectedOutput: AgentExpectedOutput | undefined,
1600
+ ): AssistantMessageOutput | undefined {
1601
+ return typeof expectedOutput === "object" && expectedOutput?.kind === "assistant-message"
1602
+ ? expectedOutput
1603
+ : undefined;
1604
+ }
1605
+
1606
+ function assistantMessageConfig(node: AgentNodeDefinition): AssistantMessageOutput | undefined {
1607
+ return assistantMessageOutput(node.expectedOutput);
1608
+ }
1609
+
1530
1610
  /**
1531
1611
  * The step contract appended to every agent-node prompt. This is the
1532
1612
  * documented standard for how the model completes a workflow step.
@@ -1536,8 +1616,24 @@ export function appendStepContract(
1536
1616
  workflowName: string,
1537
1617
  nodeId: string,
1538
1618
  attemptId: string,
1539
- expectedOutput: string | undefined,
1619
+ expectedOutput: AgentExpectedOutput | undefined,
1540
1620
  ): string {
1621
+ const assistant = assistantMessageOutput(expectedOutput);
1622
+ if (assistant !== undefined) {
1623
+ return [
1624
+ prompt.trimEnd(),
1625
+ "",
1626
+ "---",
1627
+ `Workflow step contract (workflow: ${workflowName}, step: ${nodeId}, attempt: ${attemptId})`,
1628
+ "",
1629
+ "Reply with a normal assistant message.",
1630
+ "Do not call the workflow tool to complete this step.",
1631
+ "Your visible reply becomes the workflow step output after the turn settles.",
1632
+ ...(assistant.maxChars !== undefined
1633
+ ? [`Keep the visible reply within ${assistant.maxChars} characters.`]
1634
+ : []),
1635
+ ].join("\n");
1636
+ }
1541
1637
  return [
1542
1638
  prompt.trimEnd(),
1543
1639
  "",
@@ -1548,7 +1644,7 @@ export function appendStepContract(
1548
1644
  `{"action": "update", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "update": {"type": "...", "key": "...", "data": {...}}}`,
1549
1645
  "Complete this step by calling the `workflow` tool exactly once with:",
1550
1646
  `{"action": "submit", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
1551
- `Expected output: ${expectedOutput ?? "a JSON object with your result"}`,
1647
+ `Expected output: ${typeof expectedOutput === "string" ? expectedOutput : "a JSON object with your result"}`,
1552
1648
  "The step is complete only after the workflow tool accepts the output.",
1553
1649
  "If the tool reports a validation error, correct the output and call it again.",
1554
1650
  ].join("\n");