stageflow 0.18.0 → 0.19.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 (41) hide show
  1. package/dist/agent/activity.d.ts +4 -0
  2. package/dist/agent/claudeAdapter.js +38 -0
  3. package/dist/agent/fakeAgent.js +3 -1
  4. package/dist/agent/piAdapter.js +33 -1
  5. package/dist/agent/port.d.ts +1 -1
  6. package/dist/agent/stageTimeout.d.ts +9 -0
  7. package/dist/agent/stageTimeout.js +24 -0
  8. package/dist/cli/graphCommand.d.ts +15 -0
  9. package/dist/cli/graphCommand.js +79 -0
  10. package/dist/cli/graphRender.d.ts +2 -0
  11. package/dist/cli/graphRender.js +85 -0
  12. package/dist/cli/runsCommand.d.ts +1 -1
  13. package/dist/cli/runsCommand.js +37 -0
  14. package/dist/cli.js +12 -0
  15. package/dist/mcp/controlTools.js +17 -0
  16. package/dist/package-meta.d.ts +1 -1
  17. package/dist/package-meta.js +1 -1
  18. package/dist/runstore/port.js +2 -0
  19. package/dist/runtime/forkGeneration.d.ts +4 -0
  20. package/dist/runtime/forkGeneration.js +16 -0
  21. package/dist/runtime/pipelineScheduler.js +23 -1
  22. package/dist/runtime/reopenSkipped.d.ts +13 -0
  23. package/dist/runtime/reopenSkipped.js +56 -0
  24. package/dist/runtime/resumeTimedOut.d.ts +33 -0
  25. package/dist/runtime/resumeTimedOut.js +165 -0
  26. package/dist/runtime/runManager.d.ts +1 -0
  27. package/dist/runtime/runManager.js +76 -0
  28. package/dist/runtime/stageRecovery.js +2 -2
  29. package/dist/runtime/stageRunner.js +6 -2
  30. package/dist/server/http.js +18 -0
  31. package/dist/tools/askOperator.d.ts +1 -0
  32. package/dist/tools/askOperator.js +14 -11
  33. package/dist/tools/emitStageEnvelope.d.ts +1 -0
  34. package/dist/tools/emitStageEnvelope.js +3 -1
  35. package/dist/tools/prepareToolArguments.d.ts +4 -0
  36. package/dist/tools/prepareToolArguments.js +79 -0
  37. package/dist/ui/assets/{index-DsgVjLUS.css → index-CSsDued1.css} +1 -1
  38. package/dist/ui/assets/index-DMVdl1qB.js +119 -0
  39. package/dist/ui/index.html +2 -2
  40. package/package.json +1 -1
  41. package/dist/ui/assets/index-DwKGaQvQ.js +0 -119
@@ -0,0 +1,13 @@
1
+ import type { RunStore } from "../runstore/port.js";
2
+ import type { StageEnvelope } from "../types/envelope.js";
3
+ import type { ResolvedPipelineDag } from "../types/pipeline.js";
4
+ import type { StageScheduleState } from "./pipelineScheduler.js";
5
+ export declare function isUnchosenForkChild(dag: ResolvedPipelineDag, stageId: string, envelopes: Map<string, StageEnvelope>): boolean;
6
+ export declare function eventsShowStarted(events: ReadonlyArray<{
7
+ event: string;
8
+ }>): boolean;
9
+ export declare function shouldReopenSkippedStage(dag: ResolvedPipelineDag, stageId: string, states: Map<string, StageScheduleState>, envelopes: Map<string, StageEnvelope>, hasStarted: boolean, options?: {
10
+ supersededCloneIds?: ReadonlySet<string>;
11
+ }): boolean;
12
+ export declare function persistReopenedStages(store: Pick<RunStore, "appendStageEvent">, runId: string, stageIds: Iterable<string>, states: Map<string, StageScheduleState>): Promise<void>;
13
+ export declare function reopenRunnableSkippedStages(store: Pick<RunStore, "appendStageEvent" | "listStageEvents" | "listForkGenerations">, runId: string, dag: ResolvedPipelineDag, states: Map<string, StageScheduleState>, envelopes: Map<string, StageEnvelope>): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import { normalizeForkChoice } from "../envelope/forkChoice.js";
2
+ import { forkCohortMapsFromStore } from "./forkGeneration.js";
3
+ import { joinAllowsRun } from "./joinReadiness.js";
4
+ export function isUnchosenForkChild(dag, stageId, envelopes) {
5
+ for (const node of dag.nodes) {
6
+ const envelope = envelopes.get(node.id);
7
+ if (envelope?.fork_choice === undefined || envelope.fork_choice === null) {
8
+ continue;
9
+ }
10
+ const children = dag.childrenOf[node.id] ?? [];
11
+ if (!children.includes(stageId))
12
+ continue;
13
+ const chosen = normalizeForkChoice(envelope.fork_choice, "stored");
14
+ if (!chosen.has(stageId))
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ export function eventsShowStarted(events) {
20
+ return events.some((event) => event.event === "started" || event.event === "resumed");
21
+ }
22
+ export function shouldReopenSkippedStage(dag, stageId, states, envelopes, hasStarted, options) {
23
+ if (hasStarted)
24
+ return false;
25
+ if (options?.supersededCloneIds?.has(stageId))
26
+ return false;
27
+ if (states.get(stageId) !== "skipped")
28
+ return false;
29
+ if (isUnchosenForkChild(dag, stageId, envelopes))
30
+ return false;
31
+ const hypothetical = new Map(states);
32
+ hypothetical.set(stageId, "pending");
33
+ return joinAllowsRun(dag, stageId, hypothetical, envelopes);
34
+ }
35
+ export async function persistReopenedStages(store, runId, stageIds, states) {
36
+ for (const id of stageIds) {
37
+ if (states.get(id) !== "skipped")
38
+ continue;
39
+ await store.appendStageEvent(runId, id, { event: "reopened" });
40
+ states.set(id, "pending");
41
+ }
42
+ }
43
+ export async function reopenRunnableSkippedStages(store, runId, dag, states, envelopes) {
44
+ const { supersededCloneIds } = await forkCohortMapsFromStore(store, runId);
45
+ const ids = [];
46
+ for (const [id, state] of states) {
47
+ if (state !== "skipped")
48
+ continue;
49
+ const events = await store.listStageEvents(runId, id);
50
+ if (!shouldReopenSkippedStage(dag, id, states, envelopes, eventsShowStarted(events), { supersededCloneIds })) {
51
+ continue;
52
+ }
53
+ ids.push(id);
54
+ }
55
+ await persistReopenedStages(store, runId, ids, states);
56
+ }
@@ -0,0 +1,33 @@
1
+ import type { AgentPort } from "../agent/port.js";
2
+ import type { RunStore } from "../runstore/port.js";
3
+ import type { StageExecutionMode } from "./stageConcurrency.js";
4
+ import type { StageProcessLauncher } from "./stageProcessLauncher.js";
5
+ import { StageHitlController } from "./stageHitl.js";
6
+ import { type OperatorCatalog } from "./stageAttemptBootstrap.js";
7
+ export type ResumeTimedOutContext = {
8
+ runId: string;
9
+ stageId: string;
10
+ agent: AgentPort;
11
+ store: RunStore;
12
+ hitl: StageHitlController;
13
+ executionMode: StageExecutionMode;
14
+ stageProcessLauncher?: StageProcessLauncher;
15
+ cwd: string;
16
+ factoryCwd?: string;
17
+ maxActiveStagesPerRun: number;
18
+ operatorCatalog?: OperatorCatalog;
19
+ };
20
+ export declare function assertTimedOutStageEligible(status: string, events: ReadonlyArray<{
21
+ event: string;
22
+ reason?: string;
23
+ }>): {
24
+ ok: true;
25
+ } | {
26
+ ok: false;
27
+ reason: string;
28
+ status: 409;
29
+ };
30
+ export declare function reconstructTimedOutAndContinue(ctx: ResumeTimedOutContext): Promise<{
31
+ ok: boolean;
32
+ reason?: string;
33
+ }>;
@@ -0,0 +1,165 @@
1
+ import { existsSync } from "node:fs";
2
+ import { fakeHitlResumePath } from "../agent/fakeAgent.js";
3
+ import { definitionIdForInstance } from "../runstore/stageInstanceId.js";
4
+ import { attemptSessionPath } from "../runstore/workspaceLayout.js";
5
+ import { WAIT_WITHOUT_WORKER_DISPATCH } from "./answerResume.js";
6
+ import { resumeRun } from "./pipelineScheduler.js";
7
+ import { loadRunContext } from "./resumeReconstruct.js";
8
+ import { isRunStageWaiting, runStage } from "./stageRunner.js";
9
+ import { attemptContext } from "./stageAttemptContext.js";
10
+ import { openStageAttempt, } from "./stageAttemptBootstrap.js";
11
+ import { buildStageRoots, withResolvedAuthPath } from "./stageRoots.js";
12
+ import { isStageTimeoutReason, lastFailedReason, } from "../agent/stageTimeout.js";
13
+ import { deriveExecutionPatchFromEvent } from "../runstore/stageExecution.js";
14
+ export function assertTimedOutStageEligible(status, events) {
15
+ if (status !== "failed") {
16
+ return {
17
+ ok: false,
18
+ reason: `Stage is not timed out (status=${status})`,
19
+ status: 409,
20
+ };
21
+ }
22
+ if (!isStageTimeoutReason(lastFailedReason(events))) {
23
+ return {
24
+ ok: false,
25
+ reason: "Stage did not fail due to timeout; use retry to start a new attempt",
26
+ status: 409,
27
+ };
28
+ }
29
+ return { ok: true };
30
+ }
31
+ export async function reconstructTimedOutAndContinue(ctx) {
32
+ const { runId, stageId, agent, store, cwd } = ctx;
33
+ const factoryCwd = ctx.factoryCwd ?? cwd;
34
+ try {
35
+ const latestExecution = await store.getLatestStageExecution(runId, stageId);
36
+ const attempt = latestExecution?.attempt ?? 1;
37
+ const attemptCtx = attemptContext(attempt);
38
+ const attemptOpt = attemptCtx.eventOptions();
39
+ const { meta, task, loaded } = await loadRunContext(store, runId, cwd);
40
+ const definitionId = definitionIdForInstance(meta.pipeline_dag, stageId);
41
+ const stageIndex = loaded.stages.findIndex((s) => s.id === definitionId);
42
+ if (stageIndex < 0) {
43
+ const reason = `Stage ${stageId} not in pipeline ${meta.pipeline_id}`;
44
+ await store.appendStageEvent(runId, stageId, { event: "failed", reason }, attemptOpt);
45
+ await store.updateRunStatus(runId, "failed");
46
+ return { ok: false, reason };
47
+ }
48
+ const stage = loaded.stages[stageIndex];
49
+ const dag = meta.pipeline_dag ?? loaded.dag;
50
+ const workspaceDir = store.getWorkspaceDir(runId);
51
+ const checkoutRoot = meta.checkout_root;
52
+ const roots = withResolvedAuthPath(buildStageRoots(workspaceDir, stageId, checkoutRoot, attemptCtx), factoryCwd);
53
+ const sessionFile = attemptSessionPath(workspaceDir, stageId, attempt);
54
+ const fakeResume = fakeHitlResumePath(roots, stageId);
55
+ if (!existsSync(sessionFile) && !existsSync(fakeResume)) {
56
+ return {
57
+ ok: false,
58
+ reason: "missing session to resume; use retry to start a new attempt",
59
+ };
60
+ }
61
+ const resumed = { event: "resumed" };
62
+ await store.appendStageEvent(runId, stageId, resumed, attemptOpt);
63
+ try {
64
+ const execution = await store.getStageExecution(runId, stageId, attempt);
65
+ const patch = deriveExecutionPatchFromEvent(resumed, execution);
66
+ if (Object.keys(patch).length > 0) {
67
+ await store.updateStageExecution(runId, stageId, attempt, patch);
68
+ }
69
+ }
70
+ catch {
71
+ // no execution row yet
72
+ }
73
+ await store.updateRunStatus(runId, "running");
74
+ const opened = await openStageAttempt({
75
+ agent,
76
+ store,
77
+ runId,
78
+ stage,
79
+ stageId,
80
+ task,
81
+ dag,
82
+ checkoutRoot,
83
+ workspaceDir,
84
+ factoryCwd,
85
+ attemptCtx,
86
+ operatorCatalog: ctx.operatorCatalog,
87
+ sessionMode: "timeout_resume",
88
+ resumeToken: sessionFile,
89
+ onActivity: (event) => {
90
+ void store.appendStageEvent(runId, stageId, event, attemptOpt);
91
+ },
92
+ });
93
+ if (!opened.ok) {
94
+ await store.appendStageEvent(runId, stageId, { event: "failed", reason: opened.reason }, attemptOpt);
95
+ await store.updateRunStatus(runId, "failed");
96
+ return { ok: false, reason: opened.reason };
97
+ }
98
+ const result = await runStage({
99
+ agent,
100
+ store,
101
+ runId,
102
+ stage,
103
+ stageId,
104
+ task,
105
+ dag,
106
+ checkoutRoot,
107
+ workspaceDir,
108
+ hitl: ctx.hitl,
109
+ skipStarted: true,
110
+ existingHandle: opened.handle,
111
+ attemptCtx,
112
+ factoryCwd,
113
+ operatorCatalog: ctx.operatorCatalog,
114
+ sessionMode: "timeout_resume",
115
+ resumeToken: sessionFile,
116
+ });
117
+ if (isRunStageWaiting(result)) {
118
+ await store.updateRunStatus(runId, "failed");
119
+ return {
120
+ ok: false,
121
+ reason: WAIT_WITHOUT_WORKER_DISPATCH,
122
+ };
123
+ }
124
+ if (!result.ok) {
125
+ await store.updateRunStatus(runId, "failed");
126
+ return { ok: false, reason: result.reason };
127
+ }
128
+ const rest = await resumeRun({
129
+ prepared: {
130
+ task,
131
+ loaded,
132
+ run: { runId, workspaceDir },
133
+ agent,
134
+ store,
135
+ cwd,
136
+ projectRoot: factoryCwd,
137
+ checkoutRoot,
138
+ hitl: ctx.hitl,
139
+ operatorCatalog: ctx.operatorCatalog,
140
+ },
141
+ maxActiveStagesPerRun: ctx.maxActiveStagesPerRun,
142
+ resumeFromStageId: stageId,
143
+ initialPrior: result.envelope ?? null,
144
+ executionMode: ctx.executionMode,
145
+ stageProcessLauncher: ctx.stageProcessLauncher,
146
+ });
147
+ if (rest.outcome === "failed") {
148
+ return { ok: false, reason: rest.reason };
149
+ }
150
+ return { ok: true };
151
+ }
152
+ catch (err) {
153
+ const reason = err instanceof Error ? err.message : `timeout resume failed: ${String(err)}`;
154
+ try {
155
+ const latestExecution = await store.getLatestStageExecution(runId, stageId);
156
+ const attemptCtx = attemptContext(latestExecution?.attempt ?? 1);
157
+ await store.appendStageEvent(runId, stageId, { event: "failed", reason }, attemptCtx.eventOptions());
158
+ await store.updateRunStatus(runId, "failed");
159
+ }
160
+ catch {
161
+ // ignore secondary failures
162
+ }
163
+ return { ok: false, reason };
164
+ }
165
+ }
@@ -135,6 +135,7 @@ export declare class RunManager {
135
135
  }): Promise<StartRunResult>;
136
136
  rerun(runId: string): Promise<StartRunResult>;
137
137
  retryStage(runId: string, stageId: string): Promise<RetryStageResult>;
138
+ resumeTimedOutStage(runId: string, stageId: string): Promise<RetryStageResult>;
138
139
  recoverManualStage(runId: string, stageId: string, guidance?: string): Promise<RetryStageResult>;
139
140
  recoverManualStageUntilStop(runId: string, stageId: string, guidance?: string): Promise<{
140
141
  ok: true;
@@ -3,6 +3,7 @@ import { readFile, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { normalizeCatalogPath } from "../runstore/normalizeCatalogPath.js";
5
5
  import { loadRunContext } from "./resumeReconstruct.js";
6
+ import { assertTimedOutStageEligible, reconstructTimedOutAndContinue, } from "./resumeTimedOut.js";
6
7
  import { loadTaskFromYaml } from "../config/loadTask.js";
7
8
  import { deriveStatusFromStages, findUnhandledFailedStage, } from "../runstore/port.js";
8
9
  import { PipelineValidationError, startPipeline, } from "./pipelineRunner.js";
@@ -417,6 +418,81 @@ export class RunManager {
417
418
  async retryStage(runId, stageId) {
418
419
  return this.retryStageInternal(runId, stageId, true);
419
420
  }
421
+ async resumeTimedOutStage(runId, stageId) {
422
+ const resumeKey = waitKey(runId, stageId);
423
+ if (this.resumeInFlight.has(resumeKey) || this.retryInFlight.has(resumeKey)) {
424
+ return {
425
+ ok: false,
426
+ reason: `Resume already in progress for run ${runId} stage ${stageId}`,
427
+ status: 409,
428
+ };
429
+ }
430
+ this.resumeInFlight.add(resumeKey);
431
+ let detail;
432
+ try {
433
+ detail = await this.options.store.readRun(runId);
434
+ }
435
+ catch {
436
+ this.resumeInFlight.delete(resumeKey);
437
+ return { ok: false, reason: `Run not found: ${runId}`, status: 404 };
438
+ }
439
+ const stageSnap = detail.stages.find((s) => s.stage_id === stageId);
440
+ if (!stageSnap) {
441
+ this.resumeInFlight.delete(resumeKey);
442
+ return { ok: false, reason: `Stage not found: ${stageId}`, status: 404 };
443
+ }
444
+ const eligibility = assertTimedOutStageEligible(stageSnap.status, stageSnap.events);
445
+ if (!eligibility.ok) {
446
+ this.resumeInFlight.delete(resumeKey);
447
+ return eligibility;
448
+ }
449
+ const latest = await this.options.store.getLatestStageExecution(runId, stageId);
450
+ const attemptIndex = latest?.attempt ?? 1;
451
+ const wasActive = this.active.has(runId);
452
+ const tracked = await this.ensureResumeTracked(runId);
453
+ if (!tracked.ok) {
454
+ this.resumeInFlight.delete(resumeKey);
455
+ return { ok: false, reason: tracked.reason, status: 409 };
456
+ }
457
+ const insertedForResume = !wasActive;
458
+ try {
459
+ const done = reconstructTimedOutAndContinue({
460
+ runId,
461
+ stageId,
462
+ agent: this.options.agent,
463
+ store: this.options.store,
464
+ hitl: this.hitl,
465
+ executionMode: this.executionMode,
466
+ cwd: this.cwd,
467
+ maxActiveStagesPerRun: this.maxActiveStagesPerRun,
468
+ factoryCwd: this.projectRoot,
469
+ ...(this.stageProcessLauncher !== undefined
470
+ ? { stageProcessLauncher: this.stageProcessLauncher }
471
+ : {}),
472
+ ...(this.options.operatorCatalog !== undefined
473
+ ? { operatorCatalog: this.options.operatorCatalog }
474
+ : {}),
475
+ });
476
+ this.registerResumeUntrack(runId, done);
477
+ const outcome = await done;
478
+ if (!outcome.ok) {
479
+ if (insertedForResume) {
480
+ this.removeActiveEntry(runId, false);
481
+ }
482
+ const missingSession = outcome.reason !== undefined &&
483
+ outcome.reason.startsWith("missing session to resume");
484
+ return {
485
+ ok: false,
486
+ reason: outcome.reason ?? "timeout resume failed",
487
+ status: missingSession ? 409 : 500,
488
+ };
489
+ }
490
+ return { ok: true, runId, stageId, attemptIndex };
491
+ }
492
+ finally {
493
+ this.resumeInFlight.delete(resumeKey);
494
+ }
495
+ }
420
496
  async recoverManualStage(runId, stageId, guidance) {
421
497
  return this.recoverManualStageInternal(runId, stageId, guidance, true);
422
498
  }
@@ -35,10 +35,10 @@ export async function failStageAsInterrupted(options) {
35
35
  }
36
36
  export async function syncRunStatusFromStages(store, runId) {
37
37
  const meta = await store.readRunMeta(runId);
38
- if (meta.status === "succeeded")
39
- return;
40
38
  const run = await store.readRun(runId);
41
39
  const derived = deriveStatusFromStages(run.stages, meta.pipeline_dag);
40
+ if (meta.status === "succeeded" && derived !== "running")
41
+ return;
42
42
  if ((meta.status ?? "created") !== derived) {
43
43
  await store.updateRunStatus(runId, derived);
44
44
  }
@@ -3,6 +3,7 @@ import { buildStageRoots, withResolvedAuthPath, } from "./stageRoots.js";
3
3
  import { createDefaultHitlQaHooks, recordWaitEnter, } from "./stageHitl.js";
4
4
  import { openStageAttempt, } from "./stageAttemptBootstrap.js";
5
5
  import { createVerifiedStageExecution } from "./verifiedStageExecution.js";
6
+ import { isStageTimeoutReason } from "../agent/stageTimeout.js";
6
7
  export function isRunStageWaiting(outcome) {
7
8
  return "waiting" in outcome && outcome.waiting === true;
8
9
  }
@@ -220,7 +221,9 @@ export async function runStage(options) {
220
221
  attemptCtx,
221
222
  });
222
223
  console.error(`Stage ${stageId} failed: ${reason}`);
223
- await handle.close().catch(() => undefined);
224
+ await handle
225
+ .close(isStageTimeoutReason(reason) ? { park: true } : undefined)
226
+ .catch(() => undefined);
224
227
  return { ok: false, reason };
225
228
  }
226
229
  if (isRunStageWaiting(outcome)) {
@@ -251,6 +254,7 @@ export async function runStage(options) {
251
254
  });
252
255
  }
253
256
  finally {
254
- await handle.close().catch(() => undefined);
257
+ const timedOut = isStageTimeoutReason(outcome.ok ? undefined : outcome.reason);
258
+ await handle.close(timedOut ? { park: true } : undefined).catch(() => undefined);
255
259
  }
256
260
  }
@@ -91,6 +91,7 @@ function isMutatingApi(method, pathname) {
91
91
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/answer$/.test(pathname) ||
92
92
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/feedback-decision$/.test(pathname) ||
93
93
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/retry$/.test(pathname) ||
94
+ /^\/api\/runs\/[^/]+\/stages\/[^/]+\/resume$/.test(pathname) ||
94
95
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/recovery$/.test(pathname) ||
95
96
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/recovery\/stop$/.test(pathname) ||
96
97
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/abandon$/.test(pathname) ||
@@ -390,6 +391,23 @@ export async function startUiServer(options) {
390
391
  });
391
392
  return true;
392
393
  }
394
+ const resumeMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/resume$/);
395
+ if (method === "POST" && resumeMatch) {
396
+ await readJsonBody(req);
397
+ const runId = decodeURIComponent(resumeMatch[1] ?? "");
398
+ const stageId = decodeURIComponent(resumeMatch[2] ?? "");
399
+ const result = await manager.resumeTimedOutStage(runId, stageId);
400
+ if (!result.ok) {
401
+ json(res, result.status ?? 500, mapRetryStageFailure(result));
402
+ return true;
403
+ }
404
+ json(res, 202, {
405
+ runId: result.runId,
406
+ stageId: result.stageId,
407
+ attemptIndex: result.attemptIndex,
408
+ });
409
+ return true;
410
+ }
393
411
  const recoverMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/recovery$/);
394
412
  if (method === "POST" && recoverMatch) {
395
413
  let body;
@@ -330,6 +330,7 @@ export declare function createAskOperatorTool(options: AskOperatorToolOptions):
330
330
  artifacts: Type.TArray<Type.TString>;
331
331
  id: Type.TOptional<Type.TString>;
332
332
  }>)[]]>;
333
+ prepareArguments: (args: unknown) => never;
333
334
  execute: (_toolCallId: string, params: unknown) => Promise<{
334
335
  content: {
335
336
  type: "text";
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { Type } from "typebox";
12
+ import { prepareAskOperatorArguments } from "./prepareToolArguments.js";
12
13
  export class AskOperatorError extends Error {
13
14
  constructor(message) {
14
15
  super(message);
@@ -168,10 +169,11 @@ function parseSubQuestion(value, path) {
168
169
  };
169
170
  }
170
171
  export function parseAskOperatorParams(value) {
171
- if (!isRecord(value)) {
172
+ const prepared = prepareAskOperatorArguments(value);
173
+ if (!isRecord(prepared)) {
172
174
  throw new AskOperatorError("ask_operator params must be an object");
173
175
  }
174
- const kind = value.kind;
176
+ const kind = prepared.kind;
175
177
  if (typeof kind !== "string") {
176
178
  throw new AskOperatorError("kind is required");
177
179
  }
@@ -181,36 +183,36 @@ export function parseAskOperatorParams(value) {
181
183
  kind !== "artifact_backed") {
182
184
  throw new AskOperatorError(`unsupported prompt kind "${kind}" (allowed: free_text, confirm, multi_question, artifact_backed)`);
183
185
  }
184
- const id = optionalId(value.id, "id");
186
+ const id = optionalId(prepared.id, "id");
185
187
  if (kind === "free_text" || kind === "confirm") {
186
188
  return {
187
189
  kind,
188
- message: requireNonEmptyString(value.message, "message"),
190
+ message: requireNonEmptyString(prepared.message, "message"),
189
191
  ...(id !== undefined ? { id } : {}),
190
192
  };
191
193
  }
192
194
  if (kind === "artifact_backed") {
193
- if (!Array.isArray(value.artifacts)) {
195
+ if (!Array.isArray(prepared.artifacts)) {
194
196
  throw new AskOperatorError("artifacts must be an array");
195
197
  }
196
- if (value.artifacts.length < 1) {
198
+ if (prepared.artifacts.length < 1) {
197
199
  throw new AskOperatorError("artifacts must contain at least one path");
198
200
  }
199
- const artifacts = value.artifacts.map((item, index) => requireNonEmptyString(item, `artifacts[${index}]`));
201
+ const artifacts = prepared.artifacts.map((item, index) => requireNonEmptyString(item, `artifacts[${index}]`));
200
202
  return {
201
203
  kind,
202
- message: requireNonEmptyString(value.message, "message"),
204
+ message: requireNonEmptyString(prepared.message, "message"),
203
205
  artifacts,
204
206
  ...(id !== undefined ? { id } : {}),
205
207
  };
206
208
  }
207
- if (!Array.isArray(value.questions)) {
209
+ if (!Array.isArray(prepared.questions)) {
208
210
  throw new AskOperatorError("questions must be an array");
209
211
  }
210
- if (value.questions.length < 1) {
212
+ if (prepared.questions.length < 1) {
211
213
  throw new AskOperatorError("questions must contain at least one item");
212
214
  }
213
- const questions = value.questions.map((item, index) => parseSubQuestion(item, `questions[${index}]`));
215
+ const questions = prepared.questions.map((item, index) => parseSubQuestion(item, `questions[${index}]`));
214
216
  return {
215
217
  kind,
216
218
  questions,
@@ -401,6 +403,7 @@ export function createAskOperatorTool(options) {
401
403
  label: "Ask operator",
402
404
  description: `Ask the operator a question and wait for their answer. Supports ${kindList} prompts. Does not complete the stage — call emit_stage_envelope when finished.`,
403
405
  parameters: askOperatorParamsSchemaFor(allowedKinds),
406
+ prepareArguments: prepareAskOperatorArguments,
404
407
  execute: async (_toolCallId, params) => {
405
408
  let prompt;
406
409
  try {
@@ -37,6 +37,7 @@ export declare function createEmitStageEnvelopeTool(capture: EmitCapture, payloa
37
37
  stage_id: Type.TOptional<Type.TString>;
38
38
  notes: Type.TOptional<Type.TString>;
39
39
  }>;
40
+ prepareArguments: (args: unknown) => never;
40
41
  execute: (_toolCallId: string, params: unknown) => Promise<{
41
42
  content: {
42
43
  type: "text";
@@ -15,6 +15,7 @@ import { assertFeedbackLoopAction } from "../envelope/feedbackLoop.js";
15
15
  import { normalizeForkChoice } from "../envelope/forkChoice.js";
16
16
  import { assertEnvelopePayload, compilePayloadSchema, } from "../envelope/payloadSchema.js";
17
17
  import { assertPreEmitChecks, } from "../envelope/preEmitChecks.js";
18
+ import { prepareEmitStageEnvelopeArguments } from "./prepareToolArguments.js";
18
19
  function stringLiteralsSchema(values) {
19
20
  if (values.length === 1) {
20
21
  return Type.Literal(values[0]);
@@ -78,12 +79,13 @@ export function createEmitStageEnvelopeTool(capture, payloadSchema, forkEmitCont
78
79
  stage_id: Type.Optional(Type.String()),
79
80
  notes: Type.Optional(Type.String()),
80
81
  }),
82
+ prepareArguments: prepareEmitStageEnvelopeArguments,
81
83
  execute: async (_toolCallId, params) => {
82
84
  if (capture.envelope) {
83
85
  return toolResult("Envelope already accepted; later emits are ignored.", { advancing: isAdvancingEnvelope(capture.envelope), error: "" }, { terminate: true });
84
86
  }
85
87
  try {
86
- const envelope = assertRequiredEnvelope(params);
88
+ const envelope = assertRequiredEnvelope(prepareEmitStageEnvelopeArguments(params));
87
89
  assertFeedbackLoopAction(envelope, feedbackLoopEmitContext);
88
90
  const isSendBack = envelope.feedback_loop?.action === "send_back";
89
91
  if (envelope.status !== "failure" &&
@@ -0,0 +1,4 @@
1
+ export declare function coerceJsonArrayField(args: Record<string, unknown>, key: string): void;
2
+ export declare function coerceJsonObjectField(args: Record<string, unknown>, key: string): void;
3
+ export declare function prepareAskOperatorArguments(input: unknown): unknown;
4
+ export declare function prepareEmitStageEnvelopeArguments(input: unknown): unknown;
@@ -0,0 +1,79 @@
1
+ function isRecord(value) {
2
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+ function parseJsonArrayString(value) {
5
+ if (typeof value !== "string")
6
+ return undefined;
7
+ const trimmed = value.trim();
8
+ if (!trimmed.startsWith("["))
9
+ return undefined;
10
+ try {
11
+ const parsed = JSON.parse(trimmed);
12
+ return Array.isArray(parsed) ? parsed : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ function parseJsonObjectString(value) {
19
+ if (typeof value !== "string")
20
+ return undefined;
21
+ const trimmed = value.trim();
22
+ if (!trimmed.startsWith("{"))
23
+ return undefined;
24
+ try {
25
+ const parsed = JSON.parse(trimmed);
26
+ return isRecord(parsed) ? parsed : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ export function coerceJsonArrayField(args, key) {
33
+ const parsed = parseJsonArrayString(args[key]);
34
+ if (parsed !== undefined) {
35
+ args[key] = parsed;
36
+ }
37
+ }
38
+ export function coerceJsonObjectField(args, key) {
39
+ const parsed = parseJsonObjectString(args[key]);
40
+ if (parsed !== undefined) {
41
+ args[key] = parsed;
42
+ }
43
+ }
44
+ function coerceJsonObjectArrayElements(args, key) {
45
+ const value = args[key];
46
+ if (!Array.isArray(value))
47
+ return;
48
+ args[key] = value.map((item) => parseJsonObjectString(item) ?? item);
49
+ }
50
+ export function prepareAskOperatorArguments(input) {
51
+ if (!isRecord(input))
52
+ return input;
53
+ const args = { ...input };
54
+ coerceJsonArrayField(args, "questions");
55
+ coerceJsonObjectArrayElements(args, "questions");
56
+ coerceJsonArrayField(args, "artifacts");
57
+ return args;
58
+ }
59
+ export function prepareEmitStageEnvelopeArguments(input) {
60
+ if (!isRecord(input))
61
+ return input;
62
+ const args = { ...input };
63
+ coerceJsonObjectField(args, "payload");
64
+ coerceJsonObjectField(args, "feedback_loop");
65
+ coerceJsonArrayField(args, "artifacts");
66
+ coerceJsonArrayField(args, "fork_choice");
67
+ coerceJsonArrayField(args, "checklist_attestations");
68
+ coerceJsonObjectArrayElements(args, "checklist_attestations");
69
+ if (Array.isArray(args.checklist_attestations)) {
70
+ args.checklist_attestations = args.checklist_attestations.map((item) => {
71
+ if (!isRecord(item))
72
+ return item;
73
+ const next = { ...item };
74
+ coerceJsonArrayField(next, "items");
75
+ return next;
76
+ });
77
+ }
78
+ return args;
79
+ }