@wichayutdew/pi-workflows 3.5.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
@@ -695,6 +695,7 @@ function parseWorkflowStep(value, stepId, path, errors) {
695
695
  "title",
696
696
  "prompt",
697
697
  "agent",
698
+ "maxToolCalls",
698
699
  "permissions",
699
700
  "requires",
700
701
  "transitions",
@@ -707,6 +708,10 @@ function parseWorkflowStep(value, stepId, path, errors) {
707
708
  pattern: AGENT_PROFILE_NAME_PATTERN
708
709
  });
709
710
  const agent = agentName ? { name: agentName } : undefined;
711
+ const maxToolCalls = value.maxToolCalls === undefined ? undefined : readInteger(value.maxToolCalls, 1, `${path}.maxToolCalls`, errors, {
712
+ min: 1,
713
+ max: 1e5
714
+ });
710
715
  const permissions = parsePermissions(value.permissions, `${path}.permissions`, errors);
711
716
  const requires = parseRequirements(value.requires, permissions, `${path}.requires`, errors);
712
717
  const transitions = parseTransitions(value.transitions, `${path}.transitions`, errors);
@@ -726,6 +731,13 @@ function parseWorkflowStep(value, stepId, path, errors) {
726
731
  errors.push(`${path}.transitions: artifact-contract retry requires a "retry" transition`);
727
732
  }
728
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
+ }
729
741
  if (workspace) {
730
742
  workspace.bindOn.forEach((outcome) => {
731
743
  if (!Object.hasOwn(transitions, outcome)) {
@@ -739,6 +751,7 @@ function parseWorkflowStep(value, stepId, path, errors) {
739
751
  title,
740
752
  prompt,
741
753
  ...agent ? { agent } : {},
754
+ ...maxToolCalls === undefined ? {} : { maxToolCalls },
742
755
  permissions,
743
756
  requires,
744
757
  transitions,
@@ -2609,6 +2622,22 @@ var RESULT_KEYS = new Set([
2609
2622
  var isObject = (value) => {
2610
2623
  return value !== null && typeof value === "object" && !Array.isArray(value);
2611
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
+ }
2612
2641
  function parseResultWorkspace(value, outcome, policy) {
2613
2642
  const requiresWorkspace = policy.workspace?.bindOn.includes(outcome) === true;
2614
2643
  if (!requiresWorkspace) {
@@ -2663,6 +2692,7 @@ function parseWorkflowStepResult(value, policy) {
2663
2692
  if (summary.length > policy.summaryMaxChars) {
2664
2693
  throw new Error(`workflow step summary exceeds ${policy.summaryMaxChars} characters`);
2665
2694
  }
2695
+ validateNonSuccessSummary(value.outcome, summary);
2666
2696
  if (value.artifact !== undefined && typeof value.artifact !== "string") {
2667
2697
  throw new Error("workflow step artifact must be a string");
2668
2698
  }
@@ -6053,6 +6083,7 @@ var resolveStep = (workflow, run) => {
6053
6083
  return step;
6054
6084
  };
6055
6085
  var RESUME_INPUT_PLACEHOLDER = /\{\{\s*resume\.input\s*\}\}/;
6086
+ var WORKFLOW_INPUT_PLACEHOLDER = /\{\{\s*workflow\.input\s*\}\}/;
6056
6087
  var renderStepPrompt = ({
6057
6088
  workflow,
6058
6089
  run,
@@ -6094,6 +6125,7 @@ function buildStepTask(options) {
6094
6125
  const isDelegated = execution === "delegated";
6095
6126
  const promptTemplate = workflow.prompts[run.currentStepId] ?? "";
6096
6127
  const prompt = renderStepPrompt({ workflow, run, step, execution });
6128
+ const showWorkflowInput = WORKFLOW_INPUT_PLACEHOLDER.test(promptTemplate);
6097
6129
  const handoff = currentStepHandoff(run);
6098
6130
  const contract = createStepContract({ workflow, run, step });
6099
6131
  const completionTool = isDelegated ? "structured_output" : "workflow_complete_step";
@@ -6113,6 +6145,7 @@ function buildStepTask(options) {
6113
6145
  ...step.agent ? ["## Role prompt", "", rolePrompt(step.agent.name), ""] : [],
6114
6146
  prompt,
6115
6147
  "",
6148
+ ...showWorkflowInput ? ["## Original workflow request", "", run.input || "(none supplied)", ""] : [],
6116
6149
  ...isDelegated ? buildDelegatedHandoffSection(handoff) : [],
6117
6150
  ...buildRestartWorkspaceSection(run.restartWorkspaceCwd),
6118
6151
  ...buildResumeInputSection(run, RESUME_INPUT_PLACEHOLDER.test(promptTemplate)),
@@ -6393,6 +6426,10 @@ var POLICY_KEYS = new Set([
6393
6426
  "outcomes",
6394
6427
  "pauseOutcomes",
6395
6428
  "summaryMaxChars",
6429
+ "maxToolCalls",
6430
+ "handoffReserve",
6431
+ "totalToolCalls",
6432
+ "handoffOutcome",
6396
6433
  "gateSubmitOutcome",
6397
6434
  "workspace"
6398
6435
  ]);
@@ -6411,6 +6448,29 @@ var rejectUnknownProperties = (value) => {
6411
6448
  throw new Error(`child policy has unknown property "${unknownKey}"`);
6412
6449
  }
6413
6450
  };
6451
+ var parseToolBudget = (value) => {
6452
+ const fields2 = [
6453
+ value.maxToolCalls,
6454
+ value.handoffReserve,
6455
+ value.totalToolCalls
6456
+ ];
6457
+ if (fields2.every((field) => field === undefined))
6458
+ return {};
6459
+ if (fields2.some((field) => field === undefined)) {
6460
+ throw new Error("child policy tool budget fields must be provided together");
6461
+ }
6462
+ const [maxToolCalls, handoffReserve, totalToolCalls] = fields2;
6463
+ if (typeof maxToolCalls !== "number" || !Number.isInteger(maxToolCalls) || maxToolCalls < 1 || maxToolCalls > 1e5) {
6464
+ throw new Error("child policy maxToolCalls is invalid");
6465
+ }
6466
+ if (handoffReserve !== 2) {
6467
+ throw new Error("child policy handoffReserve is invalid");
6468
+ }
6469
+ if (typeof totalToolCalls !== "number" || !Number.isInteger(totalToolCalls) || totalToolCalls !== maxToolCalls + handoffReserve) {
6470
+ throw new Error("child policy totalToolCalls is invalid");
6471
+ }
6472
+ return { maxToolCalls, handoffReserve, totalToolCalls };
6473
+ };
6414
6474
  var parseIdentityAndPaths = (value, environment) => {
6415
6475
  const requestId = requiredString(value, "requestId");
6416
6476
  const agent = requiredString(value, "agent");
@@ -6465,9 +6525,28 @@ var parseChildPolicy = (value, environment = DEFAULT_CHILD_POLICY_ENVIRONMENT) =
6465
6525
  if (!isRecord11(value))
6466
6526
  throw new Error("child policy must be an object");
6467
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
+ }
6468
6545
  return {
6469
6546
  ...parseIdentityAndPaths(value, environment),
6470
- ...parseChildPolicySections(value)
6547
+ ...sections,
6548
+ ...toolBudget,
6549
+ ...handoffOutcome ? { handoffOutcome } : {}
6471
6550
  };
6472
6551
  };
6473
6552
 
@@ -6564,6 +6643,7 @@ var parseDelegatedStepResult = (value, policy) => {
6564
6643
  }
6565
6644
  };
6566
6645
  // src/harness/delegation-plan.ts
6646
+ var HANDOFF_RESERVE = 2;
6567
6647
  function createDelegationPlan(input, dependencies) {
6568
6648
  const { workflow, run, step, latestContext } = input;
6569
6649
  const agent = step.agent?.name;
@@ -6658,6 +6738,12 @@ function createDelegationPlan(input, dependencies) {
6658
6738
  const workspace = dependencies.createDelegationWorkspace();
6659
6739
  const outcomes = allowedOutcomes(workflow, run);
6660
6740
  const outcomeSet = new Set(outcomes);
6741
+ const toolBudget = step.maxToolCalls === undefined ? {} : {
6742
+ maxToolCalls: step.maxToolCalls,
6743
+ handoffReserve: HANDOFF_RESERVE,
6744
+ totalToolCalls: step.maxToolCalls + HANDOFF_RESERVE,
6745
+ handoffOutcome: "handoff"
6746
+ };
6661
6747
  const policyDigest = digest({
6662
6748
  version: 1,
6663
6749
  requestId,
@@ -6670,6 +6756,7 @@ function createDelegationPlan(input, dependencies) {
6670
6756
  resultPath: workspace.resultPath,
6671
6757
  permissions: step.permissions,
6672
6758
  outcomes,
6759
+ ...toolBudget,
6673
6760
  ...step.workspace ? { workspace: step.workspace } : {}
6674
6761
  });
6675
6762
  const policy = {
@@ -6689,6 +6776,8 @@ function createDelegationPlan(input, dependencies) {
6689
6776
  outcomes,
6690
6777
  pauseOutcomes: Object.entries(step.transitions).filter(([outcome, target]) => target === "$pause" && outcomeSet.has(outcome)).map(([outcome]) => outcome),
6691
6778
  summaryMaxChars: workflow.definition.summaryMaxChars,
6779
+ ...toolBudget,
6780
+ ...step.maxToolCalls === undefined ? {} : { handoffOutcome: "handoff" },
6692
6781
  ...step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {},
6693
6782
  ...step.workspace ? { workspace: structuredClone(step.workspace) } : {}
6694
6783
  };
@@ -7951,6 +8040,45 @@ var COMPLETION_REPAIR_PROMPT = [
7951
8040
  "Call `structured_output` exactly once, alone, with one configured outcome and the required summary, artifact, and workspace fields."
7952
8041
  ].join(`
7953
8042
  `);
8043
+ var toolBudgetWarningPrompt = ({
8044
+ productiveCalls,
8045
+ productiveRemaining,
8046
+ handoffReserve
8047
+ }) => [
8048
+ "Tool-call budget warning.",
8049
+ `Productive calls used: ${productiveCalls}.`,
8050
+ `Productive calls remaining: ${productiveRemaining}.`,
8051
+ `Handoff reserve: ${handoffReserve} calls.`,
8052
+ "Begin preparing a compact handoff now."
8053
+ ].join(`
8054
+ `);
8055
+ var TOOL_BUDGET_HANDOFF_PROMPT = [
8056
+ "The productive tool-call budget is exhausted.",
8057
+ "Work tools are locked; do not execute further work.",
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."
8060
+ ].join(`
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
+ };
7954
8082
  var needsCompletionRepair = ({
7955
8083
  policy,
7956
8084
  dependencies
@@ -8035,11 +8163,25 @@ var childSystemPrompt = (policy) => {
8035
8163
  "",
8036
8164
  "The parent workflow harness owns orchestration and state transitions.",
8037
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.",
8038
8170
  "When finished, call `structured_output` exactly once and as the only tool call in that message.",
8039
8171
  "Pass the workflow result as its `value`: outcome, summary, optional artifact, and workspace only when required below.",
8040
8172
  `Valid outcomes: ${policy.outcomes.join(", ")}`,
8041
8173
  `Pause outcomes: ${policy.pauseOutcomes.join(", ") || "(none)"}`,
8042
8174
  `Summary limit: ${policy.summaryMaxChars} characters`,
8175
+ ...policy.maxToolCalls === undefined ? [] : [
8176
+ "",
8177
+ "## Tool-call budget",
8178
+ `Productive tool-call budget: ${policy.maxToolCalls} calls.`,
8179
+ `Handoff reserve: ${policy.handoffReserve} calls.`,
8180
+ `Total tool-call budget: ${policy.totalToolCalls} calls.`,
8181
+ "At 2 productive calls remaining, prepare a concise handoff with completed work, current state, remaining work, and any blocker.",
8182
+ "Work tools are locked when the productive budget is exhausted.",
8183
+ "If the child settles without a result after exhaustion, the extension writes the configured `handoff` structured result."
8184
+ ],
8043
8185
  ...policy.gateSubmitOutcome ? [
8044
8186
  `Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`
8045
8187
  ] : [],
@@ -8060,7 +8202,9 @@ var INITIAL_STATE = {
8060
8202
  policyError: undefined,
8061
8203
  invalidCompletionCalls: new Set,
8062
8204
  effectiveTools: new Set,
8063
- repairRequested: false
8205
+ productiveToolCallIds: new Set,
8206
+ repairRequested: false,
8207
+ runtimeMode: "working"
8064
8208
  };
8065
8209
  var errorMessage2 = (error) => error instanceof Error ? error.message : String(error);
8066
8210
  var invalidPolicyInput = (pi, policyError, images) => {
@@ -8127,7 +8271,9 @@ var registerSubagentChildRuntime = (pi, options = {}) => {
8127
8271
  activePolicy: extracted.policy,
8128
8272
  policyError: undefined,
8129
8273
  effectiveTools,
8130
- repairRequested: false
8274
+ productiveToolCallIds: new Set,
8275
+ repairRequested: false,
8276
+ runtimeMode: "working"
8131
8277
  };
8132
8278
  } catch (error) {
8133
8279
  const policyError = errorMessage2(error);
@@ -8160,11 +8306,67 @@ ${childSystemPrompt(state.activePolicy)}`
8160
8306
  pi.on("turn_start", () => {
8161
8307
  state = { ...state, invalidCompletionCalls: new Set };
8162
8308
  });
8309
+ pi.on("tool_execution_end", (event) => {
8310
+ const policy = state.activePolicy;
8311
+ const handoffReserve = policy?.handoffReserve;
8312
+ if (!policy || policy.maxToolCalls === undefined || handoffReserve === undefined || event.toolName === CHILD_COMPLETION_TOOL || state.runtimeMode === "handoff" || state.productiveToolCallIds.has(event.toolCallId)) {
8313
+ return;
8314
+ }
8315
+ const productiveToolCallIds = new Set(state.productiveToolCallIds);
8316
+ productiveToolCallIds.add(event.toolCallId);
8317
+ const productiveCalls = productiveToolCallIds.size;
8318
+ if (productiveCalls >= policy.maxToolCalls) {
8319
+ state = {
8320
+ ...state,
8321
+ effectiveTools: new Set,
8322
+ productiveToolCallIds,
8323
+ runtimeMode: "handoff"
8324
+ };
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
+ }
8342
+ return;
8343
+ }
8344
+ state = { ...state, productiveToolCallIds };
8345
+ const productiveRemaining = policy.maxToolCalls - productiveCalls;
8346
+ if (productiveRemaining <= handoffReserve) {
8347
+ pi.sendUserMessage(toolBudgetWarningPrompt({
8348
+ productiveCalls,
8349
+ productiveRemaining,
8350
+ handoffReserve
8351
+ }), { deliverAs: "followUp" });
8352
+ }
8353
+ });
8163
8354
  pi.on("agent_settled", () => {
8164
8355
  const policy = state.activePolicy;
8165
- if (!policy || state.repairRequested || !needsCompletionRepair({ policy, dependencies })) {
8356
+ if (!policy || state.repairRequested)
8166
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 {}
8167
8367
  }
8368
+ if (!needsCompletionRepair({ policy, dependencies }))
8369
+ return;
8168
8370
  state = {
8169
8371
  ...state,
8170
8372
  repairRequested: true,
@@ -8223,6 +8425,12 @@ ${childSystemPrompt(state.activePolicy)}`
8223
8425
  };
8224
8426
  }
8225
8427
  }
8428
+ if (state.runtimeMode === "handoff") {
8429
+ return {
8430
+ block: true,
8431
+ reason: "Productive tool-call budget is exhausted; the extension-owned handoff is already persisted"
8432
+ };
8433
+ }
8226
8434
  if (CHILD_COORDINATION_TOOLS.has(event.toolName)) {
8227
8435
  return {
8228
8436
  block: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wichayutdew/pi-workflows",
3
- "version": "3.5.0",
3
+ "version": "3.7.0",
4
4
  "description": "A declarative, pauseable workflow harness for Pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -669,6 +669,11 @@
669
669
  "agent": {
670
670
  "$ref": "#/$defs/subagentRuntimeName"
671
671
  },
672
+ "maxToolCalls": {
673
+ "type": "integer",
674
+ "minimum": 1,
675
+ "maximum": 100000
676
+ },
672
677
  "permissions": {
673
678
  "$ref": "#/$defs/permissions"
674
679
  },
@@ -87,6 +87,7 @@ export type WorkflowStep = {
87
87
  readonly prompt: PromptSpec;
88
88
  /** Optional workflow-owned role prompt for this main-agent step. */
89
89
  readonly agent?: StepAgent;
90
+ readonly maxToolCalls?: number;
90
91
  readonly permissions: StepPermissions;
91
92
  readonly requires: StepRequirements;
92
93
  readonly transitions: Readonly<Record<string, StepTarget>>;
@@ -358,6 +358,7 @@ export function parseWorkflowStep(
358
358
  'title',
359
359
  'prompt',
360
360
  'agent',
361
+ 'maxToolCalls',
361
362
  'permissions',
362
363
  'requires',
363
364
  'transitions',
@@ -382,6 +383,13 @@ export function parseWorkflowStep(
382
383
  const agent: StepAgent | undefined = agentName
383
384
  ? { name: agentName }
384
385
  : undefined;
386
+ const maxToolCalls =
387
+ value.maxToolCalls === undefined
388
+ ? undefined
389
+ : readInteger(value.maxToolCalls, 1, `${path}.maxToolCalls`, errors, {
390
+ min: 1,
391
+ max: 100_000,
392
+ });
385
393
  const permissions = parsePermissions(
386
394
  value.permissions,
387
395
  `${path}.permissions`,
@@ -430,6 +438,17 @@ export function parseWorkflowStep(
430
438
  );
431
439
  }
432
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
+ }
433
452
  if (workspace) {
434
453
  workspace.bindOn.forEach((outcome) => {
435
454
  if (!Object.hasOwn(transitions, outcome)) {
@@ -445,6 +464,7 @@ export function parseWorkflowStep(
445
464
  title,
446
465
  prompt,
447
466
  ...(agent ? { agent } : {}),
467
+ ...(maxToolCalls === undefined ? {} : { maxToolCalls }),
448
468
  permissions,
449
469
  requires,
450
470
  transitions,
@@ -13,6 +13,8 @@ import { buildDelegatedStepTask } from '../prompt.ts';
13
13
  import type { WorkflowHarnessDependencies } from './dependencies.ts';
14
14
  import type { ActiveDelegation } from './types.ts';
15
15
 
16
+ const HANDOFF_RESERVE = 2;
17
+
16
18
  type DelegationPlanInput = {
17
19
  workflow: LoadedWorkflow;
18
20
  run: WorkflowRun;
@@ -156,6 +158,15 @@ export function createDelegationPlan(
156
158
  const workspace = dependencies.createDelegationWorkspace();
157
159
  const outcomes = allowedOutcomes(workflow, run);
158
160
  const outcomeSet = new Set(outcomes);
161
+ const toolBudget =
162
+ step.maxToolCalls === undefined
163
+ ? {}
164
+ : {
165
+ maxToolCalls: step.maxToolCalls,
166
+ handoffReserve: HANDOFF_RESERVE,
167
+ totalToolCalls: step.maxToolCalls + HANDOFF_RESERVE,
168
+ handoffOutcome: 'handoff',
169
+ };
159
170
  const policyDigest = digest({
160
171
  version: 1,
161
172
  requestId,
@@ -168,6 +179,7 @@ export function createDelegationPlan(
168
179
  resultPath: workspace.resultPath,
169
180
  permissions: step.permissions,
170
181
  outcomes,
182
+ ...toolBudget,
171
183
  ...(step.workspace ? { workspace: step.workspace } : {}),
172
184
  });
173
185
  const policy: ChildStepPolicy = {
@@ -191,6 +203,8 @@ export function createDelegationPlan(
191
203
  )
192
204
  .map(([outcome]) => outcome),
193
205
  summaryMaxChars: workflow.definition.summaryMaxChars,
206
+ ...toolBudget,
207
+ ...(step.maxToolCalls === undefined ? {} : { handoffOutcome: 'handoff' }),
194
208
  ...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
195
209
  ...(step.workspace ? { workspace: structuredClone(step.workspace) } : {}),
196
210
  };
@@ -22,6 +22,14 @@ export type ChildStepPolicy = {
22
22
  /** Outcomes that pause instead of advancing to another workflow step. */
23
23
  readonly pauseOutcomes: ReadonlyArray<string>;
24
24
  readonly summaryMaxChars: number;
25
+ /** Productive calls available before the mandatory handoff reserve. */
26
+ readonly maxToolCalls?: number;
27
+ /** Calls reserved for the child to hand off after productive work. */
28
+ readonly handoffReserve?: number;
29
+ /** Productive calls plus the mandatory handoff reserve. */
30
+ readonly totalToolCalls?: number;
31
+ /** Extension-owned outcome persisted when an exhausted child settles without a result. */
32
+ readonly handoffOutcome?: string;
25
33
  readonly gateSubmitOutcome?: string;
26
34
  readonly workspace?: StepWorkspaceBinding;
27
35
  };
@@ -28,6 +28,10 @@ const POLICY_KEYS: ReadonlySet<string> = new Set([
28
28
  'outcomes',
29
29
  'pauseOutcomes',
30
30
  'summaryMaxChars',
31
+ 'maxToolCalls',
32
+ 'handoffReserve',
33
+ 'totalToolCalls',
34
+ 'handoffOutcome',
31
35
  'gateSubmitOutcome',
32
36
  'workspace',
33
37
  ]);
@@ -75,6 +79,48 @@ const rejectUnknownProperties = (
75
79
  }
76
80
  };
77
81
 
82
+ type ToolBudget = Pick<
83
+ ChildStepPolicy,
84
+ 'maxToolCalls' | 'handoffReserve' | 'totalToolCalls'
85
+ >;
86
+
87
+ const parseToolBudget = (
88
+ value: Readonly<Record<string, unknown>>,
89
+ ): ToolBudget => {
90
+ const fields = [
91
+ value.maxToolCalls,
92
+ value.handoffReserve,
93
+ value.totalToolCalls,
94
+ ];
95
+ if (fields.every((field) => field === undefined)) return {};
96
+ if (fields.some((field) => field === undefined)) {
97
+ throw new Error(
98
+ 'child policy tool budget fields must be provided together',
99
+ );
100
+ }
101
+
102
+ const [maxToolCalls, handoffReserve, totalToolCalls] = fields;
103
+ if (
104
+ typeof maxToolCalls !== 'number' ||
105
+ !Number.isInteger(maxToolCalls) ||
106
+ maxToolCalls < 1 ||
107
+ maxToolCalls > 100_000
108
+ ) {
109
+ throw new Error('child policy maxToolCalls is invalid');
110
+ }
111
+ if (handoffReserve !== 2) {
112
+ throw new Error('child policy handoffReserve is invalid');
113
+ }
114
+ if (
115
+ typeof totalToolCalls !== 'number' ||
116
+ !Number.isInteger(totalToolCalls) ||
117
+ totalToolCalls !== maxToolCalls + handoffReserve
118
+ ) {
119
+ throw new Error('child policy totalToolCalls is invalid');
120
+ }
121
+ return { maxToolCalls, handoffReserve, totalToolCalls };
122
+ };
123
+
78
124
  type IdentityAndPaths = Pick<
79
125
  ChildStepPolicy,
80
126
  | 'version'
@@ -161,8 +207,27 @@ export const parseChildPolicy = (
161
207
  if (!isRecord(value)) throw new Error('child policy must be an object');
162
208
 
163
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
+ }
164
227
  return {
165
228
  ...parseIdentityAndPaths(value, environment),
166
- ...parseChildPolicySections(value),
229
+ ...sections,
230
+ ...toolBudget,
231
+ ...(handoffOutcome ? { handoffOutcome } : {}),
167
232
  };
168
233
  };
@@ -29,11 +29,27 @@ 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(', ')}`,
35
39
  `Pause outcomes: ${policy.pauseOutcomes.join(', ') || '(none)'}`,
36
40
  `Summary limit: ${policy.summaryMaxChars} characters`,
41
+ ...(policy.maxToolCalls === undefined
42
+ ? []
43
+ : [
44
+ '',
45
+ '## Tool-call budget',
46
+ `Productive tool-call budget: ${policy.maxToolCalls} calls.`,
47
+ `Handoff reserve: ${policy.handoffReserve} calls.`,
48
+ `Total tool-call budget: ${policy.totalToolCalls} calls.`,
49
+ 'At 2 productive calls remaining, prepare a concise handoff with completed work, current state, remaining work, and any blocker.',
50
+ 'Work tools are locked when the productive budget is exhausted.',
51
+ 'If the child settles without a result after exhaustion, the extension writes the configured `handoff` structured result.',
52
+ ]),
37
53
  ...(policy.gateSubmitOutcome
38
54
  ? [
39
55
  `Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`,
@@ -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
 
@@ -7,6 +8,56 @@ export const COMPLETION_REPAIR_PROMPT = [
7
8
  'Call `structured_output` exactly once, alone, with one configured outcome and the required summary, artifact, and workspace fields.',
8
9
  ].join('\n');
9
10
 
11
+ export const toolBudgetWarningPrompt = ({
12
+ productiveCalls,
13
+ productiveRemaining,
14
+ handoffReserve,
15
+ }: {
16
+ readonly productiveCalls: number;
17
+ readonly productiveRemaining: number;
18
+ readonly handoffReserve: number;
19
+ }): string =>
20
+ [
21
+ 'Tool-call budget warning.',
22
+ `Productive calls used: ${productiveCalls}.`,
23
+ `Productive calls remaining: ${productiveRemaining}.`,
24
+ `Handoff reserve: ${handoffReserve} calls.`,
25
+ 'Begin preparing a compact handoff now.',
26
+ ].join('\n');
27
+
28
+ export const TOOL_BUDGET_HANDOFF_PROMPT = [
29
+ 'The productive tool-call budget is exhausted.',
30
+ 'Work tools are locked; do not execute further work.',
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.',
33
+ ].join('\n');
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
+
10
61
  /** Returns whether a same-child completion repair may be requested safely. */
11
62
  export const needsCompletionRepair = ({
12
63
  policy,
@@ -16,6 +16,9 @@ import { DEFAULT_CHILD_RUNTIME_DEPENDENCIES } from './child-runtime-dependencies
16
16
  import {
17
17
  COMPLETION_REPAIR_PROMPT,
18
18
  needsCompletionRepair,
19
+ TOOL_BUDGET_HANDOFF_PROMPT,
20
+ toolBudgetHandoffResult,
21
+ toolBudgetWarningPrompt,
19
22
  } from './child-runtime-repair.ts';
20
23
  import {
21
24
  verifyChildCapability,
@@ -43,7 +46,9 @@ type ChildRuntimeState = {
43
46
  readonly policyError: string | undefined;
44
47
  readonly invalidCompletionCalls: ReadonlySet<string>;
45
48
  readonly effectiveTools: ReadonlySet<string>;
49
+ readonly productiveToolCallIds: ReadonlySet<string>;
46
50
  readonly repairRequested: boolean;
51
+ readonly runtimeMode: 'working' | 'handoff';
47
52
  };
48
53
 
49
54
  const INITIAL_STATE: ChildRuntimeState = {
@@ -51,7 +56,9 @@ const INITIAL_STATE: ChildRuntimeState = {
51
56
  policyError: undefined,
52
57
  invalidCompletionCalls: new Set(),
53
58
  effectiveTools: new Set(),
59
+ productiveToolCallIds: new Set(),
54
60
  repairRequested: false,
61
+ runtimeMode: 'working',
55
62
  };
56
63
 
57
64
  const errorMessage = (error: unknown): string =>
@@ -148,7 +155,9 @@ export const registerSubagentChildRuntime = (
148
155
  activePolicy: extracted.policy,
149
156
  policyError: undefined,
150
157
  effectiveTools,
158
+ productiveToolCallIds: new Set(),
151
159
  repairRequested: false,
160
+ runtimeMode: 'working',
152
161
  };
153
162
  } catch (error) {
154
163
  const policyError = errorMessage(error);
@@ -182,15 +191,87 @@ export const registerSubagentChildRuntime = (
182
191
  state = { ...state, invalidCompletionCalls: new Set() };
183
192
  });
184
193
 
185
- pi.on('agent_settled', () => {
194
+ pi.on('tool_execution_end', (event) => {
186
195
  const policy = state.activePolicy;
196
+ const handoffReserve = policy?.handoffReserve;
187
197
  if (
188
198
  !policy ||
189
- state.repairRequested ||
190
- !needsCompletionRepair({ policy, dependencies })
199
+ policy.maxToolCalls === undefined ||
200
+ handoffReserve === undefined ||
201
+ event.toolName === CHILD_COMPLETION_TOOL ||
202
+ state.runtimeMode === 'handoff' ||
203
+ state.productiveToolCallIds.has(event.toolCallId)
191
204
  ) {
192
205
  return;
193
206
  }
207
+
208
+ const productiveToolCallIds = new Set(state.productiveToolCallIds);
209
+ productiveToolCallIds.add(event.toolCallId);
210
+ const productiveCalls = productiveToolCallIds.size;
211
+ if (productiveCalls >= policy.maxToolCalls) {
212
+ state = {
213
+ ...state,
214
+ effectiveTools: new Set(),
215
+ productiveToolCallIds,
216
+ runtimeMode: 'handoff',
217
+ };
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
+ }
235
+ return;
236
+ }
237
+
238
+ state = { ...state, productiveToolCallIds };
239
+ const productiveRemaining = policy.maxToolCalls - productiveCalls;
240
+ if (productiveRemaining <= handoffReserve) {
241
+ pi.sendUserMessage(
242
+ toolBudgetWarningPrompt({
243
+ productiveCalls,
244
+ productiveRemaining,
245
+ handoffReserve,
246
+ }),
247
+ { deliverAs: 'followUp' },
248
+ );
249
+ }
250
+ });
251
+
252
+ pi.on('agent_settled', () => {
253
+ const policy = state.activePolicy;
254
+ if (!policy || state.repairRequested) return;
255
+ if (
256
+ state.runtimeMode === 'handoff' &&
257
+ needsCompletionRepair({ policy, dependencies })
258
+ ) {
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
+ }
273
+ }
274
+ if (!needsCompletionRepair({ policy, dependencies })) return;
194
275
  state = {
195
276
  ...state,
196
277
  repairRequested: true,
@@ -252,6 +333,13 @@ export const registerSubagentChildRuntime = (
252
333
  };
253
334
  }
254
335
  }
336
+ if (state.runtimeMode === 'handoff') {
337
+ return {
338
+ block: true,
339
+ reason:
340
+ 'Productive tool-call budget is exhausted; the extension-owned handoff is already persisted',
341
+ };
342
+ }
255
343
  if (CHILD_COORDINATION_TOOLS.has(event.toolName)) {
256
344
  return {
257
345
  block: true,
@@ -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
  }