@signalridge/pi-subagents 1.13.0 → 1.15.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.
@@ -21,6 +21,7 @@ import {
21
21
  SessionManager,
22
22
  SettingsManager,
23
23
  } from "@earendil-works/pi-coding-agent";
24
+ import { type ApprovalEvents, captureApprovalService, queryApprovalAdapter, requiresApprovalAdapter, schemaRequiresApprovalAdapter } from "@signalridge/pi-approval-core";
24
25
  import { type AgentTierResolutionSnapshot, resolveAgentTier } from "./agent-tiers.js";
25
26
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
26
27
  import { createAskGate } from "./ask-tools.js";
@@ -42,9 +43,12 @@ import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resol
42
43
  import { parentModelSessionOptions } from "./model-runtime-bridge.js";
43
44
  import { checkModelScope } from "./model-scope.js";
44
45
  import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
46
+ import { createChildApprovalTools } from "./operation-approvals.js";
45
47
  import { ANSWER_SUBAGENT_QUESTION, type ParentQuestionHandler } from "./parent-questions.js";
46
48
  import { captureProjectTrust, childProjectTrust, configurationMcpFactory, isProjectResource } from "./project-trust.js";
47
49
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
50
+ import { captureQuestionOrigin } from "./question-origin.js";
51
+ import { getDefaultTurnPolicy, type RunBudgets, selectResumeTurnPolicy, sessionBudgets, setDefaultTurnPolicy } from "./run-budget-policy.js";
48
52
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
49
53
  import { preloadSkills } from "./skill-loader.js";
50
54
  import { createSupervisorTool } from "./supervisor.js";
@@ -350,9 +354,6 @@ async function runConfiguredGate(command: string, cwd: string, pi: ExtensionAPI)
350
354
  }
351
355
  }
352
356
 
353
- /** Default max turns. undefined = unlimited (no turn limit). */
354
- let defaultMaxTurns: number | undefined;
355
-
356
357
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
357
358
  export function normalizeMaxTurns(n: number | undefined): number | undefined {
358
359
  if (n == null || n === 0) return undefined;
@@ -360,9 +361,9 @@ export function normalizeMaxTurns(n: number | undefined): number | undefined {
360
361
  }
361
362
 
362
363
  /** Get the default max turns value. undefined = unlimited. */
363
- export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
364
+ export function getDefaultMaxTurns(): number | undefined { return getDefaultTurnPolicy(); }
364
365
  /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
365
- export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); }
366
+ export function setDefaultMaxTurns(n: number | undefined): void { setDefaultTurnPolicy(normalizeMaxTurns(n)); }
366
367
 
367
368
  /** Additional turns allowed after the soft limit steer message. */
368
369
  /**
@@ -396,9 +397,11 @@ const TOOL_CALL_ABORT_GRACE_MS = 25;
396
397
  interface ApprovalTimer {
397
398
  isInteractiveAsk: (name: string) => boolean;
398
399
  armApproved: (toolCallId: string, toolName: string) => void;
400
+ pauseHuman: (toolCallId: string) => () => void;
399
401
  }
400
402
  // The policy survives runAgent for resumed turns; the current prompt owns its
401
- // abort timers, so resume replaces armApproved instead of arming a stale timer.
403
+ // abort timers, so resume replaces both approval callbacks instead of using
404
+ // a stale execution budget.
402
405
  const approvalTimers = new WeakMap<AgentSession, ApprovalTimer>();
403
406
  let defaultToolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS;
404
407
  export function getDefaultToolTimeoutMs(): number { return defaultToolTimeoutMs; }
@@ -427,7 +430,8 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, label: string):
427
430
  function createToolTimeouts(session: AgentSession, grace: number, isInteractiveAsk: (name: string) => boolean) {
428
431
  const pending = new Map<string, ReturnType<typeof setTimeout>>();
429
432
  const due = new Map<string, number>();
430
- const paused = new Map<string, { remaining: number; children: Set<string> }>();
433
+ const paused = new Map<string, { remaining: number; children: Set<string>; human: number }>();
434
+ let disposed = false;
431
435
  const clear = (id: string): void => {
432
436
  const handle = pending.get(id);
433
437
  if (handle) clearTimeout(handle);
@@ -436,7 +440,7 @@ function createToolTimeouts(session: AgentSession, grace: number, isInteractiveA
436
440
  paused.delete(id);
437
441
  };
438
442
  const arm = (id: string, name: string, delay = defaultToolTimeoutMs + grace): void => {
439
- if (defaultToolTimeoutMs <= 0 || name === "contact_supervisor") return;
443
+ if (disposed || defaultToolTimeoutMs <= 0 || name === "contact_supervisor") return;
440
444
  due.set(id, performance.now() + delay);
441
445
  const handle = setTimeout(() => {
442
446
  pending.delete(id);
@@ -450,25 +454,42 @@ function createToolTimeouts(session: AgentSession, grace: number, isInteractiveA
450
454
  handle.unref?.();
451
455
  pending.set(id, handle);
452
456
  };
457
+ const pause = (id: string) => {
458
+ const existing = paused.get(id);
459
+ if (existing) return existing;
460
+ const handle = pending.get(id);
461
+ const deadline = due.get(id);
462
+ if (!handle || deadline === undefined) return undefined;
463
+ clearTimeout(handle);
464
+ pending.delete(id);
465
+ due.delete(id);
466
+ const state = { remaining: Math.max(0, deadline - performance.now()), children: new Set<string>(), human: 0 };
467
+ paused.set(id, state);
468
+ return state;
469
+ };
470
+ const resume = (id: string): void => {
471
+ const state = paused.get(id);
472
+ if (!state || state.children.size || state.human) return;
473
+ paused.delete(id);
474
+ arm(id, "", state.remaining);
475
+ };
453
476
  return {
454
477
  armApproved: (id: string, name: string): void => arm(id, name),
478
+ pauseHuman: (id: string): (() => void) => {
479
+ const state = pause(id);
480
+ if (!state) return () => {};
481
+ state.human++;
482
+ let finished = false;
483
+ return () => {
484
+ if (finished) return;
485
+ finished = true;
486
+ state.human--;
487
+ resume(id);
488
+ };
489
+ },
455
490
  start: (event: { toolCallId: string; toolName: string; parentToolCallId?: string }): void => {
456
491
  if (event.parentToolCallId) {
457
- const parent = paused.get(event.parentToolCallId);
458
- if (parent) parent.children.add(event.toolCallId);
459
- else {
460
- const handle = pending.get(event.parentToolCallId);
461
- const deadline = due.get(event.parentToolCallId);
462
- if (handle && deadline !== undefined) {
463
- clearTimeout(handle);
464
- pending.delete(event.parentToolCallId);
465
- due.delete(event.parentToolCallId);
466
- paused.set(event.parentToolCallId, {
467
- remaining: Math.max(0, deadline - performance.now()),
468
- children: new Set([event.toolCallId]),
469
- });
470
- }
471
- }
492
+ pause(event.parentToolCallId)?.children.add(event.toolCallId);
472
493
  }
473
494
  if (!isInteractiveAsk(event.toolName)) arm(event.toolCallId, event.toolName);
474
495
  },
@@ -478,11 +499,10 @@ function createToolTimeouts(session: AgentSession, grace: number, isInteractiveA
478
499
  const parent = paused.get(event.parentToolCallId);
479
500
  if (!parent) return;
480
501
  parent.children.delete(event.toolCallId);
481
- if (parent.children.size > 0) return;
482
- paused.delete(event.parentToolCallId);
483
- arm(event.parentToolCallId, "", parent.remaining);
502
+ resume(event.parentToolCallId);
484
503
  },
485
504
  dispose: (): void => {
505
+ disposed = true;
486
506
  for (const handle of pending.values()) clearTimeout(handle);
487
507
  pending.clear();
488
508
  due.clear();
@@ -844,9 +864,11 @@ function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string
844
864
  * reads instead, leaving registration/unsubscription and all metadata intact.
845
865
  * Each read wraps the CURRENT callbacks before Pi takes its event snapshot.
846
866
  */
867
+ type ToolCallAdmission = () => ToolCallEventResult | undefined;
847
868
  function guardExtensionToolCalls(
848
869
  extension: Extension,
849
870
  deadlines: WeakMap<object, number>,
871
+ admissions: WeakMap<object, ToolCallAdmission>,
850
872
  ): Extension {
851
873
  type Handler = NonNullable<ReturnType<Extension["handlers"]["get"]>>[number];
852
874
  const guarded = new WeakMap<Handler, Handler>();
@@ -864,18 +886,23 @@ function guardExtensionToolCalls(
864
886
  return { block: true, reason: "Invalid subagent tool_call event." };
865
887
  }
866
888
  const deadline = deadlines.get(call);
867
- if (deadline === undefined) {
889
+ const admission = admissions.get(call);
890
+ if (deadline === undefined || !admission) {
868
891
  return { block: true, reason: "Subagent tool policy was bypassed." };
869
892
  }
870
- if (deadline === Infinity) return handler(...args);
871
893
  const toolName = Reflect.get(call, "toolName");
872
894
  const label = `tool_call for "${typeof toolName === "string" ? toolName : "unknown"}"`;
873
895
  const remaining = Math.ceil(deadline - performance.now());
874
896
  if (remaining <= 0) return { block: true, reason: `${label} timed out before the extension handler.` };
875
897
  try {
876
- // This race blocks the call; it cannot cancel side effects of
877
- // a handler that keeps running after its Promise loses.
878
- return await withTimeout(Promise.resolve().then(() => handler(...args)), remaining, label);
898
+ // This race cannot cancel side effects in a timed-out handler.
899
+ const result = deadline === Infinity ? await handler(...args)
900
+ : await withTimeout(Promise.resolve().then(() => handler(...args)), remaining, label);
901
+ if (deadline <= performance.now()) return { block: true, reason: `${label} timed out` };
902
+ // Later handlers mutate the native args object. Check after EACH
903
+ // handler using the original execution name/schema, not its mutable
904
+ // event.toolName. This admission never invokes askGate again.
905
+ return admission() ?? result;
879
906
  } catch (error) {
880
907
  // Pi does not catch tool_call handler failures. A timeout must
881
908
  // block the tool, not leave a rejected event or an awaited hook.
@@ -908,6 +935,11 @@ export async function runAgent(
908
935
  // an already-cancelled caller. This matters for tool-call signals: the
909
936
  // caller may be cancelled before this async function gets its first turn.
910
937
  throwIfAborted(options.signal);
938
+ // Capture the ROOT peer synchronously, before discovery or resource allocation.
939
+ // Only absence is optional; malformed/duplicate peers must fail closed here.
940
+ const approvalCapture = options.pi.events ? captureApprovalService(options.pi.events) : undefined;
941
+ const approvalService = approvalCapture?.service;
942
+ const approvalRootOrigin = approvalService ? captureQuestionOrigin(ctx) : undefined;
911
943
  if (options.supervisorQuestions === "parent" && !options.parentQuestion) {
912
944
  throw new Error('Parent clarification requires a manager-issued background channel. Use Agent with run_in_background: true and supervisor_questions: "parent".');
913
945
  }
@@ -1144,11 +1176,15 @@ export async function runAgent(
1144
1176
  const approvalTimer: ApprovalTimer = {
1145
1177
  isInteractiveAsk: (name) => interactiveAskNames.has(name.toLowerCase()),
1146
1178
  armApproved: () => {},
1179
+ pauseHuman: () => () => {},
1147
1180
  };
1148
1181
  // The policy starts a single monotonic deadline for each public tool_call
1149
1182
  // event. All subsequently selected extension handlers share its remainder,
1150
1183
  // including calls initiated by another tool through ctx.executeTool().
1151
1184
  const toolCallDeadlines = new WeakMap<object, number>();
1185
+ const toolCallAdmissions = new WeakMap<object, ToolCallAdmission>();
1186
+ let captureAdmission: (name: string, input: unknown) => ToolCallAdmission =
1187
+ () => () => ({ block: true, reason: "Subagent tool policy is not ready." });
1152
1188
  const extensionsOverride = (base: LoadExtensionsResult): LoadExtensionsResult => {
1153
1189
  const policy = base.extensions.find((e) => e.path === `<inline:${TOOL_POLICY_EXTENSION}>`);
1154
1190
  if (!policy) throw new Error("Subagent tool policy extension failed to load.");
@@ -1171,7 +1207,7 @@ export async function runAgent(
1171
1207
  return {
1172
1208
  ...base,
1173
1209
  extensions: [policy, ...selected.filter((e) => e !== policy)
1174
- .map((extension) => guardExtensionToolCalls(extension, toolCallDeadlines))],
1210
+ .map((extension) => guardExtensionToolCalls(extension, toolCallDeadlines, toolCallAdmissions))],
1175
1211
  };
1176
1212
  };
1177
1213
 
@@ -1179,12 +1215,14 @@ export async function runAgent(
1179
1215
  // ctx.executeTool calls. A policy factory also loads with noExtensions: true,
1180
1216
  // without loading any discovered extension. Deny until the child policy is
1181
1217
  // installed, including calls an extension might make during session_start.
1182
- let checkToolCall: (name: string, input: unknown, deadline: number) => Promise<ToolCallEventResult | undefined> =
1218
+ let checkToolCall: (name: string, input: unknown, deadline: number, admission: ToolCallAdmission) => Promise<ToolCallEventResult | undefined> =
1183
1219
  async () => ({ block: true, reason: "Subagent tool policy is not ready." });
1220
+ let childApprovalEvents: ApprovalEvents | undefined;
1184
1221
  const extensionFactories: InlineExtension[] = [{
1185
1222
  name: TOOL_POLICY_EXTENSION,
1186
1223
  hidden: true,
1187
1224
  factory: (pi) => {
1225
+ childApprovalEvents = pi.events;
1188
1226
  pi.on("tool_call", async (event, hookCtx) => {
1189
1227
  if (hookCtx?.signal?.aborted) return { block: true, reason: "Subagent tool call was aborted." };
1190
1228
  const waitingForHuman = approvalTimer.isInteractiveAsk(event.toolName);
@@ -1195,7 +1233,9 @@ export async function runAgent(
1195
1233
  ? performance.now() + defaultToolTimeoutMs
1196
1234
  : Infinity;
1197
1235
  toolCallDeadlines.set(event, deadline);
1198
- const decision = await checkToolCall(event.toolName, event.input, deadline);
1236
+ const admission = captureAdmission(event.toolName, event.input);
1237
+ toolCallAdmissions.set(event, admission);
1238
+ const decision = await checkToolCall(event.toolName, event.input, deadline, admission);
1199
1239
  // Abort cannot interrupt an already-open human dialog on these hosts.
1200
1240
  // Do not execute a tool if that dialog eventually approves a stopped run.
1201
1241
  if (hookCtx?.signal?.aborted) return { block: true, reason: "Subagent tool call was aborted." };
@@ -1279,6 +1319,10 @@ export async function runAgent(
1279
1319
  .flatMap(resource => resolvedNames.get(resource.path) ?? []));
1280
1320
  let warnedBackground = false;
1281
1321
  const admitChildExtension = (resource: ResolvedResource, intentional: boolean): boolean => {
1322
+ if (approvalService && resolvedNames.get(resource.path)?.includes("pi-approvals")) {
1323
+ if (intentional) throw new Error(`pi-approvals cannot load in agent "${type}"; integrate approvals in the root session instead (requested extension "${resource.path}").`);
1324
+ return false;
1325
+ }
1282
1326
  if (!resolvedNames.get(resource.path)?.includes("pi-background")) return true;
1283
1327
  const reason = `pi-background cannot load in agent "${type}": managed child sessions do not support asynchronous model wakes after completion/worktree cleanup`;
1284
1328
  if (intentional) throw new Error(`${reason} (requested extension "${resource.path}").`);
@@ -1517,9 +1561,34 @@ export async function runAgent(
1517
1561
  },
1518
1562
  })
1519
1563
  : [];
1520
- // One list from here on: both sets are custom tools this package injects, and
1521
- // the scoping pass below keeps exactly the names it is given.
1522
- const injectedTools = [...nestedTools, ...supervisorTools];
1564
+ // Reviewed replacements are SDK tools, not inherited extension permissions.
1565
+ // Admit only canonical names already allowed by the child policy.
1566
+ let approvalSession: AgentSession | undefined;
1567
+ // SDK winner metadata alone misses a losing late extension registration.
1568
+ // Inspect live public maps at bind, admission and every execute guard.
1569
+ const hasCanonicalConflict = (name: string): boolean =>
1570
+ loader.getExtensions().extensions.some(extension => extension.tools.has(name));
1571
+ const reviewedTools = approvalCapture && approvalService && approvalRootOrigin && toolNames.some(name => ["edit", "write", "bash"].includes(name) && !disallowedSet?.has(name))
1572
+ ? createChildApprovalTools({
1573
+ service: approvalService, root: ctx, rootOrigin: approvalRootOrigin, agentId: options.agentId, settings: settingsManager,
1574
+ assertServiceCurrent: approvalCapture.assertCurrent,
1575
+ isToolCurrent: (name) => approvalSession?.getActiveToolNames().includes(name) === true,
1576
+ waiting: (id) => approvalSession ? approvalTimers.get(approvalSession)?.pauseHuman(id) ?? (() => {}) : () => {},
1577
+ isBound: (): boolean => {
1578
+ const tools = approvalSession?.getAllTools();
1579
+ return [...reviewedNames].every(name => !hasCanonicalConflict(name) && tools?.find(tool => tool.name === name)?.sourceInfo.path === `<sdk:${name}>`);
1580
+ },
1581
+ }).filter(tool => toolNames.includes(tool.name) && !disallowedSet?.has(tool.name))
1582
+ : [];
1583
+ const reviewedNames = new Set(reviewedTools.map(tool => tool.name));
1584
+ for (const extension of loader.getExtensions().extensions) {
1585
+ for (const name of reviewedNames) {
1586
+ if (extension.tools.has(name)) {
1587
+ throw new Error(`Approval-required tool "${name}" was shadowed by "${extension.path}"; remove the conflicting child extension.`);
1588
+ }
1589
+ }
1590
+ }
1591
+ const injectedTools = [...nestedTools, ...supervisorTools, ...reviewedTools];
1523
1592
  const nestedToolNames = new Set(injectedTools.map(tool => tool.name));
1524
1593
  const askGate = createAskGate({
1525
1594
  askTools: agentConfig?.askTools ?? [],
@@ -1637,6 +1706,7 @@ export async function runAgent(
1637
1706
  }
1638
1707
 
1639
1708
  const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
1709
+ approvalSession = session;
1640
1710
  approvalTimers.set(session, approvalTimer);
1641
1711
  // Install the forwarding listener immediately after session creation, before
1642
1712
  // bindExtensions/session_start can do asynchronous work. The old placement
@@ -1650,12 +1720,42 @@ export async function runAgent(
1650
1720
  const inScope = noExtensions ? undefined : extensionToolNamesInScope(session, {
1651
1721
  loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, resolvedNames,
1652
1722
  });
1653
- checkToolCall = async (name, input, deadline) => {
1723
+ captureAdmission = (name, input) => {
1724
+ // Pi executes these captured args, even if a handler reassigns event.input.
1725
+ const captured = session.getAllTools().find(tool => tool.name === name);
1726
+ const requiresSchema = !!approvalService && !!captured && schemaRequiresApprovalAdapter(captured.parameters);
1727
+ return () => {
1728
+ if (!approvalService) return undefined;
1729
+ if (reviewedNames.has(name)) {
1730
+ if (hasCanonicalConflict(name) || session.getAllTools().find(tool => tool.name === name)?.sourceInfo.path !== `<sdk:${name}>`)
1731
+ return { block: true, reason: `Approval-required tool "${name}" was shadowed; remove the conflicting child extension.` };
1732
+ return undefined;
1733
+ }
1734
+ const effective = session.getAllTools().find(tool => tool.name === name);
1735
+ if (requiresApprovalAdapter(input) || requiresSchema) {
1736
+ try {
1737
+ if (!captured || !effective || !childApprovalEvents || effective.parameters !== captured.parameters ||
1738
+ effective.description !== captured.description || effective.sourceInfo.path !== captured.sourceInfo.path)
1739
+ throw new Error("Child approval binding changed");
1740
+ queryApprovalAdapter(childApprovalEvents, name, captured.parameters);
1741
+ } catch {
1742
+ return { block: true, reason: `Tool "${name}" requires exactly one live cooperative approval adapter; unreviewed fallback is blocked.` };
1743
+ }
1744
+ }
1745
+ return undefined;
1746
+ };
1747
+ };
1748
+ checkToolCall = async (name, input, deadline, admission) => {
1654
1749
  const run = async (): Promise<ToolCallEventResult | undefined> => {
1655
1750
  // Scope first, then approval: never prompt for a tool this child cannot use.
1656
1751
  if (inScope && !inScope().has(name)) {
1657
1752
  return { block: true, reason: `Tool "${name}" is not available to this subagent.` };
1658
1753
  }
1754
+ if (reviewedNames.has(name) && (hasCanonicalConflict(name) || session.getAllTools().find(tool => tool.name === name)?.sourceInfo.path !== `<sdk:${name}>`)) {
1755
+ return { block: true, reason: `Approval-required tool "${name}" was shadowed; only its reviewed SDK binding may execute.` };
1756
+ }
1757
+ const denied = admission();
1758
+ if (denied) return denied;
1659
1759
  return askGate?.(name, input);
1660
1760
  };
1661
1761
  if (deadline === Infinity) return run();
@@ -1686,6 +1786,11 @@ export async function runAgent(
1686
1786
  },
1687
1787
  });
1688
1788
  throwIfAborted(options.signal);
1789
+ for (const name of reviewedNames) {
1790
+ if (hasCanonicalConflict(name) || session.getAllTools().find(tool => tool.name === name)?.sourceInfo.path !== `<sdk:${name}>`) {
1791
+ throw new Error(`Approval-required tool "${name}" was shadowed; remove the conflicting child extension.`);
1792
+ }
1793
+ }
1689
1794
 
1690
1795
  // With `allowedToolNames` unset, the ACTIVE set still needs managing: Pi
1691
1796
  // activates default built-ins and eligible extension tools at registration.
@@ -1703,84 +1808,23 @@ export async function runAgent(
1703
1808
  options.onSessionCreated(session);
1704
1809
  }
1705
1810
 
1706
- // Track turns for graceful max_turns enforcement
1707
- let turnCount = 0;
1708
- const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
1709
- let softLimitReached = false;
1710
- let aborted = false;
1711
-
1712
- // Resource budgets, in the same shape as max_turns: a wrap-up steer at the
1713
- // soft threshold, an abort at the hard one. They bound what a single agent
1714
- // can spend on its own, which turn count does not — one turn can burn an
1715
- // arbitrary number of tokens or tool calls.
1716
- //
1717
- // `0` means unlimited here, matching this package's existing convention for
1718
- // `maxTurns`. That is the opposite of `maxSubagentSpawnsPerBranch`, and
1719
- // deliberately: these are opt-in resource caps that ship off, while the
1720
- // branch spawn budget is a safety valve that ships on.
1721
- const tokenBudget = normalizeMaxTurns(agentConfig?.maxTokens ?? defaultMaxTokens);
1722
- const toolCallBudget = normalizeMaxTurns(agentConfig?.maxToolCalls ?? defaultMaxToolCalls);
1723
- let budgetTokens = 0;
1724
- let budgetToolCalls = 0;
1725
- let tokenSoftReached = false;
1726
- let toolSoftReached = false;
1727
-
1728
- /**
1729
- * Apply one budget, returning the next soft-limit state.
1730
- *
1731
- * The soft threshold is `SOFT_BUDGET_FRACTION` of the budget so the agent has
1732
- * room left to actually write its answer after being told to wrap up — a
1733
- * steer sent at 100% would be asking for a final response with no allowance
1734
- * to produce it.
1735
- */
1736
- const applyBudget = (used: number, budget: number | undefined, softReached: boolean, label: string): boolean => {
1737
- if (budget == null) return softReached;
1738
- // The hard limit is checked FIRST. One message can consume more than the
1739
- // whole budget, and testing the soft threshold first would answer that with
1740
- // a wrap-up steer and no abort — leaving an agent already over budget to
1741
- // run on until its next event.
1742
- if (used >= budget) {
1743
- aborted = true;
1744
- session.abort();
1745
- return true;
1746
- }
1747
- if (!softReached && used >= budget * SOFT_BUDGET_FRACTION) {
1748
- session.steer(
1749
- `You are near your ${label} budget for this task. Wrap up immediately — provide your final answer now.`,
1750
- );
1751
- return true;
1752
- }
1753
- return softReached;
1811
+ const limits = {
1812
+ maxTurns: options.maxTurns ?? agentConfig?.maxTurns ?? getDefaultMaxTurns(),
1813
+ maxTokens: normalizeMaxTurns(agentConfig?.maxTokens ?? defaultMaxTokens),
1814
+ maxToolCalls: normalizeMaxTurns(agentConfig?.maxToolCalls ?? defaultMaxToolCalls),
1754
1815
  };
1816
+ sessionBudgets.set(session, limits);
1817
+ const budget = createRunBudget(session, limits, options.onTurnEnd);
1755
1818
 
1756
1819
  let currentMessageText = "";
1757
- // Per-tool timeout: a hung bash/MCP call must not stall the subagent forever.
1758
- // Each tool_execution_start arms a timer; the matching end clears it. On
1759
- // timeout the session is aborted — which propagates via the tool's
1760
- // AbortSignal into the hanging execute() and surfaces as an error result.
1761
- // `contact_supervisor` is excluded: it intentionally waits for a human.
1762
1820
  const toolTimeouts = createToolTimeouts(session, noExtensions ? 0 : TOOL_CALL_ABORT_GRACE_MS, approvalTimer.isInteractiveAsk);
1763
1821
  approvalTimer.armApproved = toolTimeouts.armApproved;
1764
- // Own finalized output before observer dispatch: a synchronous callback
1765
- // failure must remain visible without erasing the answer it interrupted.
1822
+ approvalTimer.pauseHuman = toolTimeouts.pauseHuman;
1823
+ // Own finalized output before dispatching observers that may throw.
1766
1824
  const collector = collectResponseText(session);
1767
1825
  const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
1768
- if (event.type === "turn_end") {
1769
- turnCount++;
1770
- options.onTurnEnd?.(turnCount);
1771
- if (maxTurns != null) {
1772
- if (!softLimitReached && turnCount >= maxTurns) {
1773
- softLimitReached = true;
1774
- session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
1775
- } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
1776
- aborted = true;
1777
- session.abort();
1778
- }
1779
- }
1780
- }
1781
- if (event.type === "message_start") {
1782
- currentMessageText = "";
1783
- }
1826
+ budget.onEvent(event);
1827
+ if (event.type === "message_start") currentMessageText = "";
1784
1828
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
1785
1829
  currentMessageText += event.assistantMessageEvent.delta;
1786
1830
  options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
@@ -1792,22 +1836,14 @@ export async function runAgent(
1792
1836
  if (event.type === "tool_execution_end") {
1793
1837
  toolTimeouts.end(event);
1794
1838
  options.onToolActivity?.({ type: "end", toolName: event.toolName });
1795
- budgetToolCalls++;
1796
- toolSoftReached = applyBudget(budgetToolCalls, toolCallBudget, toolSoftReached, "tool call");
1797
1839
  }
1798
1840
  if (event.type === "message_end" && event.message.role === "assistant") {
1799
- const u = (event.message as any).usage;
1841
+ const u = event.message.usage;
1800
1842
  if (u) options.onAssistantUsage?.({
1801
1843
  input: u.input ?? 0,
1802
1844
  output: u.output ?? 0,
1803
1845
  cacheWrite: u.cacheWrite ?? 0,
1804
1846
  });
1805
- if (u) {
1806
- // Same total as `getLifetimeTotal`: input + output + cacheWrite, with
1807
- // cacheRead deliberately excluded.
1808
- budgetTokens += (u.input ?? 0) + (u.output ?? 0) + (u.cacheWrite ?? 0);
1809
- tokenSoftReached = applyBudget(budgetTokens, tokenBudget, tokenSoftReached, "token");
1810
- }
1811
1847
  }
1812
1848
  if (event.type === "compaction_end" && !event.aborted && event.result) {
1813
1849
  options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
@@ -1819,26 +1855,23 @@ export async function runAgent(
1819
1855
  await session.prompt(prompt);
1820
1856
  } finally {
1821
1857
  approvalTimer.armApproved = () => {};
1858
+ approvalTimer.pauseHuman = () => () => {};
1822
1859
  unsubTurns();
1823
1860
  collector.unsubscribe();
1824
1861
  toolTimeouts.dispose();
1825
1862
  }
1826
1863
 
1827
1864
  const baseText = collector.getText();
1828
- // The acceptance gate runs AFTER the agent is done and its verdict is
1829
- // appended to the result, so the parent reads the check and the claim it is
1830
- // checking side by side. It deliberately does not steer the agent to fix
1831
- // what failed: the gate is evidence for the caller, not another turn.
1865
+ // Acceptance gates report evidence after the run, never spend another turn.
1832
1866
  const responseText = agentConfig?.gate
1833
1867
  ? `${baseText}${await runConfiguredGate(agentConfig.gate, effectiveCwd, options.pi)}`
1834
1868
  : baseText;
1835
1869
  completed = true;
1836
- return { responseText, session, aborted, steered: softLimitReached, failure: collector.getFailure() };
1870
+ return { responseText, session, ...budget.outcome(), failure: collector.getFailure() };
1837
1871
  } finally {
1838
1872
  cleanupAbort();
1839
- // The manager takes ownership only after onSessionCreated is invoked. If
1840
- // setup or the first prompt fails before that hand-off, this runner owns
1841
- // the session and must close it instead of leaving a live session behind.
1873
+ // Until onSessionCreated hands ownership to the manager, the runner must
1874
+ // close a session whose setup/prompt failed.
1842
1875
  if (!handedOff && !completed) {
1843
1876
  try {
1844
1877
  if (!options.signal?.aborted) await session.abort();
@@ -1849,6 +1882,86 @@ export async function runAgent(
1849
1882
  }
1850
1883
  }
1851
1884
 
1885
+ /** One invocation's soft/hard budgets, shared by fresh and resumed prompts. */
1886
+ function createRunBudget(session: AgentSession, limits: RunBudgets, onTurnEnd?: (count: number) => void) {
1887
+ let turnCount = 0;
1888
+ const maxTurns = normalizeMaxTurns(limits.maxTurns);
1889
+ let softLimitReached = false;
1890
+ let aborted = false;
1891
+
1892
+ // Resource budgets, in the same shape as max_turns: a wrap-up steer at the
1893
+ // soft threshold, an abort at the hard one. They bound what a single agent
1894
+ // can spend on its own, which turn count does not — one turn can burn an
1895
+ // arbitrary number of tokens or tool calls.
1896
+ //
1897
+ // `0` means unlimited here, matching this package's existing convention for
1898
+ // `maxTurns`. That is the opposite of `maxSubagentSpawnsPerBranch`, and
1899
+ // deliberately: these are opt-in resource caps that ship off, while the
1900
+ // branch spawn budget is a safety valve that ships on.
1901
+ const tokenBudget = limits.maxTokens;
1902
+ const toolCallBudget = limits.maxToolCalls;
1903
+ let budgetTokens = 0;
1904
+ let budgetToolCalls = 0;
1905
+ let tokenSoftReached = false;
1906
+ let toolSoftReached = false;
1907
+
1908
+ /**
1909
+ * Apply one budget, returning the next soft-limit state.
1910
+ *
1911
+ * The soft threshold is `SOFT_BUDGET_FRACTION` of the budget so the agent has
1912
+ * room left to actually write its answer after being told to wrap up — a
1913
+ * steer sent at 100% would be asking for a final response with no allowance
1914
+ * to produce it.
1915
+ */
1916
+ const applyBudget = (used: number, budget: number | undefined, softReached: boolean, label: string): boolean => {
1917
+ if (budget == null) return softReached;
1918
+ // The hard limit is checked FIRST. One message can consume more than the
1919
+ // whole budget, and testing the soft threshold first would answer that with
1920
+ // a wrap-up steer and no abort — leaving an agent already over budget to
1921
+ // run on until its next event.
1922
+ if (used >= budget) {
1923
+ aborted = true;
1924
+ session.abort();
1925
+ return true;
1926
+ }
1927
+ if (!softReached && used >= budget * SOFT_BUDGET_FRACTION) {
1928
+ session.steer(
1929
+ `You are near your ${label} budget for this task. Wrap up immediately — provide your final answer now.`,
1930
+ );
1931
+ return true;
1932
+ }
1933
+ return softReached;
1934
+ };
1935
+
1936
+ return {
1937
+ outcome: () => ({ aborted, steered: softLimitReached }),
1938
+ onEvent: (event: AgentSessionEvent): void => {
1939
+ if (event.type === "turn_end") {
1940
+ turnCount++;
1941
+ if (maxTurns != null) {
1942
+ if (!softLimitReached && turnCount >= maxTurns) {
1943
+ softLimitReached = true;
1944
+ session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
1945
+ } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
1946
+ aborted = true;
1947
+ session.abort();
1948
+ }
1949
+ }
1950
+ onTurnEnd?.(turnCount);
1951
+ }
1952
+ if (event.type === "tool_execution_end") {
1953
+ budgetToolCalls++;
1954
+ toolSoftReached = applyBudget(budgetToolCalls, toolCallBudget, toolSoftReached, "tool call");
1955
+ }
1956
+ if (event.type === "message_end" && event.message.role === "assistant" && event.message.usage) {
1957
+ const u = event.message.usage;
1958
+ budgetTokens += (u.input ?? 0) + (u.output ?? 0) + (u.cacheWrite ?? 0);
1959
+ tokenSoftReached = applyBudget(budgetTokens, tokenBudget, tokenSoftReached, "token");
1960
+ }
1961
+ },
1962
+ };
1963
+ }
1964
+
1852
1965
  /**
1853
1966
  * Send a new prompt to an existing session (resume).
1854
1967
  */
@@ -1856,41 +1969,58 @@ export async function resumeAgent(
1856
1969
  session: AgentSession,
1857
1970
  prompt: string,
1858
1971
  options: {
1972
+ maxTurns?: number;
1973
+ onTurnEnd?: (count: number) => void;
1974
+ onTextDelta?: (delta: string, fullText: string) => void;
1859
1975
  onToolActivity?: (activity: ToolActivity) => void;
1860
1976
  onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
1861
1977
  onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
1862
1978
  signal?: AbortSignal;
1863
1979
  } = {},
1864
- ): Promise<{ text: string; failure?: string }> {
1980
+ ): Promise<{ text: string; failure?: string; aborted: boolean; steered: boolean }> {
1865
1981
  throwIfAborted(options.signal);
1866
1982
  assertSafeChildSession(session);
1867
1983
  const collector = collectResponseText(session);
1868
1984
  const cleanupAbort = forwardAbortSignal(session, options.signal);
1869
1985
 
1870
- const unsubEvents = (options.onToolActivity || options.onAssistantUsage || options.onCompaction)
1871
- ? session.subscribe((event: AgentSessionEvent) => {
1872
- if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
1873
- if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
1874
- if (event.type === "message_end" && event.message.role === "assistant") {
1875
- const u = (event.message as any).usage;
1876
- if (u) options.onAssistantUsage?.({
1877
- input: u.input ?? 0,
1878
- output: u.output ?? 0,
1879
- cacheWrite: u.cacheWrite ?? 0,
1880
- });
1881
- }
1882
- if (event.type === "compaction_end" && !event.aborted && event.result) {
1883
- options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
1884
- }
1885
- })
1886
- : () => {};
1986
+ const inherited = sessionBudgets.get(session);
1987
+ const budget = createRunBudget(session, {
1988
+ maxTurns: selectResumeTurnPolicy(session, options.maxTurns),
1989
+ maxTokens: inherited ? inherited.maxTokens : normalizeMaxTurns(defaultMaxTokens),
1990
+ maxToolCalls: inherited ? inherited.maxToolCalls : normalizeMaxTurns(defaultMaxToolCalls),
1991
+ }, options.onTurnEnd);
1992
+ let currentMessageText = "";
1993
+ const unsubEvents = session.subscribe((event: AgentSessionEvent) => {
1994
+ budget.onEvent(event);
1995
+ if (event.type === "message_start") currentMessageText = "";
1996
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
1997
+ currentMessageText += event.assistantMessageEvent.delta;
1998
+ options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
1999
+ }
2000
+ if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
2001
+ if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
2002
+ if (event.type === "message_end" && event.message.role === "assistant") {
2003
+ const u = event.message.usage;
2004
+ if (u) options.onAssistantUsage?.({
2005
+ input: u.input ?? 0,
2006
+ output: u.output ?? 0,
2007
+ cacheWrite: u.cacheWrite ?? 0,
2008
+ });
2009
+ }
2010
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
2011
+ options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
2012
+ }
2013
+ });
1887
2014
  // The child policy and its consent gate survive resumed turns. Replace the
1888
2015
  // original prompt's timer callback so a delayed approval cannot arm a stale
1889
2016
  // timer that the first prompt no longer clears.
1890
2017
  const approvalTimer = approvalTimers.get(session);
1891
2018
  const toolTimeouts = createToolTimeouts(session, TOOL_CALL_ABORT_GRACE_MS,
1892
2019
  approvalTimer?.isInteractiveAsk ?? (() => false));
1893
- if (approvalTimer) approvalTimer.armApproved = toolTimeouts.armApproved;
2020
+ if (approvalTimer) {
2021
+ approvalTimer.armApproved = toolTimeouts.armApproved;
2022
+ approvalTimer.pauseHuman = toolTimeouts.pauseHuman;
2023
+ }
1894
2024
  const unsubResumeTimeout = session.subscribe((event: AgentSessionEvent) => {
1895
2025
  if (event.type === "tool_execution_start") toolTimeouts.start(event);
1896
2026
  else if (event.type === "tool_execution_end") toolTimeouts.end(event);
@@ -1900,7 +2030,10 @@ export async function resumeAgent(
1900
2030
  throwIfAborted(options.signal);
1901
2031
  await session.prompt(prompt);
1902
2032
  } finally {
1903
- if (approvalTimer) approvalTimer.armApproved = () => {};
2033
+ if (approvalTimer) {
2034
+ approvalTimer.armApproved = () => {};
2035
+ approvalTimer.pauseHuman = () => () => {};
2036
+ }
1904
2037
  collector.unsubscribe();
1905
2038
  unsubEvents();
1906
2039
  unsubResumeTimeout();
@@ -1910,6 +2043,7 @@ export async function resumeAgent(
1910
2043
 
1911
2044
  return {
1912
2045
  text: collector.getText(),
2046
+ ...budget.outcome(),
1913
2047
  failure: collector.getFailure(),
1914
2048
  };
1915
2049
  }