@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.
- package/CHANGELOG.md +29 -0
- package/README.md +14 -0
- package/package.json +2 -1
- package/src/agent-manager.ts +102 -51
- package/src/agent-runner.ts +280 -146
- package/src/index.ts +195 -39
- package/src/internal-run.ts +5 -1
- package/src/invocation-config.ts +6 -1
- package/src/nested-tools.ts +7 -2
- package/src/operation-approvals.ts +49 -0
- package/src/run-budget-policy.ts +22 -0
- package/src/ui/fleet-list.ts +4 -2
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
877
|
-
|
|
878
|
-
|
|
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
|
|
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
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
-
|
|
1765
|
-
//
|
|
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
|
-
|
|
1769
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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,
|
|
1870
|
+
return { responseText, session, ...budget.outcome(), failure: collector.getFailure() };
|
|
1837
1871
|
} finally {
|
|
1838
1872
|
cleanupAbort();
|
|
1839
|
-
//
|
|
1840
|
-
//
|
|
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
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
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)
|
|
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)
|
|
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
|
}
|