@herbertgao/pi-subagents 0.17.1 → 0.18.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 +12 -0
- package/README.md +427 -120
- package/docs/rpc.md +184 -0
- package/docs/workflows.md +466 -0
- package/examples/agent-tool-description.md +6 -6
- package/examples/workflows/compose.js +52 -0
- package/examples/workflows/fan-out-audit.js +56 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +30 -0
- package/examples/workflows/review-panel.js +68 -0
- package/examples/workflows/structured-findings.js +81 -0
- package/package.json +12 -10
- package/src/agent-file-toggle.ts +52 -12
- package/src/agent-manager.ts +837 -146
- package/src/agent-runner.ts +213 -39
- package/src/cross-extension-rpc.ts +73 -14
- package/src/custom-agents.ts +101 -47
- package/src/index.ts +2249 -914
- package/src/invocation-config.ts +13 -0
- package/src/mention-clone.ts +215 -0
- package/src/mention.ts +147 -0
- package/src/model-resolver.ts +9 -1
- package/src/nested-tools.ts +40 -26
- package/src/output-file.ts +18 -8
- package/src/prompts.ts +46 -9
- package/src/schedule.ts +21 -16
- package/src/settings.ts +137 -7
- package/src/structured-output.ts +136 -0
- package/src/types.ts +126 -8
- package/src/ui/agent-mention.ts +274 -0
- package/src/ui/agent-widget.ts +20 -5
- package/src/ui/conversation-viewer.ts +10 -4
- package/src/ui/fleet-list.ts +167 -22
- package/src/ui/workflow-card.ts +555 -0
- package/src/ui/workflow-dialog.ts +1304 -0
- package/src/ui/workflow-menu.ts +226 -0
- package/src/workflow/collisions.ts +122 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +463 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +142 -0
- package/src/workflow/meta.ts +401 -0
- package/src/workflow/progress.ts +622 -0
- package/src/workflow/runtime.ts +1399 -0
- package/src/workflow/saved.ts +230 -0
- package/src/workflow/task.ts +333 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +97 -95
- package/src/xml.ts +13 -0
package/src/agent-runner.ts
CHANGED
|
@@ -40,8 +40,14 @@ import {
|
|
|
40
40
|
} from "./nested-tools.js"
|
|
41
41
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js"
|
|
42
42
|
import { preloadSkills } from "./skill-loader.js"
|
|
43
|
+
import {
|
|
44
|
+
createStructuredCapture,
|
|
45
|
+
createStructuredOutputTool,
|
|
46
|
+
structuredRetryPrompt,
|
|
47
|
+
} from "./structured-output.js"
|
|
43
48
|
import type { SubagentType, ThinkingLevel } from "./types.js"
|
|
44
49
|
import type { LifetimeUsage } from "./usage.js"
|
|
50
|
+
import type { CompiledSchema } from "./workflow/json-schema.js"
|
|
45
51
|
|
|
46
52
|
/**
|
|
47
53
|
* Tool names registered by THIS extension. Single source of truth so the
|
|
@@ -51,6 +57,7 @@ import type { LifetimeUsage } from "./usage.js"
|
|
|
51
57
|
*/
|
|
52
58
|
export const SUBAGENT_TOOL_NAMES = {
|
|
53
59
|
AGENT: "Agent",
|
|
60
|
+
WORKFLOW: "SubagentWorkflow",
|
|
54
61
|
GET_RESULT: "get_subagent_result",
|
|
55
62
|
STEER: "steer_subagent",
|
|
56
63
|
} as const
|
|
@@ -253,8 +260,15 @@ export function installExtensionToolScope(
|
|
|
253
260
|
disallowedSet: Set<string> | undefined
|
|
254
261
|
extNames: Set<string>
|
|
255
262
|
narrowing: Map<string, Set<string>>
|
|
256
|
-
/**
|
|
257
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Injected `customTools` to keep active regardless of the built-in list.
|
|
265
|
+
*
|
|
266
|
+
* Two kinds arrive here and they are blocked for different reasons: opt-in
|
|
267
|
+
* nested-delegation tools share EXCLUDED_TOOL_NAMES' names, and
|
|
268
|
+
* StructuredOutput is simply not a built-in, so neither survives a `keep`
|
|
269
|
+
* seeded from `toolNames`.
|
|
270
|
+
*/
|
|
271
|
+
readmitToolNames: Set<string>
|
|
258
272
|
},
|
|
259
273
|
): void {
|
|
260
274
|
const {
|
|
@@ -263,7 +277,7 @@ export function installExtensionToolScope(
|
|
|
263
277
|
disallowedSet,
|
|
264
278
|
extNames,
|
|
265
279
|
narrowing,
|
|
266
|
-
|
|
280
|
+
readmitToolNames,
|
|
267
281
|
} = ctx
|
|
268
282
|
|
|
269
283
|
// The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
|
|
@@ -286,12 +300,11 @@ export function installExtensionToolScope(
|
|
|
286
300
|
}
|
|
287
301
|
}
|
|
288
302
|
for (const name of EXCLUDED_TOOL_NAMES) keep.delete(name)
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// them
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
303
|
+
// Injected tools are legitimately active for this agent — re-admit them so
|
|
304
|
+
// the renarrow keeps them in the active set and beforeToolCall doesn't
|
|
305
|
+
// block them. Already vetted against `disallowed_tools` by the caller,
|
|
306
|
+
// which is the only place that knows which kind may be taken back.
|
|
307
|
+
for (const name of readmitToolNames) keep.add(name)
|
|
295
308
|
return keep
|
|
296
309
|
}
|
|
297
310
|
|
|
@@ -350,6 +363,15 @@ export function setDefaultMaxTurns(n: number | undefined): void {
|
|
|
350
363
|
defaultMaxTurns = normalizeMaxTurns(n)
|
|
351
364
|
}
|
|
352
365
|
|
|
366
|
+
/**
|
|
367
|
+
* The turn limit a run of `type` will actually enforce: an explicit value if the
|
|
368
|
+
* caller supplied one, else the agent's own `max_turns`, else the project
|
|
369
|
+
* default. `undefined` = unlimited.
|
|
370
|
+
*
|
|
371
|
+
* Exported because the widget's turn counter (`↻3≤20`) has to predict this
|
|
372
|
+
* before the run starts, and a second copy of the expression would drift from
|
|
373
|
+
* the one below that enforces it.
|
|
374
|
+
*/
|
|
353
375
|
export function resolveEffectiveMaxTurns(
|
|
354
376
|
type: string,
|
|
355
377
|
explicit?: number,
|
|
@@ -359,6 +381,24 @@ export function resolveEffectiveMaxTurns(
|
|
|
359
381
|
)
|
|
360
382
|
}
|
|
361
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Project default for `persist_session`, from the `rememberAgents` setting.
|
|
386
|
+
* On by default: a persisted session is what lets `@handle` reopen an agent's
|
|
387
|
+
* conversation after its record has been evicted, which is the whole point of
|
|
388
|
+
* addressing an agent by a name that outlives one run. Per-agent frontmatter
|
|
389
|
+
* still overrides it in both directions.
|
|
390
|
+
*/
|
|
391
|
+
let rememberAgents = true
|
|
392
|
+
|
|
393
|
+
/** Whether subagent sessions are persisted by default. */
|
|
394
|
+
export function getRememberAgents(): boolean {
|
|
395
|
+
return rememberAgents
|
|
396
|
+
}
|
|
397
|
+
/** Set whether subagent sessions are persisted by default. */
|
|
398
|
+
export function setRememberAgents(b: boolean): void {
|
|
399
|
+
rememberAgents = b
|
|
400
|
+
}
|
|
401
|
+
|
|
362
402
|
/** Additional turns allowed after the soft limit steer message. */
|
|
363
403
|
let graceTurns = 5
|
|
364
404
|
|
|
@@ -422,9 +462,38 @@ export interface RunOptions {
|
|
|
422
462
|
isolated?: boolean
|
|
423
463
|
inheritContext?: boolean
|
|
424
464
|
thinkingLevel?: ThinkingLevel
|
|
465
|
+
/**
|
|
466
|
+
* Reopen this pi session file rather than starting an empty conversation.
|
|
467
|
+
* `createAgentSession` seeds itself from whatever its SessionManager holds,
|
|
468
|
+
* so pointing it at an existing file rehydrates that agent's history and the
|
|
469
|
+
* prompt continues it. Everything else — tools, model, system prompt, turn
|
|
470
|
+
* caps — is still resolved from the agent type, so the continuation runs
|
|
471
|
+
* under the type's *current* definition, not the one the original run used.
|
|
472
|
+
*/
|
|
473
|
+
resumeSessionFile?: string
|
|
474
|
+
/**
|
|
475
|
+
* True when another agent spawned this one. Only top-level agents get a
|
|
476
|
+
* handle, so only they can be reopened by name — which is the whole reason
|
|
477
|
+
* `rememberAgents` persists a session at all. A nested run's transcript would
|
|
478
|
+
* be unreachable by anything, so it stays in memory unless its own
|
|
479
|
+
* frontmatter asks otherwise.
|
|
480
|
+
*/
|
|
481
|
+
nested?: boolean
|
|
482
|
+
/**
|
|
483
|
+
* True when a workflow run spawned this agent. Its final text is the value
|
|
484
|
+
* `agent()` resolves to rather than a report a person reads, and the prompt
|
|
485
|
+
* says so — but only when `structuredOutput` is unset, since that child
|
|
486
|
+
* already has a `StructuredOutput` tool to answer through and two competing
|
|
487
|
+
* "this is how you return your answer" instructions is worse than one.
|
|
488
|
+
*/
|
|
489
|
+
workflow?: boolean
|
|
425
490
|
/** Override working directory (e.g. for worktree isolation). */
|
|
426
491
|
cwd?: string
|
|
427
|
-
/**
|
|
492
|
+
/**
|
|
493
|
+
* Directory the worktree copy was created from. Set only when `cwd` points
|
|
494
|
+
* into a worktree — the prompt then tells the agent to stay in the copy
|
|
495
|
+
* instead of following the inherited parent prompt back to the main tree.
|
|
496
|
+
*/
|
|
428
497
|
worktreeBase?: string
|
|
429
498
|
/**
|
|
430
499
|
* Where .pi config is discovered (project extensions, skills, pi settings,
|
|
@@ -451,6 +520,11 @@ export interface RunOptions {
|
|
|
451
520
|
* Called once per assistant message_end with that message's usage delta.
|
|
452
521
|
* Lets callers maintain a lifetime accumulator that survives compaction
|
|
453
522
|
* (which replaces session.state.messages and resets stats-derived sums).
|
|
523
|
+
*
|
|
524
|
+
* `cost` is pi's own `usage.cost.total` for that message — priced from the
|
|
525
|
+
* model's rates, so it is 0 (not missing) for a model pi has no pricing for.
|
|
526
|
+
* We never price anything ourselves; every dollar figure this extension shows
|
|
527
|
+
* or reports traces back to this field.
|
|
454
528
|
*/
|
|
455
529
|
onAssistantUsage?: (usage: LifetimeUsage) => void
|
|
456
530
|
/**
|
|
@@ -461,6 +535,14 @@ export interface RunOptions {
|
|
|
461
535
|
reason: "manual" | "threshold" | "overflow"
|
|
462
536
|
tokensBefore: number
|
|
463
537
|
}) => void
|
|
538
|
+
/**
|
|
539
|
+
* Make this child report through a `StructuredOutput` tool built from this
|
|
540
|
+
* schema, and put the validated payload on {@link RunResult.structuredJson}.
|
|
541
|
+
*
|
|
542
|
+
* Already compiled by the caller, so a schema this runtime cannot validate
|
|
543
|
+
* fails at the call that wrote it rather than inside the child.
|
|
544
|
+
*/
|
|
545
|
+
structuredOutput?: CompiledSchema
|
|
464
546
|
/** Runtime bridge for opt-in child-safe nested delegation. */
|
|
465
547
|
nestedRuntime?: {
|
|
466
548
|
manager: NestedAgentManager
|
|
@@ -487,6 +569,18 @@ export interface RunResult {
|
|
|
487
569
|
* stop that produced text (a legitimate truncated answer).
|
|
488
570
|
*/
|
|
489
571
|
failure?: string
|
|
572
|
+
/**
|
|
573
|
+
* The validated `StructuredOutput` payload as canonical JSON, when the caller
|
|
574
|
+
* asked for a schema and the child produced one.
|
|
575
|
+
*
|
|
576
|
+
* Deliberately not folded into {@link responseText}: `record.result` picks up
|
|
577
|
+
* a worktree branch note on the way out, which would leave the caller with
|
|
578
|
+
* unparseable JSON, and merging the two would make "produced structured
|
|
579
|
+
* output" indistinguishable from "happened to answer in JSON".
|
|
580
|
+
*/
|
|
581
|
+
structuredJson?: string
|
|
582
|
+
/** Whether the extra structured-output prompt had to be sent. */
|
|
583
|
+
structuredRetried?: boolean
|
|
490
584
|
}
|
|
491
585
|
|
|
492
586
|
/**
|
|
@@ -610,7 +704,9 @@ export async function runAgent(
|
|
|
610
704
|
const parentSystemPrompt = ctx.getSystemPrompt()
|
|
611
705
|
|
|
612
706
|
// Build prompt extras (memory, skill preloading)
|
|
613
|
-
const extras: PromptExtras = {
|
|
707
|
+
const extras: PromptExtras = {}
|
|
708
|
+
if (options.worktreeBase) extras.worktreeBase = options.worktreeBase
|
|
709
|
+
if (options.workflow && !options.structuredOutput) extras.workflowChild = true
|
|
614
710
|
|
|
615
711
|
// Resolve extensions/skills: isolated overrides to false
|
|
616
712
|
const extensions = options.isolated ? false : config.extensions
|
|
@@ -884,6 +980,36 @@ export async function runAgent(
|
|
|
884
980
|
: []
|
|
885
981
|
const nestedToolNames = new Set(nestedTools.map((tool) => tool.name))
|
|
886
982
|
|
|
983
|
+
// The `agent({ schema })` contract: this child reports its answer by calling
|
|
984
|
+
// StructuredOutput, and `structuredJson` below is what the caller reads. The
|
|
985
|
+
// schema was already compiled by whoever asked for it, so a bad one failed
|
|
986
|
+
// before any of this ran.
|
|
987
|
+
const structuredCapture = options.structuredOutput
|
|
988
|
+
? createStructuredCapture()
|
|
989
|
+
: undefined
|
|
990
|
+
const structuredTools =
|
|
991
|
+
options.structuredOutput && structuredCapture
|
|
992
|
+
? [
|
|
993
|
+
createStructuredOutputTool(
|
|
994
|
+
options.structuredOutput,
|
|
995
|
+
structuredCapture,
|
|
996
|
+
),
|
|
997
|
+
]
|
|
998
|
+
: []
|
|
999
|
+
const structuredToolNames = new Set(structuredTools.map((tool) => tool.name))
|
|
1000
|
+
// Re-admitted together at every gate below. Kept as one set so a new injected
|
|
1001
|
+
// tool cannot be added to some of the three gates and forgotten at the rest.
|
|
1002
|
+
//
|
|
1003
|
+
// `disallowed_tools` is applied HERE rather than at the gates, because the two
|
|
1004
|
+
// kinds answer to it differently: a nested delegation tool is an opt-in the
|
|
1005
|
+
// agent's own frontmatter can take back, while StructuredOutput exists only
|
|
1006
|
+
// because this call asked for a schema — removing it would make the request
|
|
1007
|
+
// unsatisfiable by construction rather than merely restricted.
|
|
1008
|
+
const readmitToolNames = new Set([
|
|
1009
|
+
...[...nestedToolNames].filter((name) => !disallowedSet?.has(name)),
|
|
1010
|
+
...structuredToolNames,
|
|
1011
|
+
])
|
|
1012
|
+
|
|
887
1013
|
// ─── Tool scoping ───────────────────────────────────────────────────────
|
|
888
1014
|
//
|
|
889
1015
|
// Some extensions register their tools ASYNCHRONOUSLY, long after the
|
|
@@ -923,6 +1049,11 @@ export async function runAgent(
|
|
|
923
1049
|
(t) => !EXCLUDED_TOOL_NAMES.includes(t) && !disallowedSet?.has(t),
|
|
924
1050
|
),
|
|
925
1051
|
...[...nestedToolNames].filter((t) => !disallowedSet?.has(t)),
|
|
1052
|
+
// Not filtered through `disallowedSet`, unlike the nested tools above:
|
|
1053
|
+
// the caller asked for a schema, and removing the only tool that can
|
|
1054
|
+
// satisfy it would make the request unsatisfiable by construction rather
|
|
1055
|
+
// than merely restricted.
|
|
1056
|
+
...structuredToolNames,
|
|
926
1057
|
]
|
|
927
1058
|
} else {
|
|
928
1059
|
// Deny the orchestration tools EXCEPT the nested ones this agent opted into —
|
|
@@ -936,7 +1067,10 @@ export async function runAgent(
|
|
|
936
1067
|
}
|
|
937
1068
|
if (disallowedSet) {
|
|
938
1069
|
// disallowed_tools wins even over an opt-in nested tool of the same name.
|
|
939
|
-
|
|
1070
|
+
// Not over StructuredOutput, though — see the allowlist branch above.
|
|
1071
|
+
for (const name of disallowedSet) {
|
|
1072
|
+
if (!structuredToolNames.has(name)) denyTools.add(name)
|
|
1073
|
+
}
|
|
940
1074
|
}
|
|
941
1075
|
sessionExcludeTools = [...denyTools]
|
|
942
1076
|
}
|
|
@@ -948,26 +1082,43 @@ export async function runAgent(
|
|
|
948
1082
|
)
|
|
949
1083
|
const defaultSessionDir =
|
|
950
1084
|
process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.()
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1085
|
+
// Frontmatter wins when it says anything; otherwise the project default,
|
|
1086
|
+
// which `rememberAgents` supplies for top-level agents only. Same precedence
|
|
1087
|
+
// as `outputTranscript`.
|
|
1088
|
+
const persistSession =
|
|
1089
|
+
agentConfig?.persistSession ?? (options.nested ? false : rememberAgents)
|
|
1090
|
+
const sessionManager = options.resumeSessionFile
|
|
1091
|
+
? // Reopening an existing conversation: the file already carries its own
|
|
1092
|
+
// header (cwd, parent) and history, so none of the create-time options
|
|
1093
|
+
// apply. `sessionDir` still matters for a later /new or /branch off it.
|
|
1094
|
+
SessionManager.open(
|
|
1095
|
+
options.resumeSessionFile,
|
|
954
1096
|
configuredSessionDir ?? defaultSessionDir,
|
|
955
|
-
{
|
|
956
|
-
parentSession: ctx.sessionManager.getSessionFile(),
|
|
957
|
-
},
|
|
958
1097
|
)
|
|
959
|
-
:
|
|
1098
|
+
: persistSession
|
|
1099
|
+
? SessionManager.create(
|
|
1100
|
+
effectiveCwd,
|
|
1101
|
+
configuredSessionDir ?? defaultSessionDir,
|
|
1102
|
+
{
|
|
1103
|
+
// Optional metadata — it only nests the subagent under its spawner in
|
|
1104
|
+
// `/resume`. Until `rememberAgents` this ran solely for the rare
|
|
1105
|
+
// `persist_session: true` agent; now it runs for every spawn, so a
|
|
1106
|
+
// context without a session manager (a bare programmatic ctx) must
|
|
1107
|
+
// still persist rather than take the whole spawn down.
|
|
1108
|
+
parentSession: ctx.sessionManager?.getSessionFile?.(),
|
|
1109
|
+
},
|
|
1110
|
+
)
|
|
1111
|
+
: SessionManager.inMemory(effectiveCwd)
|
|
960
1112
|
|
|
961
1113
|
// Pi 0.80.8 replaced createAgentSession's modelRegistry option with
|
|
962
1114
|
// modelRuntime, but ExtensionContext still exposes only the registry facade.
|
|
963
1115
|
// Pass both so the full supported Pi range retains the parent's providers.
|
|
964
|
-
|
|
1116
|
+
// SAFETY: Pi's registry facade exposes its runtime at runtime on versions
|
|
1117
|
+
// that support modelRuntime; the optional shape preserves older Pi support.
|
|
965
1118
|
const parentModelRuntime = (
|
|
966
|
-
ctx.modelRegistry as unknown as {
|
|
967
|
-
runtime?: unknown
|
|
968
|
-
}
|
|
1119
|
+
ctx.modelRegistry as unknown as { runtime?: unknown }
|
|
969
1120
|
).runtime
|
|
970
|
-
const sessionOpts:
|
|
1121
|
+
const sessionOpts: Parameters<typeof createAgentSession>[0] & {
|
|
971
1122
|
modelRegistry: ExtensionContext["modelRegistry"]
|
|
972
1123
|
modelRuntime?: unknown
|
|
973
1124
|
} = {
|
|
@@ -976,12 +1127,16 @@ export async function runAgent(
|
|
|
976
1127
|
sessionManager,
|
|
977
1128
|
settingsManager,
|
|
978
1129
|
modelRegistry: ctx.modelRegistry,
|
|
979
|
-
|
|
1130
|
+
// `as never` is what keeps this assignable across the supported Pi range:
|
|
1131
|
+
// pre-0.80.8 the field exists only via the `modelRuntime?: unknown` shim
|
|
1132
|
+
// above, while newer Pi types it as `ModelRuntime` — a shape an opaque
|
|
1133
|
+
// `unknown` read off the private facade field can never satisfy.
|
|
1134
|
+
...(parentModelRuntime !== undefined && {
|
|
980
1135
|
modelRuntime: parentModelRuntime as never,
|
|
981
1136
|
}),
|
|
982
1137
|
model,
|
|
983
1138
|
tools: sessionTools,
|
|
984
|
-
customTools: nestedTools,
|
|
1139
|
+
customTools: [...nestedTools, ...structuredTools],
|
|
985
1140
|
resourceLoader: loader,
|
|
986
1141
|
}
|
|
987
1142
|
if (sessionExcludeTools) {
|
|
@@ -1028,7 +1183,7 @@ export async function runAgent(
|
|
|
1028
1183
|
disallowedSet,
|
|
1029
1184
|
extNames,
|
|
1030
1185
|
narrowing,
|
|
1031
|
-
|
|
1186
|
+
readmitToolNames,
|
|
1032
1187
|
})
|
|
1033
1188
|
}
|
|
1034
1189
|
|
|
@@ -1110,8 +1265,24 @@ export async function runAgent(
|
|
|
1110
1265
|
// Boundary for the history fallback: only assistant text produced from here
|
|
1111
1266
|
// on counts as this run's output (a fresh session, so usually 0).
|
|
1112
1267
|
const startLen = session.messages.length
|
|
1268
|
+
let structuredRetried = false
|
|
1113
1269
|
try {
|
|
1114
1270
|
await session.prompt(effectivePrompt)
|
|
1271
|
+
|
|
1272
|
+
// One more prompt when a schema was asked for and nothing usable came back
|
|
1273
|
+
// — the model answered in prose, or only ever called the tool invalidly.
|
|
1274
|
+
// Inside this `try`, so the turn tracking, the text collector and above all
|
|
1275
|
+
// the abort forwarding are still live: torn down first, a retry would be
|
|
1276
|
+
// unkillable.
|
|
1277
|
+
if (
|
|
1278
|
+
structuredCapture !== undefined &&
|
|
1279
|
+
structuredCapture.json === undefined &&
|
|
1280
|
+
!aborted &&
|
|
1281
|
+
options.signal?.aborted !== true
|
|
1282
|
+
) {
|
|
1283
|
+
structuredRetried = true
|
|
1284
|
+
await session.prompt(structuredRetryPrompt(structuredCapture))
|
|
1285
|
+
}
|
|
1115
1286
|
} finally {
|
|
1116
1287
|
unsubTurns()
|
|
1117
1288
|
collector.unsubscribe()
|
|
@@ -1120,12 +1291,25 @@ export async function runAgent(
|
|
|
1120
1291
|
|
|
1121
1292
|
const responseText =
|
|
1122
1293
|
collector.getText().trim() || getLastAssistantText(session, startLen)
|
|
1294
|
+
// A child asked for structured output that never gave any has failed, however
|
|
1295
|
+
// articulate its prose was. Reported through `failure` so it travels the same
|
|
1296
|
+
// path as a provider error rather than arriving as a successful empty answer.
|
|
1297
|
+
const structuredFailure =
|
|
1298
|
+
structuredCapture !== undefined && structuredCapture.json === undefined
|
|
1299
|
+
? structuredCapture.lastError !== undefined
|
|
1300
|
+
? `The agent's StructuredOutput call did not match the required schema: ${structuredCapture.lastError}`
|
|
1301
|
+
: "The agent did not report its answer through StructuredOutput."
|
|
1302
|
+
: undefined
|
|
1123
1303
|
return {
|
|
1124
1304
|
responseText,
|
|
1125
1305
|
session,
|
|
1126
1306
|
aborted,
|
|
1127
1307
|
steered: softLimitReached,
|
|
1128
|
-
failure: finalTurnError(session, startLen),
|
|
1308
|
+
failure: finalTurnError(session, startLen) ?? structuredFailure,
|
|
1309
|
+
...(structuredCapture?.json !== undefined
|
|
1310
|
+
? { structuredJson: structuredCapture.json }
|
|
1311
|
+
: {}),
|
|
1312
|
+
...(structuredRetried ? { structuredRetried } : {}),
|
|
1129
1313
|
}
|
|
1130
1314
|
}
|
|
1131
1315
|
|
|
@@ -1137,8 +1321,6 @@ export async function resumeAgent(
|
|
|
1137
1321
|
prompt: string,
|
|
1138
1322
|
options: {
|
|
1139
1323
|
onToolActivity?: (activity: ToolActivity) => void
|
|
1140
|
-
/** Called at the end of each resumed agentic turn with the 1-based count. */
|
|
1141
|
-
onTurnEnd?: (turnCount: number) => void
|
|
1142
1324
|
onAssistantUsage?: (usage: LifetimeUsage) => void
|
|
1143
1325
|
onCompaction?: (info: {
|
|
1144
1326
|
reason: "manual" | "threshold" | "overflow"
|
|
@@ -1153,18 +1335,10 @@ export async function resumeAgent(
|
|
|
1153
1335
|
const startLen = session.messages.length
|
|
1154
1336
|
const collector = collectResponseText(session)
|
|
1155
1337
|
const cleanupAbort = forwardAbortSignal(session, options.signal)
|
|
1156
|
-
let turnCount = 0
|
|
1157
1338
|
|
|
1158
1339
|
const unsubEvents =
|
|
1159
|
-
options.onToolActivity ||
|
|
1160
|
-
options.onTurnEnd ||
|
|
1161
|
-
options.onAssistantUsage ||
|
|
1162
|
-
options.onCompaction
|
|
1340
|
+
options.onToolActivity || options.onAssistantUsage || options.onCompaction
|
|
1163
1341
|
? session.subscribe((event: AgentSessionEvent) => {
|
|
1164
|
-
if (event.type === "turn_end") {
|
|
1165
|
-
turnCount++
|
|
1166
|
-
options.onTurnEnd?.(turnCount)
|
|
1167
|
-
}
|
|
1168
1342
|
if (event.type === "tool_execution_start")
|
|
1169
1343
|
options.onToolActivity?.({
|
|
1170
1344
|
type: "start",
|
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
* Reply envelope follows pi-mono convention:
|
|
8
8
|
* success → { success: true, data?: T }
|
|
9
9
|
* error → { success: false, error: string }
|
|
10
|
+
*
|
|
11
|
+
* @see docs/rpc.md — the caller-facing integration reference: spawn options
|
|
12
|
+
* (including the fields spawnTopLevel strips), every error string, the
|
|
13
|
+
* completion-notification race, and what protocol version 2 does not promise.
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
import { isTopLevelAgent } from "./agent-manager.js"
|
|
12
17
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js"
|
|
13
18
|
import { checkModelScope } from "./model-scope.js"
|
|
19
|
+
import type { AgentRecord } from "./types.js"
|
|
14
20
|
|
|
15
21
|
/** Minimal event bus interface needed by the RPC handlers. */
|
|
16
22
|
export interface EventBus {
|
|
@@ -35,7 +41,22 @@ export interface SpawnCapable {
|
|
|
35
41
|
prompt: string,
|
|
36
42
|
options: any,
|
|
37
43
|
): string
|
|
44
|
+
/** Resolves once the spawned agent is running; rejects on a startup failure. */
|
|
45
|
+
awaitStartup(id: string): Promise<void>
|
|
38
46
|
abort(id: string): boolean
|
|
47
|
+
/**
|
|
48
|
+
* The record behind an id, for the stop handler's ownership check. Narrowed
|
|
49
|
+
* to the two fields `isTopLevelAgent` reads, so the RPC layer keeps its
|
|
50
|
+
* deliberately shallow view of the manager.
|
|
51
|
+
*/
|
|
52
|
+
getRecord(
|
|
53
|
+
id: string,
|
|
54
|
+
): Pick<AgentRecord, "parentAgentId" | "workflowId"> | undefined
|
|
55
|
+
/**
|
|
56
|
+
* Mark a settled agent's result as read by the caller, suppressing the
|
|
57
|
+
* completion notification — what `get_subagent_result` does when it returns
|
|
58
|
+
* one. False when there is no such agent, or it has not settled yet.
|
|
59
|
+
*/
|
|
39
60
|
consumeResult(id: string): boolean
|
|
40
61
|
}
|
|
41
62
|
|
|
@@ -94,7 +115,7 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
94
115
|
type: string
|
|
95
116
|
prompt: string
|
|
96
117
|
options?: any
|
|
97
|
-
}>(events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
|
|
118
|
+
}>(events, "subagents:rpc:spawn", async ({ type, prompt, options }) => {
|
|
98
119
|
const ctx = getCtx()
|
|
99
120
|
if (!ctx) throw new Error("No active session")
|
|
100
121
|
|
|
@@ -105,13 +126,17 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
105
126
|
// agent's auth lookup doesn't crash with "No API key found for
|
|
106
127
|
// undefined".
|
|
107
128
|
let normalizedOptions = options ?? {}
|
|
129
|
+
// `!= null` on purpose: a JSON-forwarding caller can serialize an unset
|
|
130
|
+
// field as null, and the runner reads `options.model ?? default`, so null
|
|
131
|
+
// means "inherit" — not an override to resolve or scope-check.
|
|
108
132
|
const override = normalizedOptions.model
|
|
109
|
-
// null means inherit, matching the runner's `model ?? default` behavior.
|
|
110
133
|
if (override != null) {
|
|
111
134
|
const { modelRegistry, cwd } = ctx as {
|
|
112
135
|
modelRegistry?: ModelRegistry
|
|
113
136
|
cwd?: string
|
|
114
137
|
}
|
|
138
|
+
// Names the override the same way in both messages below; an object
|
|
139
|
+
// override would otherwise interpolate as "[object Object]".
|
|
115
140
|
const label =
|
|
116
141
|
typeof override === "string"
|
|
117
142
|
? override
|
|
@@ -121,15 +146,25 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
121
146
|
`Model override "${label}" provided but ctx.modelRegistry is unavailable`,
|
|
122
147
|
)
|
|
123
148
|
}
|
|
124
|
-
|
|
125
149
|
let model = override
|
|
126
150
|
if (typeof override === "string") {
|
|
127
151
|
const resolved = resolveModel(override, modelRegistry)
|
|
128
|
-
if (typeof resolved === "string")
|
|
152
|
+
if (typeof resolved === "string") {
|
|
153
|
+
// resolveModel returns a human-readable error string when the
|
|
154
|
+
// input doesn't match any available model. Surface it instead of
|
|
155
|
+
// silently falling back so the caller sees the auth/typo issue.
|
|
156
|
+
throw new Error(resolved)
|
|
157
|
+
}
|
|
129
158
|
model = resolved
|
|
130
159
|
normalizedOptions = { ...normalizedOptions, model: resolved }
|
|
131
160
|
}
|
|
132
161
|
|
|
162
|
+
// A model on the RPC payload is an orchestrator-level choice, exactly
|
|
163
|
+
// like Agent({ model }) — so it gets the Agent tool's hard error, never
|
|
164
|
+
// the frontmatter warn (#240). The check reads the RESOLVED model:
|
|
165
|
+
// resolveModel is fuzzy, so a bare "sonnet" can land on a provider the
|
|
166
|
+
// caller never named. Frontmatter-pinned and parent-inherited models are
|
|
167
|
+
// resolved later, in agent-runner, and keep warn-and-proceed.
|
|
133
168
|
const verdict = checkModelScope({
|
|
134
169
|
model,
|
|
135
170
|
cwd: cwd ?? process.cwd(),
|
|
@@ -141,25 +176,49 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
141
176
|
if (verdict.kind === "error") throw new Error(verdict.message)
|
|
142
177
|
}
|
|
143
178
|
|
|
144
|
-
|
|
179
|
+
const id = manager.spawn(pi, ctx, type, prompt, normalizedOptions)
|
|
180
|
+
// With isolation: "worktree" the agent starts asynchronously — wait for
|
|
181
|
+
// it, so a strict-isolation failure is still an error envelope rather
|
|
182
|
+
// than an id for an agent that never ran.
|
|
183
|
+
await manager.awaitStartup(id)
|
|
184
|
+
return { id }
|
|
145
185
|
})
|
|
146
186
|
|
|
147
187
|
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
|
|
148
188
|
events,
|
|
149
189
|
"subagents:rpc:stop",
|
|
150
190
|
({ agentId }) => {
|
|
151
|
-
|
|
191
|
+
const record = manager.getRecord(agentId)
|
|
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")
|
|
202
|
+
// Not "not found" — the lookup above already proved it exists. `abort`
|
|
203
|
+
// returns false only for a record that is neither running nor queued,
|
|
204
|
+
// which is an agent that has already finished.
|
|
205
|
+
if (!manager.abort(agentId)) throw new Error("Agent is not running")
|
|
152
206
|
},
|
|
153
207
|
)
|
|
154
208
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
209
|
+
// A caller that has already shown the model an agent's result — pi-tasks'
|
|
210
|
+
// TaskOutput is the one in practice — says so here, so the completion
|
|
211
|
+
// notification for that same result is not delivered on top of it and does
|
|
212
|
+
// not cost the parent a turn. Deliberately outside the ping version
|
|
213
|
+
// handshake: an extension built against protocol v2 simply never calls it.
|
|
214
|
+
const unsubConsume = handleRpc<{ requestId: string; agentId: string }>(
|
|
215
|
+
events,
|
|
216
|
+
"subagents:rpc:consume",
|
|
217
|
+
({ agentId }) => {
|
|
218
|
+
if (!manager.consumeResult(agentId))
|
|
219
|
+
throw new Error("Agent not found or still running")
|
|
220
|
+
},
|
|
221
|
+
)
|
|
163
222
|
|
|
164
223
|
return { unsubPing, unsubSpawn, unsubStop, unsubConsume }
|
|
165
224
|
}
|