@herbertgao/pi-subagents 0.22.0-local.2 → 0.22.1-local.1
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 +6 -0
- package/README.md +142 -45
- package/docs/rpc.md +17 -7
- package/package.json +4 -4
- package/src/agent-manager.ts +111 -59
- package/src/agent-runner.ts +135 -117
- package/src/cross-extension-rpc.ts +41 -14
- package/src/index.ts +110 -19
- package/src/nested-tools.ts +8 -0
- package/src/prompts.ts +31 -0
package/src/agent-runner.ts
CHANGED
|
@@ -42,7 +42,11 @@ import {
|
|
|
42
42
|
getMaxSubagentDepth,
|
|
43
43
|
type NestedAgentManager,
|
|
44
44
|
} from "./nested-tools.js"
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
buildAgentPrompt,
|
|
47
|
+
inheritableParentPrompt,
|
|
48
|
+
type PromptExtras,
|
|
49
|
+
} from "./prompts.js"
|
|
46
50
|
import { preloadSkills } from "./skill-loader.js"
|
|
47
51
|
import {
|
|
48
52
|
createStructuredCapture,
|
|
@@ -722,6 +726,91 @@ function finalTurnError(
|
|
|
722
726
|
return undefined
|
|
723
727
|
}
|
|
724
728
|
|
|
729
|
+
/** The per-run callbacks a spawn and a resume share, plus the resolved turn limit. */
|
|
730
|
+
type RunObserverOptions = Pick<
|
|
731
|
+
RunOptions,
|
|
732
|
+
| "onTurnEnd"
|
|
733
|
+
| "onTextDelta"
|
|
734
|
+
| "onToolActivity"
|
|
735
|
+
| "onAssistantUsage"
|
|
736
|
+
| "onCompaction"
|
|
737
|
+
> & {
|
|
738
|
+
/** Already resolved: undefined = unlimited. */
|
|
739
|
+
maxTurns?: number
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Observe one prompt of a session — a spawn's or a resume's — and enforce its
|
|
744
|
+
* turn limit: steer once at `maxTurns`, hard-abort after the grace turns.
|
|
745
|
+
* `steered` / `aborted` are live fields, read while the prompt is still in
|
|
746
|
+
* flight (the structured-output retry) and after it settles (the result).
|
|
747
|
+
* Shared by `runAgent` and `resumeAgent` so a resumed run is bounded exactly
|
|
748
|
+
* like the first one (#368).
|
|
749
|
+
*/
|
|
750
|
+
function observeRun(session: AgentSession, options: RunObserverOptions) {
|
|
751
|
+
let turnCount = 0
|
|
752
|
+
let currentMessageText = ""
|
|
753
|
+
const run = {
|
|
754
|
+
steered: false,
|
|
755
|
+
aborted: false,
|
|
756
|
+
unsubscribe: () => {},
|
|
757
|
+
}
|
|
758
|
+
run.unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
759
|
+
if (event.type === "turn_end") {
|
|
760
|
+
turnCount++
|
|
761
|
+
options.onTurnEnd?.(turnCount)
|
|
762
|
+
if (options.maxTurns != null) {
|
|
763
|
+
if (!run.steered && turnCount >= options.maxTurns) {
|
|
764
|
+
run.steered = true
|
|
765
|
+
session.steer(
|
|
766
|
+
"You have reached your turn limit. Wrap up immediately — provide your final answer now.",
|
|
767
|
+
)
|
|
768
|
+
} else if (run.steered && turnCount >= options.maxTurns + graceTurns) {
|
|
769
|
+
run.aborted = true
|
|
770
|
+
session.abort()
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (event.type === "message_start") {
|
|
775
|
+
currentMessageText = ""
|
|
776
|
+
}
|
|
777
|
+
if (
|
|
778
|
+
event.type === "message_update" &&
|
|
779
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
780
|
+
) {
|
|
781
|
+
currentMessageText += event.assistantMessageEvent.delta
|
|
782
|
+
options.onTextDelta?.(
|
|
783
|
+
event.assistantMessageEvent.delta,
|
|
784
|
+
currentMessageText,
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
if (event.type === "tool_execution_start") {
|
|
788
|
+
options.onToolActivity?.({ type: "start", toolName: event.toolName })
|
|
789
|
+
}
|
|
790
|
+
if (event.type === "tool_execution_end") {
|
|
791
|
+
options.onToolActivity?.({ type: "end", toolName: event.toolName })
|
|
792
|
+
}
|
|
793
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
794
|
+
const u = (event.message as any).usage
|
|
795
|
+
if (u)
|
|
796
|
+
options.onAssistantUsage?.({
|
|
797
|
+
input: u.input ?? 0,
|
|
798
|
+
output: u.output ?? 0,
|
|
799
|
+
cacheWrite: u.cacheWrite ?? 0,
|
|
800
|
+
cacheRead: u.cacheRead ?? 0,
|
|
801
|
+
cost: u.cost?.total ?? 0,
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
805
|
+
options.onCompaction?.({
|
|
806
|
+
reason: event.reason,
|
|
807
|
+
tokensBefore: event.result.tokensBefore,
|
|
808
|
+
})
|
|
809
|
+
}
|
|
810
|
+
})
|
|
811
|
+
return run
|
|
812
|
+
}
|
|
813
|
+
|
|
725
814
|
/**
|
|
726
815
|
* Wire an AbortSignal to abort a session.
|
|
727
816
|
* Returns a cleanup function to remove the listener.
|
|
@@ -764,8 +853,12 @@ export async function runAgent(
|
|
|
764
853
|
|
|
765
854
|
const env = await detectEnv(options.pi, effectiveCwd)
|
|
766
855
|
|
|
767
|
-
// Get parent system prompt for append-mode agents
|
|
768
|
-
|
|
856
|
+
// Get parent system prompt for append-mode agents, without the sections the
|
|
857
|
+
// child session renders itself (skills, cwd, extension sections).
|
|
858
|
+
const parentSystemPrompt = inheritableParentPrompt(
|
|
859
|
+
ctx.getSystemPrompt(),
|
|
860
|
+
ctx.cwd,
|
|
861
|
+
)
|
|
769
862
|
|
|
770
863
|
// Build prompt extras (memory, skill preloading)
|
|
771
864
|
const extras: PromptExtras = {}
|
|
@@ -1072,8 +1165,12 @@ export async function runAgent(
|
|
|
1072
1165
|
options.model ??
|
|
1073
1166
|
resolveDefaultModel(ctx.model, ctx.modelRegistry, agentConfig?.model)
|
|
1074
1167
|
|
|
1075
|
-
// Resolve thinking level: explicit option > agent config >
|
|
1076
|
-
|
|
1168
|
+
// Resolve thinking level: explicit option > agent config > parent session level.
|
|
1169
|
+
// Without the parent step, an unset level falls through to pi's
|
|
1170
|
+
// defaultThinkingLevel (e.g. "max") instead of matching the spawning session.
|
|
1171
|
+
// Same idiom as mention-clone.ts.
|
|
1172
|
+
const thinkingLevel =
|
|
1173
|
+
options.thinkingLevel ?? agentConfig?.thinking ?? ctx.thinkingLevel
|
|
1077
1174
|
|
|
1078
1175
|
const disallowedSet = agentConfig?.disallowedTools
|
|
1079
1176
|
? new Set(agentConfig.disallowedTools)
|
|
@@ -1316,65 +1413,9 @@ export async function runAgent(
|
|
|
1316
1413
|
|
|
1317
1414
|
options.onSessionCreated?.(session)
|
|
1318
1415
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
let softLimitReached = false
|
|
1323
|
-
let aborted = false
|
|
1324
|
-
|
|
1325
|
-
let currentMessageText = ""
|
|
1326
|
-
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
|
|
1327
|
-
if (event.type === "turn_end") {
|
|
1328
|
-
turnCount++
|
|
1329
|
-
options.onTurnEnd?.(turnCount)
|
|
1330
|
-
if (maxTurns != null) {
|
|
1331
|
-
if (!softLimitReached && turnCount >= maxTurns) {
|
|
1332
|
-
softLimitReached = true
|
|
1333
|
-
session.steer(
|
|
1334
|
-
"You have reached your turn limit. Wrap up immediately — provide your final answer now.",
|
|
1335
|
-
)
|
|
1336
|
-
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
|
|
1337
|
-
aborted = true
|
|
1338
|
-
session.abort()
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
if (event.type === "message_start") {
|
|
1343
|
-
currentMessageText = ""
|
|
1344
|
-
}
|
|
1345
|
-
if (
|
|
1346
|
-
event.type === "message_update" &&
|
|
1347
|
-
event.assistantMessageEvent.type === "text_delta"
|
|
1348
|
-
) {
|
|
1349
|
-
currentMessageText += event.assistantMessageEvent.delta
|
|
1350
|
-
options.onTextDelta?.(
|
|
1351
|
-
event.assistantMessageEvent.delta,
|
|
1352
|
-
currentMessageText,
|
|
1353
|
-
)
|
|
1354
|
-
}
|
|
1355
|
-
if (event.type === "tool_execution_start") {
|
|
1356
|
-
options.onToolActivity?.({ type: "start", toolName: event.toolName })
|
|
1357
|
-
}
|
|
1358
|
-
if (event.type === "tool_execution_end") {
|
|
1359
|
-
options.onToolActivity?.({ type: "end", toolName: event.toolName })
|
|
1360
|
-
}
|
|
1361
|
-
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
1362
|
-
const u = (event.message as any).usage
|
|
1363
|
-
if (u)
|
|
1364
|
-
options.onAssistantUsage?.({
|
|
1365
|
-
input: u.input ?? 0,
|
|
1366
|
-
output: u.output ?? 0,
|
|
1367
|
-
cacheWrite: u.cacheWrite ?? 0,
|
|
1368
|
-
cacheRead: u.cacheRead ?? 0,
|
|
1369
|
-
cost: u.cost?.total ?? 0,
|
|
1370
|
-
})
|
|
1371
|
-
}
|
|
1372
|
-
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
1373
|
-
options.onCompaction?.({
|
|
1374
|
-
reason: event.reason,
|
|
1375
|
-
tokensBefore: event.result.tokensBefore,
|
|
1376
|
-
})
|
|
1377
|
-
}
|
|
1416
|
+
const run = observeRun(session, {
|
|
1417
|
+
...options,
|
|
1418
|
+
maxTurns: resolveEffectiveMaxTurns(type, options.maxTurns),
|
|
1378
1419
|
})
|
|
1379
1420
|
|
|
1380
1421
|
const collector = collectResponseText(session)
|
|
@@ -1407,14 +1448,14 @@ export async function runAgent(
|
|
|
1407
1448
|
if (
|
|
1408
1449
|
structuredCapture !== undefined &&
|
|
1409
1450
|
structuredCapture.json === undefined &&
|
|
1410
|
-
!aborted &&
|
|
1451
|
+
!run.aborted &&
|
|
1411
1452
|
options.signal?.aborted !== true
|
|
1412
1453
|
) {
|
|
1413
1454
|
structuredRetried = true
|
|
1414
1455
|
await session.prompt(structuredRetryPrompt(structuredCapture))
|
|
1415
1456
|
}
|
|
1416
1457
|
} finally {
|
|
1417
|
-
|
|
1458
|
+
run.unsubscribe()
|
|
1418
1459
|
collector.unsubscribe()
|
|
1419
1460
|
cleanupAbort()
|
|
1420
1461
|
}
|
|
@@ -1433,8 +1474,8 @@ export async function runAgent(
|
|
|
1433
1474
|
return {
|
|
1434
1475
|
responseText,
|
|
1435
1476
|
session,
|
|
1436
|
-
aborted,
|
|
1437
|
-
steered:
|
|
1477
|
+
aborted: run.aborted,
|
|
1478
|
+
steered: run.steered,
|
|
1438
1479
|
failure: finalTurnError(session, startLen) ?? structuredFailure,
|
|
1439
1480
|
...(structuredCapture?.json !== undefined
|
|
1440
1481
|
? { structuredJson: structuredCapture.json }
|
|
@@ -1444,77 +1485,54 @@ export async function runAgent(
|
|
|
1444
1485
|
}
|
|
1445
1486
|
|
|
1446
1487
|
/**
|
|
1447
|
-
* Send a new prompt to an existing session (resume).
|
|
1488
|
+
* Send a new prompt to an existing session (resume). A fresh run of the same
|
|
1489
|
+
* agent: the turn limit is resolved as `runAgent` resolves it — an explicit
|
|
1490
|
+
* `maxTurns`, else the agent type's own `max_turns`, else the project default
|
|
1491
|
+
* — and counted from this prompt (#368).
|
|
1448
1492
|
*/
|
|
1449
1493
|
export async function resumeAgent(
|
|
1450
1494
|
session: AgentSession,
|
|
1451
1495
|
prompt: string,
|
|
1452
|
-
options:
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
signal
|
|
1460
|
-
|
|
1461
|
-
|
|
1496
|
+
options: Pick<
|
|
1497
|
+
RunOptions,
|
|
1498
|
+
| "maxTurns"
|
|
1499
|
+
| "onTurnEnd"
|
|
1500
|
+
| "onToolActivity"
|
|
1501
|
+
| "onAssistantUsage"
|
|
1502
|
+
| "onCompaction"
|
|
1503
|
+
| "signal"
|
|
1504
|
+
> & {
|
|
1505
|
+
/** The agent's type, for its own `max_turns`. */
|
|
1506
|
+
type: SubagentType
|
|
1507
|
+
},
|
|
1508
|
+
): Promise<
|
|
1509
|
+
{ text: string; failure?: string } & Pick<RunResult, "aborted" | "steered">
|
|
1510
|
+
> {
|
|
1462
1511
|
// Boundary for the history fallback: the session already holds prior turns,
|
|
1463
1512
|
// so only assistant text produced by THIS resume prompt counts as its output
|
|
1464
1513
|
// — a failed resume must not surface the previous turn's answer (#144).
|
|
1465
1514
|
const startLen = session.messages.length
|
|
1466
1515
|
const collector = collectResponseText(session)
|
|
1467
1516
|
const cleanupAbort = forwardAbortSignal(session, options.signal)
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
if (event.type === "tool_execution_start")
|
|
1473
|
-
options.onToolActivity?.({
|
|
1474
|
-
type: "start",
|
|
1475
|
-
toolName: event.toolName,
|
|
1476
|
-
})
|
|
1477
|
-
if (event.type === "tool_execution_end")
|
|
1478
|
-
options.onToolActivity?.({ type: "end", toolName: event.toolName })
|
|
1479
|
-
if (
|
|
1480
|
-
event.type === "message_end" &&
|
|
1481
|
-
event.message.role === "assistant"
|
|
1482
|
-
) {
|
|
1483
|
-
const u = (event.message as any).usage
|
|
1484
|
-
if (u)
|
|
1485
|
-
options.onAssistantUsage?.({
|
|
1486
|
-
input: u.input ?? 0,
|
|
1487
|
-
output: u.output ?? 0,
|
|
1488
|
-
cacheWrite: u.cacheWrite ?? 0,
|
|
1489
|
-
cacheRead: u.cacheRead ?? 0,
|
|
1490
|
-
cost: u.cost?.total ?? 0,
|
|
1491
|
-
})
|
|
1492
|
-
}
|
|
1493
|
-
if (
|
|
1494
|
-
event.type === "compaction_end" &&
|
|
1495
|
-
!event.aborted &&
|
|
1496
|
-
event.result
|
|
1497
|
-
) {
|
|
1498
|
-
options.onCompaction?.({
|
|
1499
|
-
reason: event.reason,
|
|
1500
|
-
tokensBefore: event.result.tokensBefore,
|
|
1501
|
-
})
|
|
1502
|
-
}
|
|
1503
|
-
})
|
|
1504
|
-
: () => {}
|
|
1517
|
+
const run = observeRun(session, {
|
|
1518
|
+
...options,
|
|
1519
|
+
maxTurns: resolveEffectiveMaxTurns(options.type, options.maxTurns),
|
|
1520
|
+
})
|
|
1505
1521
|
|
|
1506
1522
|
try {
|
|
1507
1523
|
// Already aborted: not prompted, as in `runAgent`.
|
|
1508
1524
|
if (options.signal?.aborted !== true) await session.prompt(prompt)
|
|
1509
1525
|
} finally {
|
|
1510
1526
|
collector.unsubscribe()
|
|
1511
|
-
|
|
1527
|
+
run.unsubscribe()
|
|
1512
1528
|
cleanupAbort()
|
|
1513
1529
|
}
|
|
1514
1530
|
|
|
1515
1531
|
return {
|
|
1516
1532
|
text: collector.getText().trim() || getLastAssistantText(session, startLen),
|
|
1517
1533
|
failure: finalTurnError(session, startLen),
|
|
1534
|
+
aborted: run.aborted,
|
|
1535
|
+
steered: run.steered,
|
|
1518
1536
|
}
|
|
1519
1537
|
}
|
|
1520
1538
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-extension RPC handlers for the subagents extension.
|
|
3
3
|
*
|
|
4
|
-
* Exposes ping, spawn, stop, and
|
|
4
|
+
* Exposes ping, spawn, stop, consume, and steer RPCs over the pi.events event bus,
|
|
5
5
|
* using per-request scoped reply channels.
|
|
6
6
|
*
|
|
7
7
|
* Reply envelope follows pi-mono convention:
|
|
@@ -58,6 +58,11 @@ export interface SpawnCapable {
|
|
|
58
58
|
* one. False when there is no such agent, or it has not settled yet.
|
|
59
59
|
*/
|
|
60
60
|
consumeResult(id: string): boolean
|
|
61
|
+
/**
|
|
62
|
+
* Deliver a message to a running or queued agent's conversation — what
|
|
63
|
+
* `steer_subagent` does. False when the agent is unknown or has settled.
|
|
64
|
+
*/
|
|
65
|
+
steer(id: string, message: string): boolean
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export interface RpcDeps {
|
|
@@ -72,6 +77,7 @@ export interface RpcHandle {
|
|
|
72
77
|
unsubSpawn: () => void
|
|
73
78
|
unsubStop: () => void
|
|
74
79
|
unsubConsume: () => void
|
|
80
|
+
unsubSteer: () => void
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
/**
|
|
@@ -100,7 +106,7 @@ function handleRpc<P extends { requestId: string }>(
|
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
/**
|
|
103
|
-
* Register ping, spawn, stop, and
|
|
109
|
+
* Register ping, spawn, stop, consume, and steer RPC handlers on the event bus.
|
|
104
110
|
* Returns unsub functions for cleanup.
|
|
105
111
|
*/
|
|
106
112
|
export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
@@ -184,21 +190,25 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
184
190
|
return { id }
|
|
185
191
|
})
|
|
186
192
|
|
|
193
|
+
// Only the session's own agents are this RPC's to stop or steer. A nested
|
|
194
|
+
// child or a workflow's agent is owned by something that is *waiting on it*,
|
|
195
|
+
// and aborting or redirecting it out from under that owner turns another
|
|
196
|
+
// extension's request into a failed step here. Defence in depth rather than
|
|
197
|
+
// a live hole: no RPC hands out agent ids, so a caller has no ordinary way to
|
|
198
|
+
// name one it does not own — but the guard is cheap and the id may leak some
|
|
199
|
+
// other way. Same refuse-what-we-should-not-touch stance as `consume` below.
|
|
200
|
+
const assertOwned = (agentId: string): void => {
|
|
201
|
+
const record = manager.getRecord(agentId)
|
|
202
|
+
if (!record) throw new Error("Agent not found")
|
|
203
|
+
if (!isTopLevelAgent(record))
|
|
204
|
+
throw new Error("Agent is owned by another agent or workflow")
|
|
205
|
+
}
|
|
206
|
+
|
|
187
207
|
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
|
|
188
208
|
events,
|
|
189
209
|
"subagents:rpc:stop",
|
|
190
210
|
({ agentId }) => {
|
|
191
|
-
|
|
192
|
-
if (!record) throw new Error("Agent not found")
|
|
193
|
-
// Only the session's own agents are this RPC's to stop. A nested child or
|
|
194
|
-
// a workflow's agent is owned by something that is *waiting on it*, and
|
|
195
|
-
// aborting it out from under that owner turns another extension's stop
|
|
196
|
-
// into a failed step here. Defence in depth rather than a live hole: no
|
|
197
|
-
// RPC hands out agent ids, so a caller has no ordinary way to name one it
|
|
198
|
-
// does not own — but the guard is cheap and the id may leak some other
|
|
199
|
-
// way. Same refuse-what-we-should-not-touch stance as `consume` below.
|
|
200
|
-
if (!isTopLevelAgent(record))
|
|
201
|
-
throw new Error("Agent is owned by another agent or workflow")
|
|
211
|
+
assertOwned(agentId)
|
|
202
212
|
// Not "not found" — the lookup above already proved it exists. `abort`
|
|
203
213
|
// returns false only for a record that is neither running nor queued,
|
|
204
214
|
// which is an agent that has already finished.
|
|
@@ -220,5 +230,22 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
220
230
|
},
|
|
221
231
|
)
|
|
222
232
|
|
|
223
|
-
|
|
233
|
+
// An extension that spawned an agent over the bus and then learns something
|
|
234
|
+
// that agent needs — a second CI failure on the head it is already fixing, a
|
|
235
|
+
// review that landed mid-run — can hand it over instead of spawning a second
|
|
236
|
+
// agent for the same work or dropping the fact. Outside the ping version
|
|
237
|
+
// handshake, like consume: additive and best-effort.
|
|
238
|
+
const unsubSteer = handleRpc<{
|
|
239
|
+
requestId: string
|
|
240
|
+
agentId: string
|
|
241
|
+
message: unknown
|
|
242
|
+
}>(events, "subagents:rpc:steer", ({ agentId, message }) => {
|
|
243
|
+
if (typeof message !== "string" || message.trim().length === 0)
|
|
244
|
+
throw new Error("Steer message must be a non-empty string")
|
|
245
|
+
assertOwned(agentId)
|
|
246
|
+
if (!manager.steer(agentId, message))
|
|
247
|
+
throw new Error("Agent is not running")
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
return { unsubPing, unsubSpawn, unsubStop, unsubConsume, unsubSteer }
|
|
224
251
|
}
|
package/src/index.ts
CHANGED
|
@@ -965,12 +965,59 @@ export default function (pi: ExtensionAPI) {
|
|
|
965
965
|
compactionCount: record.compactionCount,
|
|
966
966
|
})
|
|
967
967
|
},
|
|
968
|
-
(
|
|
968
|
+
(record, usage) => {
|
|
969
969
|
// Every assistant message from every agent — nested included, exactly once.
|
|
970
970
|
// Parked here until a tool result can carry it back to the parent session;
|
|
971
971
|
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no
|
|
972
972
|
// pool grows in a session that will never drain it.
|
|
973
973
|
if (reportUsage) pendingUsage.add(usage)
|
|
974
|
+
// The same message, live, for anyone keeping their own ledger — a footer
|
|
975
|
+
// that breaks spend down by agent type and thinking level, say. This is the
|
|
976
|
+
// one place every agent's spend passes through, which is why it is emitted
|
|
977
|
+
// here and not from the lifecycle events: those fire for top-level agents
|
|
978
|
+
// only, once, at the end, so a workflow's children and nested agents would
|
|
979
|
+
// never be counted, and a running agent would read as free until it
|
|
980
|
+
// stopped. Deliberately ungated by `reportUsage` — that decides whether the
|
|
981
|
+
// parent session's own totals include this spend, not whether it happened.
|
|
982
|
+
//
|
|
983
|
+
// `model` and `thinking` are read off the record per message, not captured
|
|
984
|
+
// at spawn: the record is authoritative only once the session exists and pi
|
|
985
|
+
// has clamped the level to the model (see `onSessionCreated`).
|
|
986
|
+
const reported = toReportedUsage(usage)
|
|
987
|
+
if (!reported) return
|
|
988
|
+
// A key whose value is unknown is omitted, never `undefined`: `model` and
|
|
989
|
+
// `thinking` until the session has reported them, `parentAgentId` and
|
|
990
|
+
// `workflowId` on a top-level agent.
|
|
991
|
+
const invocation = record.invocation
|
|
992
|
+
pi.events.emit("subagents:usage", {
|
|
993
|
+
id: record.id,
|
|
994
|
+
type: record.type,
|
|
995
|
+
description: record.description,
|
|
996
|
+
...(invocation?.modelId !== undefined
|
|
997
|
+
? { model: invocation.modelId }
|
|
998
|
+
: {}),
|
|
999
|
+
...(invocation?.thinking !== undefined
|
|
1000
|
+
? { thinking: invocation.thinking }
|
|
1001
|
+
: {}),
|
|
1002
|
+
// Set only when the caller did not get the level it asked for (pi
|
|
1003
|
+
// clamped it to the model, or an agent file outranked it), so a ledger
|
|
1004
|
+
// can show `low (asked minimal)` instead of silently relabelling.
|
|
1005
|
+
...(invocation?.requestedThinking !== undefined
|
|
1006
|
+
? { requestedThinking: invocation.requestedThinking }
|
|
1007
|
+
: {}),
|
|
1008
|
+
depth: record.depth ?? 1,
|
|
1009
|
+
...(record.parentAgentId !== undefined
|
|
1010
|
+
? { parentAgentId: record.parentAgentId }
|
|
1011
|
+
: {}),
|
|
1012
|
+
...(record.workflowId !== undefined
|
|
1013
|
+
? { workflowId: record.workflowId }
|
|
1014
|
+
: {}),
|
|
1015
|
+
usage: reported,
|
|
1016
|
+
})
|
|
1017
|
+
},
|
|
1018
|
+
(record, message) => {
|
|
1019
|
+
if (!isTopLevelAgent(record)) return
|
|
1020
|
+
pi.events.emit("subagents:steered", { id: record.id, message })
|
|
974
1021
|
},
|
|
975
1022
|
)
|
|
976
1023
|
|
|
@@ -1180,6 +1227,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1180
1227
|
outbox.cancel(record.id)
|
|
1181
1228
|
return true
|
|
1182
1229
|
},
|
|
1230
|
+
steer: (id, message) => manager.steer(id, message),
|
|
1183
1231
|
},
|
|
1184
1232
|
})
|
|
1185
1233
|
// Broadcast readiness so extensions loaded alongside us can discover us.
|
|
@@ -1295,10 +1343,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1295
1343
|
// this message is still relayed even if the LLM read its last answer.
|
|
1296
1344
|
record.resultConsumed = false
|
|
1297
1345
|
manager.steer(record.id, mention.message)
|
|
1298
|
-
pi.events.emit("subagents:steered", {
|
|
1299
|
-
id: record.id,
|
|
1300
|
-
message: mention.message,
|
|
1301
|
-
})
|
|
1302
1346
|
ctx.ui.notify(`Sent to ${target}`, "info")
|
|
1303
1347
|
return { action: "handled" }
|
|
1304
1348
|
}
|
|
@@ -1308,17 +1352,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1308
1352
|
// agent, so its frontmatter is what governs — `output_transcript: false`
|
|
1309
1353
|
// must keep holding, since record.outputFile is the sole gate every
|
|
1310
1354
|
// downstream consumer keys off and a resume must not re-open it.
|
|
1311
|
-
const config = getAgentConfig(record.type)
|
|
1312
1355
|
const resumedRecord = await startBackgroundResume(
|
|
1313
1356
|
ctx,
|
|
1314
1357
|
record,
|
|
1315
1358
|
mention.message,
|
|
1316
1359
|
{
|
|
1317
1360
|
outputTranscript:
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
),
|
|
1361
|
+
getAgentConfig(record.type)?.outputTranscript ??
|
|
1362
|
+
getOutputTranscriptDefault(),
|
|
1363
|
+
maxTurns: resumeTurnLimit(record),
|
|
1322
1364
|
},
|
|
1323
1365
|
)
|
|
1324
1366
|
ctx.ui.notify(
|
|
@@ -1505,6 +1547,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1505
1547
|
rpcHandle?.unsubStop()
|
|
1506
1548
|
rpcHandle?.unsubPing()
|
|
1507
1549
|
rpcHandle?.unsubConsume()
|
|
1550
|
+
rpcHandle?.unsubSteer()
|
|
1508
1551
|
rpcHandle = undefined
|
|
1509
1552
|
currentCtx = undefined
|
|
1510
1553
|
// Only release the global slot if this activation claimed it — a child
|
|
@@ -1539,6 +1582,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1539
1582
|
// pi awaits this handler, and the process exits right after — unawaited, those
|
|
1540
1583
|
// handlers would never run. Internally bounded, so a hung one can't strand quit.
|
|
1541
1584
|
await manager.dispose(pi)
|
|
1585
|
+
// Every agent is stopped and every child session has shut down, so no more
|
|
1586
|
+
// `subagents:usage` can arrive. A ledger flushing in its own
|
|
1587
|
+
// `session_shutdown` may have run before this handler aborted the agents
|
|
1588
|
+
// (handlers run in load order) and missed their last messages; this is the
|
|
1589
|
+
// point after which a synchronous final flush is complete. Synchronous:
|
|
1590
|
+
// `emit` does not await listeners, and pi exits once this handler returns.
|
|
1591
|
+
pi.events.emit("subagents:disposed", {})
|
|
1542
1592
|
})
|
|
1543
1593
|
|
|
1544
1594
|
// Live widget: show running agents above editor.
|
|
@@ -1831,6 +1881,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
1831
1881
|
}
|
|
1832
1882
|
}
|
|
1833
1883
|
|
|
1884
|
+
/**
|
|
1885
|
+
* The turn budget for resuming `record`, from its OWN type — resume ignores
|
|
1886
|
+
* `subagent_type`. Same precedence as a spawn, frontmatter authoritative:
|
|
1887
|
+
* the agent file's `max_turns`, else the call's, else the limit the agent
|
|
1888
|
+
* was spawned with, else the default (#368).
|
|
1889
|
+
*/
|
|
1890
|
+
function resumeTurnLimit(
|
|
1891
|
+
record: AgentRecord,
|
|
1892
|
+
explicit?: number,
|
|
1893
|
+
): number | undefined {
|
|
1894
|
+
return normalizeMaxTurns(
|
|
1895
|
+
getAgentConfig(record.type)?.maxTurns ??
|
|
1896
|
+
explicit ??
|
|
1897
|
+
record.invocation?.maxTurns ??
|
|
1898
|
+
getDefaultMaxTurns(),
|
|
1899
|
+
)
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1834
1902
|
/**
|
|
1835
1903
|
* Launch a detached resume of an existing agent and wire everything a
|
|
1836
1904
|
* re-running agent needs: transcript anchoring, activity tracking, join-mode
|
|
@@ -1886,6 +1954,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1886
1954
|
// run_in_background in that same turn keep going.
|
|
1887
1955
|
const record = await manager.resume(id, prompt, undefined, {
|
|
1888
1956
|
isBackground: true,
|
|
1957
|
+
maxTurns: opts.maxTurns,
|
|
1958
|
+
onTurnEnd: bgCallbacks.onTurnEnd,
|
|
1889
1959
|
onToolActivity: bgCallbacks.onToolActivity,
|
|
1890
1960
|
onAssistantUsage: bgCallbacks.onAssistantUsage,
|
|
1891
1961
|
// Fires when the run actually starts — immediately, or on queue
|
|
@@ -2240,7 +2310,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2240
2310
|
resume: Type.Optional(
|
|
2241
2311
|
Type.String({
|
|
2242
2312
|
description:
|
|
2243
|
-
"Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
|
|
2313
|
+
"Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. The resumed run gets a fresh max_turns budget. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
|
|
2244
2314
|
}),
|
|
2245
2315
|
),
|
|
2246
2316
|
isolated: Type.Optional(
|
|
@@ -2692,6 +2762,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2692
2762
|
`Agent "${params.resume}" has no active session to resume.`,
|
|
2693
2763
|
)
|
|
2694
2764
|
}
|
|
2765
|
+
// Not `effectiveMaxTurns`: that was resolved for `subagent_type`, which
|
|
2766
|
+
// a resume ignores, so it may describe another agent.
|
|
2767
|
+
const resumeMaxTurns = resumeTurnLimit(existing, params.max_turns)
|
|
2695
2768
|
|
|
2696
2769
|
// Background resume: detached run that notifies on completion, mirroring
|
|
2697
2770
|
// a background spawn. Previously run_in_background was silently ignored
|
|
@@ -2717,7 +2790,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2717
2790
|
params.prompt,
|
|
2718
2791
|
{
|
|
2719
2792
|
outputTranscript,
|
|
2720
|
-
maxTurns:
|
|
2793
|
+
maxTurns: resumeMaxTurns,
|
|
2721
2794
|
toolCallId,
|
|
2722
2795
|
},
|
|
2723
2796
|
)
|
|
@@ -2747,11 +2820,29 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2747
2820
|
)
|
|
2748
2821
|
}
|
|
2749
2822
|
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2823
|
+
// Tracked like a foreground spawn, so the widget row and the result
|
|
2824
|
+
// card show THIS run's `↻N≤M` and tool activity rather than the
|
|
2825
|
+
// previous run's. The session predates this run, so it is seeded here.
|
|
2826
|
+
const { state: resumeState, callbacks: resumeCallbacks } =
|
|
2827
|
+
createActivityTracker(resumeMaxTurns)
|
|
2828
|
+
resumeState.session = existing.session
|
|
2829
|
+
agentActivity.set(existing.id, resumeState)
|
|
2830
|
+
widget.markRunning(existing.id)
|
|
2831
|
+
widget.ensureTimer()
|
|
2832
|
+
fleet.ensureTimer()
|
|
2833
|
+
fleet.update()
|
|
2834
|
+
let record: AgentRecord | undefined
|
|
2835
|
+
try {
|
|
2836
|
+
record = await manager.resume(params.resume, params.prompt, signal, {
|
|
2837
|
+
maxTurns: resumeMaxTurns,
|
|
2838
|
+
onTurnEnd: resumeCallbacks.onTurnEnd,
|
|
2839
|
+
onToolActivity: resumeCallbacks.onToolActivity,
|
|
2840
|
+
})
|
|
2841
|
+
} finally {
|
|
2842
|
+
agentActivity.delete(existing.id)
|
|
2843
|
+
widget.markFinished(existing.id)
|
|
2844
|
+
fleet.onAgentFinished(existing.id)
|
|
2845
|
+
}
|
|
2755
2846
|
if (!record) {
|
|
2756
2847
|
return textResult(`Failed to resume agent "${params.resume}".`)
|
|
2757
2848
|
}
|
|
@@ -2760,12 +2851,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2760
2851
|
if (record.status === "error") {
|
|
2761
2852
|
return textResult(
|
|
2762
2853
|
`Agent failed: ${record.error}${partialOutputSuffix(record)}`,
|
|
2763
|
-
buildDetails(detailBaseFor(record), record),
|
|
2854
|
+
buildDetails(detailBaseFor(record), record, resumeState),
|
|
2764
2855
|
)
|
|
2765
2856
|
}
|
|
2766
2857
|
return textResult(
|
|
2767
2858
|
record.result?.trim() || "No output.",
|
|
2768
|
-
buildDetails(detailBaseFor(record), record),
|
|
2859
|
+
buildDetails(detailBaseFor(record), record, resumeState),
|
|
2769
2860
|
)
|
|
2770
2861
|
}
|
|
2771
2862
|
|
package/src/nested-tools.ts
CHANGED
|
@@ -112,6 +112,7 @@ export interface NestedAgentManager {
|
|
|
112
112
|
id: string,
|
|
113
113
|
prompt: string,
|
|
114
114
|
signal?: AbortSignal,
|
|
115
|
+
options?: { maxTurns?: number },
|
|
115
116
|
): Promise<AgentRecord | undefined>
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -242,10 +243,17 @@ export function createNestedSubagentTools(
|
|
|
242
243
|
true,
|
|
243
244
|
)
|
|
244
245
|
}
|
|
246
|
+
// Frontmatter authoritative, as on the spawn path; the manager falls
|
|
247
|
+
// back to the limit the child was spawned with, then the default.
|
|
245
248
|
const resumed = await context.manager.resume(
|
|
246
249
|
params.resume,
|
|
247
250
|
params.prompt,
|
|
248
251
|
signal,
|
|
252
|
+
{
|
|
253
|
+
maxTurns:
|
|
254
|
+
getAgentConfigIn(loadRegistry(), existing.type)?.maxTurns ??
|
|
255
|
+
params.max_turns,
|
|
256
|
+
},
|
|
249
257
|
)
|
|
250
258
|
return resumed
|
|
251
259
|
? textResult(
|