@wichayutdew/pi-workflows 3.4.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,
@@ -3195,6 +3201,9 @@ function wrapPlain(value, width, theme, color = "text") {
3195
3201
  });
3196
3202
  return wrapped.length > 0 ? wrapped : [theme.fg(color, " ")];
3197
3203
  }
3204
+ function summaryLines(theme, value, width) {
3205
+ return [theme.fg("muted", "summary"), ...wrapPlain(value, width, theme)];
3206
+ }
3198
3207
  function boundedArtifact(value) {
3199
3208
  return value.length <= MAX_DETAIL_ARTIFACT_CHARS ? { value, truncated: false } : {
3200
3209
  value: value.slice(0, MAX_DETAIL_ARTIFACT_CHARS),
@@ -3210,7 +3219,8 @@ function renderResult(theme, attempt, width) {
3210
3219
  "",
3211
3220
  theme.bold(theme.fg("accent", "Submitted result")),
3212
3221
  ...keyValueLines(theme, "outcome", result.outcome, width, "success"),
3213
- ...keyValueLines(theme, "summary", `${result.summary}${result.summaryTruncated ? " … [trace truncated]" : ""}`, width),
3222
+ ...summaryLines(theme, `${result.summary}${result.summaryTruncated ? `
3223
+ … [trace truncated]` : ""}`, width),
3214
3224
  ...result.workspaceCwd ? keyValueLines(theme, "workspace", result.workspaceCwd, width) : [],
3215
3225
  ...artifact ? [
3216
3226
  theme.fg("muted", "artifact"),
@@ -3364,7 +3374,7 @@ function renderStepDetail(theme, snapshot, selectedIndex, cache, width) {
3364
3374
  ] : [],
3365
3375
  ...history ? [
3366
3376
  ...keyValueLines(theme, "outcome", history.outcome, width, "success"),
3367
- ...keyValueLines(theme, "summary", history.summary, width)
3377
+ ...summaryLines(theme, history.summary, width)
3368
3378
  ] : [],
3369
3379
  ...currentReason ? keyValueLines(theme, "reason", currentReason, width, "warning") : [],
3370
3380
  ...entry.isCurrent && snapshot.run.gateFeedback ? keyValueLines(theme, "feedback", snapshot.run.gateFeedback, width, "warning") : [],
@@ -6122,6 +6132,8 @@ function buildStepTask(options) {
6122
6132
  ...contract.workspaceLines,
6123
6133
  "",
6124
6134
  "Put a self-contained compact handoff in `summary`; this is the only step context passed to the next fresh child.",
6135
+ "Format all human-facing output—including summaries, gate artifacts, Markdown plans, reports, comments, and replies—for scanning: short headings, then one distinct fact, action, or metadata value per bullet or paragraph. Never pack unrelated values into one line or dense prose.",
6136
+ "When a schema needs several related fields, use a bullet list with one `field`: `value` per row; use JSON only for machine-readable data under `## Machine-readable handoff` in a fenced valid `json` block. Keep prose outside that block.",
6125
6137
  ...buildNonSuccessSummaryInstructions(contract.outcomes),
6126
6138
  ...isDelegated ? buildDelegatedCompletionInstructions() : [],
6127
6139
  "Do not call the completion tool alongside other tool calls."
@@ -6387,6 +6399,9 @@ var POLICY_KEYS = new Set([
6387
6399
  "outcomes",
6388
6400
  "pauseOutcomes",
6389
6401
  "summaryMaxChars",
6402
+ "maxToolCalls",
6403
+ "handoffReserve",
6404
+ "totalToolCalls",
6390
6405
  "gateSubmitOutcome",
6391
6406
  "workspace"
6392
6407
  ]);
@@ -6405,6 +6420,29 @@ var rejectUnknownProperties = (value) => {
6405
6420
  throw new Error(`child policy has unknown property "${unknownKey}"`);
6406
6421
  }
6407
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
+ };
6408
6446
  var parseIdentityAndPaths = (value, environment) => {
6409
6447
  const requestId = requiredString(value, "requestId");
6410
6448
  const agent = requiredString(value, "agent");
@@ -6461,7 +6499,8 @@ var parseChildPolicy = (value, environment = DEFAULT_CHILD_POLICY_ENVIRONMENT) =
6461
6499
  rejectUnknownProperties(value);
6462
6500
  return {
6463
6501
  ...parseIdentityAndPaths(value, environment),
6464
- ...parseChildPolicySections(value)
6502
+ ...parseChildPolicySections(value),
6503
+ ...parseToolBudget(value)
6465
6504
  };
6466
6505
  };
6467
6506
 
@@ -6558,6 +6597,7 @@ var parseDelegatedStepResult = (value, policy) => {
6558
6597
  }
6559
6598
  };
6560
6599
  // src/harness/delegation-plan.ts
6600
+ var HANDOFF_RESERVE = 2;
6561
6601
  function createDelegationPlan(input, dependencies) {
6562
6602
  const { workflow, run, step, latestContext } = input;
6563
6603
  const agent = step.agent?.name;
@@ -6652,6 +6692,11 @@ function createDelegationPlan(input, dependencies) {
6652
6692
  const workspace = dependencies.createDelegationWorkspace();
6653
6693
  const outcomes = allowedOutcomes(workflow, run);
6654
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
+ };
6655
6700
  const policyDigest = digest({
6656
6701
  version: 1,
6657
6702
  requestId,
@@ -6664,6 +6709,7 @@ function createDelegationPlan(input, dependencies) {
6664
6709
  resultPath: workspace.resultPath,
6665
6710
  permissions: step.permissions,
6666
6711
  outcomes,
6712
+ ...toolBudget,
6667
6713
  ...step.workspace ? { workspace: step.workspace } : {}
6668
6714
  });
6669
6715
  const policy = {
@@ -6683,6 +6729,7 @@ function createDelegationPlan(input, dependencies) {
6683
6729
  outcomes,
6684
6730
  pauseOutcomes: Object.entries(step.transitions).filter(([outcome, target]) => target === "$pause" && outcomeSet.has(outcome)).map(([outcome]) => outcome),
6685
6731
  summaryMaxChars: workflow.definition.summaryMaxChars,
6732
+ ...toolBudget,
6686
6733
  ...step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {},
6687
6734
  ...step.workspace ? { workspace: structuredClone(step.workspace) } : {}
6688
6735
  };
@@ -7945,6 +7992,24 @@ var COMPLETION_REPAIR_PROMPT = [
7945
7992
  "Call `structured_output` exactly once, alone, with one configured outcome and the required summary, artifact, and workspace fields."
7946
7993
  ].join(`
7947
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
+ `);
7948
8013
  var needsCompletionRepair = ({
7949
8014
  policy,
7950
8015
  dependencies
@@ -8034,6 +8099,16 @@ var childSystemPrompt = (policy) => {
8034
8099
  `Valid outcomes: ${policy.outcomes.join(", ")}`,
8035
8100
  `Pause outcomes: ${policy.pauseOutcomes.join(", ") || "(none)"}`,
8036
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
+ ],
8037
8112
  ...policy.gateSubmitOutcome ? [
8038
8113
  `Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`
8039
8114
  ] : [],
@@ -8054,7 +8129,9 @@ var INITIAL_STATE = {
8054
8129
  policyError: undefined,
8055
8130
  invalidCompletionCalls: new Set,
8056
8131
  effectiveTools: new Set,
8057
- repairRequested: false
8132
+ productiveToolCallIds: new Set,
8133
+ repairRequested: false,
8134
+ runtimeMode: "working"
8058
8135
  };
8059
8136
  var errorMessage2 = (error) => error instanceof Error ? error.message : String(error);
8060
8137
  var invalidPolicyInput = (pi, policyError, images) => {
@@ -8121,7 +8198,9 @@ var registerSubagentChildRuntime = (pi, options = {}) => {
8121
8198
  activePolicy: extracted.policy,
8122
8199
  policyError: undefined,
8123
8200
  effectiveTools,
8124
- repairRequested: false
8201
+ productiveToolCallIds: new Set,
8202
+ repairRequested: false,
8203
+ runtimeMode: "working"
8125
8204
  };
8126
8205
  } catch (error) {
8127
8206
  const policyError = errorMessage2(error);
@@ -8154,6 +8233,38 @@ ${childSystemPrompt(state.activePolicy)}`
8154
8233
  pi.on("turn_start", () => {
8155
8234
  state = { ...state, invalidCompletionCalls: new Set };
8156
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
+ });
8157
8268
  pi.on("agent_settled", () => {
8158
8269
  const policy = state.activePolicy;
8159
8270
  if (!policy || state.repairRequested || !needsCompletionRepair({ policy, dependencies })) {
@@ -8217,6 +8328,12 @@ ${childSystemPrompt(state.activePolicy)}`
8217
8328
  };
8218
8329
  }
8219
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
+ }
8220
8337
  if (CHILD_COORDINATION_TOOLS.has(event.toolName)) {
8221
8338
  return {
8222
8339
  block: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wichayutdew/pi-workflows",
3
- "version": "3.4.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,
@@ -151,6 +151,8 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
151
151
  ...contract.workspaceLines,
152
152
  '',
153
153
  'Put a self-contained compact handoff in `summary`; this is the only step context passed to the next fresh child.',
154
+ 'Format all human-facing output—including summaries, gate artifacts, Markdown plans, reports, comments, and replies—for scanning: short headings, then one distinct fact, action, or metadata value per bullet or paragraph. Never pack unrelated values into one line or dense prose.',
155
+ 'When a schema needs several related fields, use a bullet list with one `field`: `value` per row; use JSON only for machine-readable data under `## Machine-readable handoff` in a fenced valid `json` block. Keep prose outside that block.',
154
156
  ...buildNonSuccessSummaryInstructions(contract.outcomes),
155
157
  ...(isDelegated ? buildDelegatedCompletionInstructions() : []),
156
158
  'Do not call the completion tool alongside other tool calls.',
@@ -74,6 +74,14 @@ function wrapPlain(
74
74
  return wrapped.length > 0 ? wrapped : [theme.fg(color, ' ')];
75
75
  }
76
76
 
77
+ function summaryLines(
78
+ theme: WorkflowStatusTheme,
79
+ value: string,
80
+ width: number,
81
+ ): Array<string> {
82
+ return [theme.fg('muted', 'summary'), ...wrapPlain(value, width, theme)];
83
+ }
84
+
77
85
  function boundedArtifact(value: string): {
78
86
  readonly value: string;
79
87
  readonly truncated: boolean;
@@ -101,10 +109,9 @@ function renderResult(
101
109
  '',
102
110
  theme.bold(theme.fg('accent', 'Submitted result')),
103
111
  ...keyValueLines(theme, 'outcome', result.outcome, width, 'success'),
104
- ...keyValueLines(
112
+ ...summaryLines(
105
113
  theme,
106
- 'summary',
107
- `${result.summary}${result.summaryTruncated ? ' … [trace truncated]' : ''}`,
114
+ `${result.summary}${result.summaryTruncated ? '\n… [trace truncated]' : ''}`,
108
115
  width,
109
116
  ),
110
117
  ...(result.workspaceCwd
@@ -439,7 +446,7 @@ export function renderStepDetail(
439
446
  ...(history
440
447
  ? [
441
448
  ...keyValueLines(theme, 'outcome', history.outcome, width, 'success'),
442
- ...keyValueLines(theme, 'summary', history.summary, width),
449
+ ...summaryLines(theme, history.summary, width),
443
450
  ]
444
451
  : []),
445
452
  ...(currentReason