@wichayutdew/pi-workflows 3.5.0 → 3.6.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);
@@ -739,6 +744,7 @@ function parseWorkflowStep(value, stepId, path, errors) {
739
744
  title,
740
745
  prompt,
741
746
  ...agent ? { agent } : {},
747
+ ...maxToolCalls === undefined ? {} : { maxToolCalls },
742
748
  permissions,
743
749
  requires,
744
750
  transitions,
@@ -6393,6 +6399,9 @@ var POLICY_KEYS = new Set([
6393
6399
  "outcomes",
6394
6400
  "pauseOutcomes",
6395
6401
  "summaryMaxChars",
6402
+ "maxToolCalls",
6403
+ "handoffReserve",
6404
+ "totalToolCalls",
6396
6405
  "gateSubmitOutcome",
6397
6406
  "workspace"
6398
6407
  ]);
@@ -6411,6 +6420,29 @@ var rejectUnknownProperties = (value) => {
6411
6420
  throw new Error(`child policy has unknown property "${unknownKey}"`);
6412
6421
  }
6413
6422
  };
6423
+ var parseToolBudget = (value) => {
6424
+ const fields2 = [
6425
+ value.maxToolCalls,
6426
+ value.handoffReserve,
6427
+ value.totalToolCalls
6428
+ ];
6429
+ if (fields2.every((field) => field === undefined))
6430
+ return {};
6431
+ if (fields2.some((field) => field === undefined)) {
6432
+ throw new Error("child policy tool budget fields must be provided together");
6433
+ }
6434
+ const [maxToolCalls, handoffReserve, totalToolCalls] = fields2;
6435
+ if (typeof maxToolCalls !== "number" || !Number.isInteger(maxToolCalls) || maxToolCalls < 1 || maxToolCalls > 1e5) {
6436
+ throw new Error("child policy maxToolCalls is invalid");
6437
+ }
6438
+ if (handoffReserve !== 2) {
6439
+ throw new Error("child policy handoffReserve is invalid");
6440
+ }
6441
+ if (typeof totalToolCalls !== "number" || !Number.isInteger(totalToolCalls) || totalToolCalls !== maxToolCalls + handoffReserve) {
6442
+ throw new Error("child policy totalToolCalls is invalid");
6443
+ }
6444
+ return { maxToolCalls, handoffReserve, totalToolCalls };
6445
+ };
6414
6446
  var parseIdentityAndPaths = (value, environment) => {
6415
6447
  const requestId = requiredString(value, "requestId");
6416
6448
  const agent = requiredString(value, "agent");
@@ -6467,7 +6499,8 @@ var parseChildPolicy = (value, environment = DEFAULT_CHILD_POLICY_ENVIRONMENT) =
6467
6499
  rejectUnknownProperties(value);
6468
6500
  return {
6469
6501
  ...parseIdentityAndPaths(value, environment),
6470
- ...parseChildPolicySections(value)
6502
+ ...parseChildPolicySections(value),
6503
+ ...parseToolBudget(value)
6471
6504
  };
6472
6505
  };
6473
6506
 
@@ -6564,6 +6597,7 @@ var parseDelegatedStepResult = (value, policy) => {
6564
6597
  }
6565
6598
  };
6566
6599
  // src/harness/delegation-plan.ts
6600
+ var HANDOFF_RESERVE = 2;
6567
6601
  function createDelegationPlan(input, dependencies) {
6568
6602
  const { workflow, run, step, latestContext } = input;
6569
6603
  const agent = step.agent?.name;
@@ -6658,6 +6692,11 @@ function createDelegationPlan(input, dependencies) {
6658
6692
  const workspace = dependencies.createDelegationWorkspace();
6659
6693
  const outcomes = allowedOutcomes(workflow, run);
6660
6694
  const outcomeSet = new Set(outcomes);
6695
+ const toolBudget = step.maxToolCalls === undefined ? {} : {
6696
+ maxToolCalls: step.maxToolCalls,
6697
+ handoffReserve: HANDOFF_RESERVE,
6698
+ totalToolCalls: step.maxToolCalls + HANDOFF_RESERVE
6699
+ };
6661
6700
  const policyDigest = digest({
6662
6701
  version: 1,
6663
6702
  requestId,
@@ -6670,6 +6709,7 @@ function createDelegationPlan(input, dependencies) {
6670
6709
  resultPath: workspace.resultPath,
6671
6710
  permissions: step.permissions,
6672
6711
  outcomes,
6712
+ ...toolBudget,
6673
6713
  ...step.workspace ? { workspace: step.workspace } : {}
6674
6714
  });
6675
6715
  const policy = {
@@ -6689,6 +6729,7 @@ function createDelegationPlan(input, dependencies) {
6689
6729
  outcomes,
6690
6730
  pauseOutcomes: Object.entries(step.transitions).filter(([outcome, target]) => target === "$pause" && outcomeSet.has(outcome)).map(([outcome]) => outcome),
6691
6731
  summaryMaxChars: workflow.definition.summaryMaxChars,
6732
+ ...toolBudget,
6692
6733
  ...step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {},
6693
6734
  ...step.workspace ? { workspace: structuredClone(step.workspace) } : {}
6694
6735
  };
@@ -7951,6 +7992,24 @@ var COMPLETION_REPAIR_PROMPT = [
7951
7992
  "Call `structured_output` exactly once, alone, with one configured outcome and the required summary, artifact, and workspace fields."
7952
7993
  ].join(`
7953
7994
  `);
7995
+ var toolBudgetWarningPrompt = ({
7996
+ productiveCalls,
7997
+ productiveRemaining,
7998
+ handoffReserve
7999
+ }) => [
8000
+ "Tool-call budget warning.",
8001
+ `Productive calls used: ${productiveCalls}.`,
8002
+ `Productive calls remaining: ${productiveRemaining}.`,
8003
+ `Handoff reserve: ${handoffReserve} calls.`,
8004
+ "Begin preparing a compact handoff now."
8005
+ ].join(`
8006
+ `);
8007
+ var TOOL_BUDGET_HANDOFF_PROMPT = [
8008
+ "The productive tool-call budget is exhausted.",
8009
+ "Work tools are locked; do not execute further work.",
8010
+ "Call `structured_output` exactly once, alone, with one configured outcome and a compact handoff."
8011
+ ].join(`
8012
+ `);
7954
8013
  var needsCompletionRepair = ({
7955
8014
  policy,
7956
8015
  dependencies
@@ -8040,6 +8099,16 @@ var childSystemPrompt = (policy) => {
8040
8099
  `Valid outcomes: ${policy.outcomes.join(", ")}`,
8041
8100
  `Pause outcomes: ${policy.pauseOutcomes.join(", ") || "(none)"}`,
8042
8101
  `Summary limit: ${policy.summaryMaxChars} characters`,
8102
+ ...policy.maxToolCalls === undefined ? [] : [
8103
+ "",
8104
+ "## Tool-call budget",
8105
+ `Productive tool-call budget: ${policy.maxToolCalls} calls.`,
8106
+ `Handoff reserve: ${policy.handoffReserve} calls.`,
8107
+ `Total tool-call budget: ${policy.totalToolCalls} calls.`,
8108
+ "At 2 productive calls remaining, begin handoff.",
8109
+ "Work tools are locked when the productive budget is exhausted.",
8110
+ "Continue with `handoff` using the reserved calls."
8111
+ ],
8043
8112
  ...policy.gateSubmitOutcome ? [
8044
8113
  `Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`
8045
8114
  ] : [],
@@ -8060,7 +8129,9 @@ var INITIAL_STATE = {
8060
8129
  policyError: undefined,
8061
8130
  invalidCompletionCalls: new Set,
8062
8131
  effectiveTools: new Set,
8063
- repairRequested: false
8132
+ productiveToolCallIds: new Set,
8133
+ repairRequested: false,
8134
+ runtimeMode: "working"
8064
8135
  };
8065
8136
  var errorMessage2 = (error) => error instanceof Error ? error.message : String(error);
8066
8137
  var invalidPolicyInput = (pi, policyError, images) => {
@@ -8127,7 +8198,9 @@ var registerSubagentChildRuntime = (pi, options = {}) => {
8127
8198
  activePolicy: extracted.policy,
8128
8199
  policyError: undefined,
8129
8200
  effectiveTools,
8130
- repairRequested: false
8201
+ productiveToolCallIds: new Set,
8202
+ repairRequested: false,
8203
+ runtimeMode: "working"
8131
8204
  };
8132
8205
  } catch (error) {
8133
8206
  const policyError = errorMessage2(error);
@@ -8160,6 +8233,38 @@ ${childSystemPrompt(state.activePolicy)}`
8160
8233
  pi.on("turn_start", () => {
8161
8234
  state = { ...state, invalidCompletionCalls: new Set };
8162
8235
  });
8236
+ pi.on("tool_execution_end", (event) => {
8237
+ const policy = state.activePolicy;
8238
+ const handoffReserve = policy?.handoffReserve;
8239
+ if (!policy || policy.maxToolCalls === undefined || handoffReserve === undefined || event.toolName === CHILD_COMPLETION_TOOL || state.runtimeMode === "handoff" || state.productiveToolCallIds.has(event.toolCallId)) {
8240
+ return;
8241
+ }
8242
+ const productiveToolCallIds = new Set(state.productiveToolCallIds);
8243
+ productiveToolCallIds.add(event.toolCallId);
8244
+ const productiveCalls = productiveToolCallIds.size;
8245
+ if (productiveCalls >= policy.maxToolCalls) {
8246
+ state = {
8247
+ ...state,
8248
+ effectiveTools: new Set([CHILD_COMPLETION_TOOL]),
8249
+ productiveToolCallIds,
8250
+ runtimeMode: "handoff"
8251
+ };
8252
+ pi.setActiveTools([CHILD_COMPLETION_TOOL]);
8253
+ pi.sendUserMessage(TOOL_BUDGET_HANDOFF_PROMPT, {
8254
+ deliverAs: "followUp"
8255
+ });
8256
+ return;
8257
+ }
8258
+ state = { ...state, productiveToolCallIds };
8259
+ const productiveRemaining = policy.maxToolCalls - productiveCalls;
8260
+ if (productiveRemaining <= handoffReserve) {
8261
+ pi.sendUserMessage(toolBudgetWarningPrompt({
8262
+ productiveCalls,
8263
+ productiveRemaining,
8264
+ handoffReserve
8265
+ }), { deliverAs: "followUp" });
8266
+ }
8267
+ });
8163
8268
  pi.on("agent_settled", () => {
8164
8269
  const policy = state.activePolicy;
8165
8270
  if (!policy || state.repairRequested || !needsCompletionRepair({ policy, dependencies })) {
@@ -8223,6 +8328,12 @@ ${childSystemPrompt(state.activePolicy)}`
8223
8328
  };
8224
8329
  }
8225
8330
  }
8331
+ if (state.runtimeMode === "handoff") {
8332
+ return {
8333
+ block: true,
8334
+ reason: "Productive tool-call budget is exhausted; only structured_output is available for handoff"
8335
+ };
8336
+ }
8226
8337
  if (CHILD_COORDINATION_TOOLS.has(event.toolName)) {
8227
8338
  return {
8228
8339
  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.6.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`,
@@ -445,6 +453,7 @@ export function parseWorkflowStep(
445
453
  title,
446
454
  prompt,
447
455
  ...(agent ? { agent } : {}),
456
+ ...(maxToolCalls === undefined ? {} : { maxToolCalls }),
448
457
  permissions,
449
458
  requires,
450
459
  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,14 @@ 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
+ };
159
169
  const policyDigest = digest({
160
170
  version: 1,
161
171
  requestId,
@@ -168,6 +178,7 @@ export function createDelegationPlan(
168
178
  resultPath: workspace.resultPath,
169
179
  permissions: step.permissions,
170
180
  outcomes,
181
+ ...toolBudget,
171
182
  ...(step.workspace ? { workspace: step.workspace } : {}),
172
183
  });
173
184
  const policy: ChildStepPolicy = {
@@ -191,6 +202,7 @@ export function createDelegationPlan(
191
202
  )
192
203
  .map(([outcome]) => outcome),
193
204
  summaryMaxChars: workflow.definition.summaryMaxChars,
205
+ ...toolBudget,
194
206
  ...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
195
207
  ...(step.workspace ? { workspace: structuredClone(step.workspace) } : {}),
196
208
  };
@@ -22,6 +22,12 @@ 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;
25
31
  readonly gateSubmitOutcome?: string;
26
32
  readonly workspace?: StepWorkspaceBinding;
27
33
  };
@@ -28,6 +28,9 @@ const POLICY_KEYS: ReadonlySet<string> = new Set([
28
28
  'outcomes',
29
29
  'pauseOutcomes',
30
30
  'summaryMaxChars',
31
+ 'maxToolCalls',
32
+ 'handoffReserve',
33
+ 'totalToolCalls',
31
34
  'gateSubmitOutcome',
32
35
  'workspace',
33
36
  ]);
@@ -75,6 +78,48 @@ const rejectUnknownProperties = (
75
78
  }
76
79
  };
77
80
 
81
+ type ToolBudget = Pick<
82
+ ChildStepPolicy,
83
+ 'maxToolCalls' | 'handoffReserve' | 'totalToolCalls'
84
+ >;
85
+
86
+ const parseToolBudget = (
87
+ value: Readonly<Record<string, unknown>>,
88
+ ): ToolBudget => {
89
+ const fields = [
90
+ value.maxToolCalls,
91
+ value.handoffReserve,
92
+ value.totalToolCalls,
93
+ ];
94
+ if (fields.every((field) => field === undefined)) return {};
95
+ if (fields.some((field) => field === undefined)) {
96
+ throw new Error(
97
+ 'child policy tool budget fields must be provided together',
98
+ );
99
+ }
100
+
101
+ const [maxToolCalls, handoffReserve, totalToolCalls] = fields;
102
+ if (
103
+ typeof maxToolCalls !== 'number' ||
104
+ !Number.isInteger(maxToolCalls) ||
105
+ maxToolCalls < 1 ||
106
+ maxToolCalls > 100_000
107
+ ) {
108
+ throw new Error('child policy maxToolCalls is invalid');
109
+ }
110
+ if (handoffReserve !== 2) {
111
+ throw new Error('child policy handoffReserve is invalid');
112
+ }
113
+ if (
114
+ typeof totalToolCalls !== 'number' ||
115
+ !Number.isInteger(totalToolCalls) ||
116
+ totalToolCalls !== maxToolCalls + handoffReserve
117
+ ) {
118
+ throw new Error('child policy totalToolCalls is invalid');
119
+ }
120
+ return { maxToolCalls, handoffReserve, totalToolCalls };
121
+ };
122
+
78
123
  type IdentityAndPaths = Pick<
79
124
  ChildStepPolicy,
80
125
  | 'version'
@@ -164,5 +209,6 @@ export const parseChildPolicy = (
164
209
  return {
165
210
  ...parseIdentityAndPaths(value, environment),
166
211
  ...parseChildPolicySections(value),
212
+ ...parseToolBudget(value),
167
213
  };
168
214
  };
@@ -34,6 +34,18 @@ export const childSystemPrompt = (policy: ChildStepPolicy): string => {
34
34
  `Valid outcomes: ${policy.outcomes.join(', ')}`,
35
35
  `Pause outcomes: ${policy.pauseOutcomes.join(', ') || '(none)'}`,
36
36
  `Summary limit: ${policy.summaryMaxChars} characters`,
37
+ ...(policy.maxToolCalls === undefined
38
+ ? []
39
+ : [
40
+ '',
41
+ '## Tool-call budget',
42
+ `Productive tool-call budget: ${policy.maxToolCalls} calls.`,
43
+ `Handoff reserve: ${policy.handoffReserve} calls.`,
44
+ `Total tool-call budget: ${policy.totalToolCalls} calls.`,
45
+ 'At 2 productive calls remaining, begin handoff.',
46
+ 'Work tools are locked when the productive budget is exhausted.',
47
+ 'Continue with `handoff` using the reserved calls.',
48
+ ]),
37
49
  ...(policy.gateSubmitOutcome
38
50
  ? [
39
51
  `Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`,
@@ -7,6 +7,29 @@ export const COMPLETION_REPAIR_PROMPT = [
7
7
  'Call `structured_output` exactly once, alone, with one configured outcome and the required summary, artifact, and workspace fields.',
8
8
  ].join('\n');
9
9
 
10
+ export const toolBudgetWarningPrompt = ({
11
+ productiveCalls,
12
+ productiveRemaining,
13
+ handoffReserve,
14
+ }: {
15
+ readonly productiveCalls: number;
16
+ readonly productiveRemaining: number;
17
+ readonly handoffReserve: number;
18
+ }): string =>
19
+ [
20
+ 'Tool-call budget warning.',
21
+ `Productive calls used: ${productiveCalls}.`,
22
+ `Productive calls remaining: ${productiveRemaining}.`,
23
+ `Handoff reserve: ${handoffReserve} calls.`,
24
+ 'Begin preparing a compact handoff now.',
25
+ ].join('\n');
26
+
27
+ export const TOOL_BUDGET_HANDOFF_PROMPT = [
28
+ 'The productive tool-call budget is exhausted.',
29
+ '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
+ ].join('\n');
32
+
10
33
  /** Returns whether a same-child completion repair may be requested safely. */
11
34
  export const needsCompletionRepair = ({
12
35
  policy,
@@ -16,6 +16,8 @@ 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
+ toolBudgetWarningPrompt,
19
21
  } from './child-runtime-repair.ts';
20
22
  import {
21
23
  verifyChildCapability,
@@ -43,7 +45,9 @@ type ChildRuntimeState = {
43
45
  readonly policyError: string | undefined;
44
46
  readonly invalidCompletionCalls: ReadonlySet<string>;
45
47
  readonly effectiveTools: ReadonlySet<string>;
48
+ readonly productiveToolCallIds: ReadonlySet<string>;
46
49
  readonly repairRequested: boolean;
50
+ readonly runtimeMode: 'working' | 'handoff';
47
51
  };
48
52
 
49
53
  const INITIAL_STATE: ChildRuntimeState = {
@@ -51,7 +55,9 @@ const INITIAL_STATE: ChildRuntimeState = {
51
55
  policyError: undefined,
52
56
  invalidCompletionCalls: new Set(),
53
57
  effectiveTools: new Set(),
58
+ productiveToolCallIds: new Set(),
54
59
  repairRequested: false,
60
+ runtimeMode: 'working',
55
61
  };
56
62
 
57
63
  const errorMessage = (error: unknown): string =>
@@ -148,7 +154,9 @@ export const registerSubagentChildRuntime = (
148
154
  activePolicy: extracted.policy,
149
155
  policyError: undefined,
150
156
  effectiveTools,
157
+ productiveToolCallIds: new Set(),
151
158
  repairRequested: false,
159
+ runtimeMode: 'working',
152
160
  };
153
161
  } catch (error) {
154
162
  const policyError = errorMessage(error);
@@ -182,6 +190,51 @@ export const registerSubagentChildRuntime = (
182
190
  state = { ...state, invalidCompletionCalls: new Set() };
183
191
  });
184
192
 
193
+ pi.on('tool_execution_end', (event) => {
194
+ const policy = state.activePolicy;
195
+ const handoffReserve = policy?.handoffReserve;
196
+ if (
197
+ !policy ||
198
+ policy.maxToolCalls === undefined ||
199
+ handoffReserve === undefined ||
200
+ event.toolName === CHILD_COMPLETION_TOOL ||
201
+ state.runtimeMode === 'handoff' ||
202
+ state.productiveToolCallIds.has(event.toolCallId)
203
+ ) {
204
+ return;
205
+ }
206
+
207
+ const productiveToolCallIds = new Set(state.productiveToolCallIds);
208
+ productiveToolCallIds.add(event.toolCallId);
209
+ const productiveCalls = productiveToolCallIds.size;
210
+ if (productiveCalls >= policy.maxToolCalls) {
211
+ state = {
212
+ ...state,
213
+ effectiveTools: new Set([CHILD_COMPLETION_TOOL]),
214
+ productiveToolCallIds,
215
+ runtimeMode: 'handoff',
216
+ };
217
+ pi.setActiveTools([CHILD_COMPLETION_TOOL]);
218
+ pi.sendUserMessage(TOOL_BUDGET_HANDOFF_PROMPT, {
219
+ deliverAs: 'followUp',
220
+ });
221
+ return;
222
+ }
223
+
224
+ state = { ...state, productiveToolCallIds };
225
+ const productiveRemaining = policy.maxToolCalls - productiveCalls;
226
+ if (productiveRemaining <= handoffReserve) {
227
+ pi.sendUserMessage(
228
+ toolBudgetWarningPrompt({
229
+ productiveCalls,
230
+ productiveRemaining,
231
+ handoffReserve,
232
+ }),
233
+ { deliverAs: 'followUp' },
234
+ );
235
+ }
236
+ });
237
+
185
238
  pi.on('agent_settled', () => {
186
239
  const policy = state.activePolicy;
187
240
  if (
@@ -252,6 +305,13 @@ export const registerSubagentChildRuntime = (
252
305
  };
253
306
  }
254
307
  }
308
+ if (state.runtimeMode === 'handoff') {
309
+ return {
310
+ block: true,
311
+ reason:
312
+ 'Productive tool-call budget is exhausted; only structured_output is available for handoff',
313
+ };
314
+ }
255
315
  if (CHILD_COORDINATION_TOOLS.has(event.toolName)) {
256
316
  return {
257
317
  block: true,