@wichayutdew/pi-workflows 3.6.0 → 3.7.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.
package/dist/index.js CHANGED
@@ -731,6 +731,13 @@ function parseWorkflowStep(value, stepId, path, errors) {
731
731
  errors.push(`${path}.transitions: artifact-contract retry requires a "retry" transition`);
732
732
  }
733
733
  }
734
+ if (agent && maxToolCalls !== undefined) {
735
+ if (!Object.hasOwn(transitions, "handoff")) {
736
+ errors.push(`${path}.transitions: maxToolCalls requires a "handoff" transition`);
737
+ } else if (transitions.handoff !== stepId) {
738
+ errors.push(`${path}.transitions: handoff transition must target "${stepId}"`);
739
+ }
740
+ }
734
741
  if (workspace) {
735
742
  workspace.bindOn.forEach((outcome) => {
736
743
  if (!Object.hasOwn(transitions, outcome)) {
@@ -2615,6 +2622,22 @@ var RESULT_KEYS = new Set([
2615
2622
  var isObject = (value) => {
2616
2623
  return value !== null && typeof value === "object" && !Array.isArray(value);
2617
2624
  };
2625
+ function validateNonSuccessSummary(outcome, summary) {
2626
+ if (outcome === "blocked") {
2627
+ const isActionable = /^# Blocked:\s+.+/m.test(summary) && /^\s*\*\*Action:\*\*\s+.+/m.test(summary) && /^\s*\*\*Next:\*\*\s+.+/m.test(summary);
2628
+ if (!isActionable) {
2629
+ throw new Error("blocked summary must identify the missing user prerequisite and next action");
2630
+ }
2631
+ }
2632
+ if (outcome === "retry") {
2633
+ const describesTransientFailure = /\btransient\b/i.test(summary);
2634
+ const hasSafeRetryCondition = /\b(safe (retry|to retry)|retry (when|after|once))\b/i.test(summary);
2635
+ const requestsUserInput = /\b(provide|confirm|choose|approve|decide)\b/i.test(summary);
2636
+ if (!/^# Retry:\s+.+/m.test(summary) || !describesTransientFailure || !hasSafeRetryCondition || requestsUserInput) {
2637
+ throw new Error("retry summary must identify a transient failure and safe retry condition");
2638
+ }
2639
+ }
2640
+ }
2618
2641
  function parseResultWorkspace(value, outcome, policy) {
2619
2642
  const requiresWorkspace = policy.workspace?.bindOn.includes(outcome) === true;
2620
2643
  if (!requiresWorkspace) {
@@ -2669,6 +2692,7 @@ function parseWorkflowStepResult(value, policy) {
2669
2692
  if (summary.length > policy.summaryMaxChars) {
2670
2693
  throw new Error(`workflow step summary exceeds ${policy.summaryMaxChars} characters`);
2671
2694
  }
2695
+ validateNonSuccessSummary(value.outcome, summary);
2672
2696
  if (value.artifact !== undefined && typeof value.artifact !== "string") {
2673
2697
  throw new Error("workflow step artifact must be a string");
2674
2698
  }
@@ -6059,6 +6083,7 @@ var resolveStep = (workflow, run) => {
6059
6083
  return step;
6060
6084
  };
6061
6085
  var RESUME_INPUT_PLACEHOLDER = /\{\{\s*resume\.input\s*\}\}/;
6086
+ var WORKFLOW_INPUT_PLACEHOLDER = /\{\{\s*workflow\.input\s*\}\}/;
6062
6087
  var renderStepPrompt = ({
6063
6088
  workflow,
6064
6089
  run,
@@ -6100,6 +6125,7 @@ function buildStepTask(options) {
6100
6125
  const isDelegated = execution === "delegated";
6101
6126
  const promptTemplate = workflow.prompts[run.currentStepId] ?? "";
6102
6127
  const prompt = renderStepPrompt({ workflow, run, step, execution });
6128
+ const showWorkflowInput = WORKFLOW_INPUT_PLACEHOLDER.test(promptTemplate);
6103
6129
  const handoff = currentStepHandoff(run);
6104
6130
  const contract = createStepContract({ workflow, run, step });
6105
6131
  const completionTool = isDelegated ? "structured_output" : "workflow_complete_step";
@@ -6119,6 +6145,7 @@ function buildStepTask(options) {
6119
6145
  ...step.agent ? ["## Role prompt", "", rolePrompt(step.agent.name), ""] : [],
6120
6146
  prompt,
6121
6147
  "",
6148
+ ...showWorkflowInput ? ["## Original workflow request", "", run.input || "(none supplied)", ""] : [],
6122
6149
  ...isDelegated ? buildDelegatedHandoffSection(handoff) : [],
6123
6150
  ...buildRestartWorkspaceSection(run.restartWorkspaceCwd),
6124
6151
  ...buildResumeInputSection(run, RESUME_INPUT_PLACEHOLDER.test(promptTemplate)),
@@ -6402,6 +6429,7 @@ var POLICY_KEYS = new Set([
6402
6429
  "maxToolCalls",
6403
6430
  "handoffReserve",
6404
6431
  "totalToolCalls",
6432
+ "handoffOutcome",
6405
6433
  "gateSubmitOutcome",
6406
6434
  "workspace"
6407
6435
  ]);
@@ -6497,10 +6525,28 @@ var parseChildPolicy = (value, environment = DEFAULT_CHILD_POLICY_ENVIRONMENT) =
6497
6525
  if (!isRecord11(value))
6498
6526
  throw new Error("child policy must be an object");
6499
6527
  rejectUnknownProperties(value);
6528
+ const sections = parseChildPolicySections(value);
6529
+ const toolBudget = parseToolBudget(value);
6530
+ const handoffOutcome = value.handoffOutcome;
6531
+ if (handoffOutcome !== undefined) {
6532
+ if (typeof handoffOutcome !== "string" || handoffOutcome !== "handoff") {
6533
+ throw new Error("child policy handoffOutcome is invalid");
6534
+ }
6535
+ if (toolBudget.maxToolCalls === undefined) {
6536
+ throw new Error("child policy handoffOutcome requires a tool budget");
6537
+ }
6538
+ if (!sections.outcomes.includes(handoffOutcome)) {
6539
+ throw new Error("child policy handoffOutcome must be an allowed outcome");
6540
+ }
6541
+ }
6542
+ if (toolBudget.maxToolCalls !== undefined && handoffOutcome === undefined) {
6543
+ throw new Error("child policy tool budget requires handoffOutcome");
6544
+ }
6500
6545
  return {
6501
6546
  ...parseIdentityAndPaths(value, environment),
6502
- ...parseChildPolicySections(value),
6503
- ...parseToolBudget(value)
6547
+ ...sections,
6548
+ ...toolBudget,
6549
+ ...handoffOutcome ? { handoffOutcome } : {}
6504
6550
  };
6505
6551
  };
6506
6552
 
@@ -6695,7 +6741,8 @@ function createDelegationPlan(input, dependencies) {
6695
6741
  const toolBudget = step.maxToolCalls === undefined ? {} : {
6696
6742
  maxToolCalls: step.maxToolCalls,
6697
6743
  handoffReserve: HANDOFF_RESERVE,
6698
- totalToolCalls: step.maxToolCalls + HANDOFF_RESERVE
6744
+ totalToolCalls: step.maxToolCalls + HANDOFF_RESERVE,
6745
+ handoffOutcome: "handoff"
6699
6746
  };
6700
6747
  const policyDigest = digest({
6701
6748
  version: 1,
@@ -6730,6 +6777,7 @@ function createDelegationPlan(input, dependencies) {
6730
6777
  pauseOutcomes: Object.entries(step.transitions).filter(([outcome, target]) => target === "$pause" && outcomeSet.has(outcome)).map(([outcome]) => outcome),
6731
6778
  summaryMaxChars: workflow.definition.summaryMaxChars,
6732
6779
  ...toolBudget,
6780
+ ...step.maxToolCalls === undefined ? {} : { handoffOutcome: "handoff" },
6733
6781
  ...step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {},
6734
6782
  ...step.workspace ? { workspace: structuredClone(step.workspace) } : {}
6735
6783
  };
@@ -8007,9 +8055,30 @@ var toolBudgetWarningPrompt = ({
8007
8055
  var TOOL_BUDGET_HANDOFF_PROMPT = [
8008
8056
  "The productive tool-call budget is exhausted.",
8009
8057
  "Work tools are locked; do not execute further work.",
8010
- "Call `structured_output` exactly once, alone, with one configured outcome and a compact handoff."
8058
+ "Call `structured_output` exactly once, alone, with a detailed configured result if you can complete now.",
8059
+ "If you settle without a result, the extension persists an `handoff` result for a fresh child."
8011
8060
  ].join(`
8012
8061
  `);
8062
+ var toolBudgetHandoffResult = (policy, productiveCalls) => {
8063
+ if (policy.handoffOutcome !== "handoff") {
8064
+ throw new Error("tool-budget handoff requires a configured handoff outcome");
8065
+ }
8066
+ return {
8067
+ version: 1,
8068
+ policyDigest: policy.policyDigest,
8069
+ outcome: policy.handoffOutcome,
8070
+ summary: [
8071
+ "# Handoff: Productive tool-call budget exhausted.",
8072
+ "",
8073
+ `- Completed work: Productive calls completed: ${productiveCalls}.`,
8074
+ "- Current state: Work tools are locked; no further productive tool calls ran.",
8075
+ "- Remaining work: A fresh child must inspect the previous handoff and continue this same step.",
8076
+ "- Blocker: The configured productive tool-call budget is exhausted.",
8077
+ "**Next:** Start a fresh delegated attempt for this step using the persisted handoff."
8078
+ ].join(`
8079
+ `)
8080
+ };
8081
+ };
8013
8082
  var needsCompletionRepair = ({
8014
8083
  policy,
8015
8084
  dependencies
@@ -8094,6 +8163,10 @@ var childSystemPrompt = (policy) => {
8094
8163
  "",
8095
8164
  "The parent workflow harness owns orchestration and state transitions.",
8096
8165
  "Perform only this delegated step. Its child-side tool policy is enforced.",
8166
+ "Do not launch subagents while executing this declarative workflow step.",
8167
+ "Use `blocked` only when progress requires user-provided information, a decision, authority, credentials, or approval.",
8168
+ "Use `retry` only for a transient failure that can be retried without new user input.",
8169
+ "Do not open a skill unless this step YAML lists that skill.",
8097
8170
  "When finished, call `structured_output` exactly once and as the only tool call in that message.",
8098
8171
  "Pass the workflow result as its `value`: outcome, summary, optional artifact, and workspace only when required below.",
8099
8172
  `Valid outcomes: ${policy.outcomes.join(", ")}`,
@@ -8105,9 +8178,9 @@ var childSystemPrompt = (policy) => {
8105
8178
  `Productive tool-call budget: ${policy.maxToolCalls} calls.`,
8106
8179
  `Handoff reserve: ${policy.handoffReserve} calls.`,
8107
8180
  `Total tool-call budget: ${policy.totalToolCalls} calls.`,
8108
- "At 2 productive calls remaining, begin handoff.",
8181
+ "At 2 productive calls remaining, prepare a concise handoff with completed work, current state, remaining work, and any blocker.",
8109
8182
  "Work tools are locked when the productive budget is exhausted.",
8110
- "Continue with `handoff` using the reserved calls."
8183
+ "If the child settles without a result after exhaustion, the extension writes the configured `handoff` structured result."
8111
8184
  ],
8112
8185
  ...policy.gateSubmitOutcome ? [
8113
8186
  `Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`
@@ -8245,14 +8318,27 @@ ${childSystemPrompt(state.activePolicy)}`
8245
8318
  if (productiveCalls >= policy.maxToolCalls) {
8246
8319
  state = {
8247
8320
  ...state,
8248
- effectiveTools: new Set([CHILD_COMPLETION_TOOL]),
8321
+ effectiveTools: new Set,
8249
8322
  productiveToolCallIds,
8250
8323
  runtimeMode: "handoff"
8251
8324
  };
8252
- pi.setActiveTools([CHILD_COMPLETION_TOOL]);
8253
- pi.sendUserMessage(TOOL_BUDGET_HANDOFF_PROMPT, {
8254
- deliverAs: "followUp"
8255
- });
8325
+ try {
8326
+ writeChildResult({
8327
+ policy,
8328
+ result: toolBudgetHandoffResult(policy, productiveCalls),
8329
+ dependencies
8330
+ });
8331
+ pi.setActiveTools([]);
8332
+ } catch {
8333
+ state = {
8334
+ ...state,
8335
+ effectiveTools: new Set([CHILD_COMPLETION_TOOL])
8336
+ };
8337
+ pi.setActiveTools([CHILD_COMPLETION_TOOL]);
8338
+ pi.sendUserMessage(TOOL_BUDGET_HANDOFF_PROMPT, {
8339
+ deliverAs: "followUp"
8340
+ });
8341
+ }
8256
8342
  return;
8257
8343
  }
8258
8344
  state = { ...state, productiveToolCallIds };
@@ -8267,9 +8353,20 @@ ${childSystemPrompt(state.activePolicy)}`
8267
8353
  });
8268
8354
  pi.on("agent_settled", () => {
8269
8355
  const policy = state.activePolicy;
8270
- if (!policy || state.repairRequested || !needsCompletionRepair({ policy, dependencies })) {
8356
+ if (!policy || state.repairRequested)
8271
8357
  return;
8358
+ if (state.runtimeMode === "handoff" && needsCompletionRepair({ policy, dependencies })) {
8359
+ try {
8360
+ writeChildResult({
8361
+ policy,
8362
+ result: toolBudgetHandoffResult(policy, state.productiveToolCallIds.size),
8363
+ dependencies
8364
+ });
8365
+ return;
8366
+ } catch {}
8272
8367
  }
8368
+ if (!needsCompletionRepair({ policy, dependencies }))
8369
+ return;
8273
8370
  state = {
8274
8371
  ...state,
8275
8372
  repairRequested: true,
@@ -8331,7 +8428,7 @@ ${childSystemPrompt(state.activePolicy)}`
8331
8428
  if (state.runtimeMode === "handoff") {
8332
8429
  return {
8333
8430
  block: true,
8334
- reason: "Productive tool-call budget is exhausted; only structured_output is available for handoff"
8431
+ reason: "Productive tool-call budget is exhausted; the extension-owned handoff is already persisted"
8335
8432
  };
8336
8433
  }
8337
8434
  if (CHILD_COORDINATION_TOOLS.has(event.toolName)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wichayutdew/pi-workflows",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "A declarative, pauseable workflow harness for Pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -438,6 +438,17 @@ export function parseWorkflowStep(
438
438
  );
439
439
  }
440
440
  }
441
+ if (agent && maxToolCalls !== undefined) {
442
+ if (!Object.hasOwn(transitions, 'handoff')) {
443
+ errors.push(
444
+ `${path}.transitions: maxToolCalls requires a "handoff" transition`,
445
+ );
446
+ } else if (transitions.handoff !== stepId) {
447
+ errors.push(
448
+ `${path}.transitions: handoff transition must target "${stepId}"`,
449
+ );
450
+ }
451
+ }
441
452
  if (workspace) {
442
453
  workspace.bindOn.forEach((outcome) => {
443
454
  if (!Object.hasOwn(transitions, outcome)) {
@@ -165,6 +165,7 @@ export function createDelegationPlan(
165
165
  maxToolCalls: step.maxToolCalls,
166
166
  handoffReserve: HANDOFF_RESERVE,
167
167
  totalToolCalls: step.maxToolCalls + HANDOFF_RESERVE,
168
+ handoffOutcome: 'handoff',
168
169
  };
169
170
  const policyDigest = digest({
170
171
  version: 1,
@@ -203,6 +204,7 @@ export function createDelegationPlan(
203
204
  .map(([outcome]) => outcome),
204
205
  summaryMaxChars: workflow.definition.summaryMaxChars,
205
206
  ...toolBudget,
207
+ ...(step.maxToolCalls === undefined ? {} : { handoffOutcome: 'handoff' }),
206
208
  ...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
207
209
  ...(step.workspace ? { workspace: structuredClone(step.workspace) } : {}),
208
210
  };
@@ -28,6 +28,8 @@ export type ChildStepPolicy = {
28
28
  readonly handoffReserve?: number;
29
29
  /** Productive calls plus the mandatory handoff reserve. */
30
30
  readonly totalToolCalls?: number;
31
+ /** Extension-owned outcome persisted when an exhausted child settles without a result. */
32
+ readonly handoffOutcome?: string;
31
33
  readonly gateSubmitOutcome?: string;
32
34
  readonly workspace?: StepWorkspaceBinding;
33
35
  };
@@ -31,6 +31,7 @@ const POLICY_KEYS: ReadonlySet<string> = new Set([
31
31
  'maxToolCalls',
32
32
  'handoffReserve',
33
33
  'totalToolCalls',
34
+ 'handoffOutcome',
34
35
  'gateSubmitOutcome',
35
36
  'workspace',
36
37
  ]);
@@ -206,9 +207,27 @@ export const parseChildPolicy = (
206
207
  if (!isRecord(value)) throw new Error('child policy must be an object');
207
208
 
208
209
  rejectUnknownProperties(value);
210
+ const sections = parseChildPolicySections(value);
211
+ const toolBudget = parseToolBudget(value);
212
+ const handoffOutcome = value.handoffOutcome;
213
+ if (handoffOutcome !== undefined) {
214
+ if (typeof handoffOutcome !== 'string' || handoffOutcome !== 'handoff') {
215
+ throw new Error('child policy handoffOutcome is invalid');
216
+ }
217
+ if (toolBudget.maxToolCalls === undefined) {
218
+ throw new Error('child policy handoffOutcome requires a tool budget');
219
+ }
220
+ if (!sections.outcomes.includes(handoffOutcome)) {
221
+ throw new Error('child policy handoffOutcome must be an allowed outcome');
222
+ }
223
+ }
224
+ if (toolBudget.maxToolCalls !== undefined && handoffOutcome === undefined) {
225
+ throw new Error('child policy tool budget requires handoffOutcome');
226
+ }
209
227
  return {
210
228
  ...parseIdentityAndPaths(value, environment),
211
- ...parseChildPolicySections(value),
212
- ...parseToolBudget(value),
229
+ ...sections,
230
+ ...toolBudget,
231
+ ...(handoffOutcome ? { handoffOutcome } : {}),
213
232
  };
214
233
  };
@@ -29,6 +29,10 @@ export const childSystemPrompt = (policy: ChildStepPolicy): string => {
29
29
  '',
30
30
  'The parent workflow harness owns orchestration and state transitions.',
31
31
  'Perform only this delegated step. Its child-side tool policy is enforced.',
32
+ 'Do not launch subagents while executing this declarative workflow step.',
33
+ 'Use `blocked` only when progress requires user-provided information, a decision, authority, credentials, or approval.',
34
+ 'Use `retry` only for a transient failure that can be retried without new user input.',
35
+ 'Do not open a skill unless this step YAML lists that skill.',
32
36
  'When finished, call `structured_output` exactly once and as the only tool call in that message.',
33
37
  'Pass the workflow result as its `value`: outcome, summary, optional artifact, and workspace only when required below.',
34
38
  `Valid outcomes: ${policy.outcomes.join(', ')}`,
@@ -42,9 +46,9 @@ export const childSystemPrompt = (policy: ChildStepPolicy): string => {
42
46
  `Productive tool-call budget: ${policy.maxToolCalls} calls.`,
43
47
  `Handoff reserve: ${policy.handoffReserve} calls.`,
44
48
  `Total tool-call budget: ${policy.totalToolCalls} calls.`,
45
- 'At 2 productive calls remaining, begin handoff.',
49
+ 'At 2 productive calls remaining, prepare a concise handoff with completed work, current state, remaining work, and any blocker.',
46
50
  'Work tools are locked when the productive budget is exhausted.',
47
- 'Continue with `handoff` using the reserved calls.',
51
+ 'If the child settles without a result after exhaustion, the extension writes the configured `handoff` structured result.',
48
52
  ]),
49
53
  ...(policy.gateSubmitOutcome
50
54
  ? [
@@ -1,3 +1,4 @@
1
+ import type { WorkflowStepResult } from '../../runtime/step-result.ts';
1
2
  import type { ChildStepPolicy } from './child-policy-types.ts';
2
3
  import type { SubagentChildRuntimeDependencies } from './child-runtime-types.ts';
3
4
 
@@ -27,9 +28,36 @@ export const toolBudgetWarningPrompt = ({
27
28
  export const TOOL_BUDGET_HANDOFF_PROMPT = [
28
29
  'The productive tool-call budget is exhausted.',
29
30
  'Work tools are locked; do not execute further work.',
30
- 'Call `structured_output` exactly once, alone, with one configured outcome and a compact handoff.',
31
+ 'Call `structured_output` exactly once, alone, with a detailed configured result if you can complete now.',
32
+ 'If you settle without a result, the extension persists an `handoff` result for a fresh child.',
31
33
  ].join('\n');
32
34
 
35
+ /** Builds the only extension-owned result allowed after productive budget exhaustion. */
36
+ export const toolBudgetHandoffResult = (
37
+ policy: ChildStepPolicy,
38
+ productiveCalls: number,
39
+ ): WorkflowStepResult => {
40
+ if (policy.handoffOutcome !== 'handoff') {
41
+ throw new Error(
42
+ 'tool-budget handoff requires a configured handoff outcome',
43
+ );
44
+ }
45
+ return {
46
+ version: 1,
47
+ policyDigest: policy.policyDigest,
48
+ outcome: policy.handoffOutcome,
49
+ summary: [
50
+ '# Handoff: Productive tool-call budget exhausted.',
51
+ '',
52
+ `- Completed work: Productive calls completed: ${productiveCalls}.`,
53
+ '- Current state: Work tools are locked; no further productive tool calls ran.',
54
+ '- Remaining work: A fresh child must inspect the previous handoff and continue this same step.',
55
+ '- Blocker: The configured productive tool-call budget is exhausted.',
56
+ '**Next:** Start a fresh delegated attempt for this step using the persisted handoff.',
57
+ ].join('\n'),
58
+ };
59
+ };
60
+
33
61
  /** Returns whether a same-child completion repair may be requested safely. */
34
62
  export const needsCompletionRepair = ({
35
63
  policy,
@@ -17,6 +17,7 @@ import {
17
17
  COMPLETION_REPAIR_PROMPT,
18
18
  needsCompletionRepair,
19
19
  TOOL_BUDGET_HANDOFF_PROMPT,
20
+ toolBudgetHandoffResult,
20
21
  toolBudgetWarningPrompt,
21
22
  } from './child-runtime-repair.ts';
22
23
  import {
@@ -210,14 +211,27 @@ export const registerSubagentChildRuntime = (
210
211
  if (productiveCalls >= policy.maxToolCalls) {
211
212
  state = {
212
213
  ...state,
213
- effectiveTools: new Set([CHILD_COMPLETION_TOOL]),
214
+ effectiveTools: new Set(),
214
215
  productiveToolCallIds,
215
216
  runtimeMode: 'handoff',
216
217
  };
217
- pi.setActiveTools([CHILD_COMPLETION_TOOL]);
218
- pi.sendUserMessage(TOOL_BUDGET_HANDOFF_PROMPT, {
219
- deliverAs: 'followUp',
220
- });
218
+ try {
219
+ writeChildResult({
220
+ policy,
221
+ result: toolBudgetHandoffResult(policy, productiveCalls),
222
+ dependencies,
223
+ });
224
+ pi.setActiveTools([]);
225
+ } catch {
226
+ state = {
227
+ ...state,
228
+ effectiveTools: new Set([CHILD_COMPLETION_TOOL]),
229
+ };
230
+ pi.setActiveTools([CHILD_COMPLETION_TOOL]);
231
+ pi.sendUserMessage(TOOL_BUDGET_HANDOFF_PROMPT, {
232
+ deliverAs: 'followUp',
233
+ });
234
+ }
221
235
  return;
222
236
  }
223
237
 
@@ -237,13 +251,27 @@ export const registerSubagentChildRuntime = (
237
251
 
238
252
  pi.on('agent_settled', () => {
239
253
  const policy = state.activePolicy;
254
+ if (!policy || state.repairRequested) return;
240
255
  if (
241
- !policy ||
242
- state.repairRequested ||
243
- !needsCompletionRepair({ policy, dependencies })
256
+ state.runtimeMode === 'handoff' &&
257
+ needsCompletionRepair({ policy, dependencies })
244
258
  ) {
245
- return;
259
+ try {
260
+ writeChildResult({
261
+ policy,
262
+ result: toolBudgetHandoffResult(
263
+ policy,
264
+ state.productiveToolCallIds.size,
265
+ ),
266
+ dependencies,
267
+ });
268
+ return;
269
+ } catch {
270
+ // Preserve the normal completion-repair path so the parent gets the
271
+ // correlated result failure if the protected write cannot be made.
272
+ }
246
273
  }
274
+ if (!needsCompletionRepair({ policy, dependencies })) return;
247
275
  state = {
248
276
  ...state,
249
277
  repairRequested: true,
@@ -309,7 +337,7 @@ export const registerSubagentChildRuntime = (
309
337
  return {
310
338
  block: true,
311
339
  reason:
312
- 'Productive tool-call budget is exhausted; only structured_output is available for handoff',
340
+ 'Productive tool-call budget is exhausted; the extension-owned handoff is already persisted',
313
341
  };
314
342
  }
315
343
  if (CHILD_COORDINATION_TOOLS.has(event.toolName)) {
@@ -51,6 +51,7 @@ type RenderStepPromptOptions = {
51
51
  };
52
52
 
53
53
  const RESUME_INPUT_PLACEHOLDER = /\{\{\s*resume\.input\s*\}\}/;
54
+ const WORKFLOW_INPUT_PLACEHOLDER = /\{\{\s*workflow\.input\s*\}\}/;
54
55
 
55
56
  const renderStepPrompt = ({
56
57
  workflow,
@@ -110,6 +111,7 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
110
111
  const isDelegated = execution === 'delegated';
111
112
  const promptTemplate = workflow.prompts[run.currentStepId] ?? '';
112
113
  const prompt = renderStepPrompt({ workflow, run, step, execution });
114
+ const showWorkflowInput = WORKFLOW_INPUT_PLACEHOLDER.test(promptTemplate);
113
115
  const handoff = currentStepHandoff(run);
114
116
  const contract = createStepContract({ workflow, run, step });
115
117
  const completionTool = isDelegated
@@ -135,6 +137,9 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
135
137
  : []),
136
138
  prompt,
137
139
  '',
140
+ ...(showWorkflowInput
141
+ ? ['## Original workflow request', '', run.input || '(none supplied)', '']
142
+ : []),
138
143
  ...(isDelegated ? buildDelegatedHandoffSection(handoff) : []),
139
144
  ...buildRestartWorkspaceSection(run.restartWorkspaceCwd),
140
145
  ...buildResumeInputSection(
@@ -45,6 +45,37 @@ const isObject = (value: unknown): value is Record<string, unknown> => {
45
45
  return value !== null && typeof value === 'object' && !Array.isArray(value);
46
46
  };
47
47
 
48
+ function validateNonSuccessSummary(outcome: string, summary: string): void {
49
+ if (outcome === 'blocked') {
50
+ const isActionable =
51
+ /^# Blocked:\s+.+/m.test(summary) &&
52
+ /^\s*\*\*Action:\*\*\s+.+/m.test(summary) &&
53
+ /^\s*\*\*Next:\*\*\s+.+/m.test(summary);
54
+ if (!isActionable) {
55
+ throw new Error(
56
+ 'blocked summary must identify the missing user prerequisite and next action',
57
+ );
58
+ }
59
+ }
60
+ if (outcome === 'retry') {
61
+ const describesTransientFailure = /\btransient\b/i.test(summary);
62
+ const hasSafeRetryCondition =
63
+ /\b(safe (retry|to retry)|retry (when|after|once))\b/i.test(summary);
64
+ const requestsUserInput =
65
+ /\b(provide|confirm|choose|approve|decide)\b/i.test(summary);
66
+ if (
67
+ !/^# Retry:\s+.+/m.test(summary) ||
68
+ !describesTransientFailure ||
69
+ !hasSafeRetryCondition ||
70
+ requestsUserInput
71
+ ) {
72
+ throw new Error(
73
+ 'retry summary must identify a transient failure and safe retry condition',
74
+ );
75
+ }
76
+ }
77
+ }
78
+
48
79
  function parseResultWorkspace(
49
80
  value: unknown,
50
81
  outcome: string,
@@ -131,6 +162,7 @@ export function parseWorkflowStepResult(
131
162
  `workflow step summary exceeds ${policy.summaryMaxChars} characters`,
132
163
  );
133
164
  }
165
+ validateNonSuccessSummary(value.outcome, summary);
134
166
  if (value.artifact !== undefined && typeof value.artifact !== 'string') {
135
167
  throw new Error('workflow step artifact must be a string');
136
168
  }