@herbertgao/pi-subagents 0.17.1 → 0.18.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 +6 -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 +11 -9
- 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/schedule.ts
CHANGED
|
@@ -281,8 +281,15 @@ export class SubagentScheduler {
|
|
|
281
281
|
isolated: job.isolated,
|
|
282
282
|
thinkingLevel: job.thinking,
|
|
283
283
|
isolation: job.isolation,
|
|
284
|
+
// A scheduled run has no tool call to build this, so without it the
|
|
285
|
+
// conversation viewer shows nothing about how the job was configured.
|
|
286
|
+
// The model is left out on purpose: agent-manager fills in the effective
|
|
287
|
+
// one when the session reports it, and naming the pre-session pick here
|
|
288
|
+
// would only be right until then.
|
|
284
289
|
invocation: {
|
|
285
290
|
thinking: job.thinking,
|
|
291
|
+
// Normalized like the Agent tool's own snapshot: `0` means unlimited,
|
|
292
|
+
// and rendering it as "max turns: 0" would read as a limit of none.
|
|
286
293
|
maxTurns: normalizeMaxTurns(job.max_turns),
|
|
287
294
|
isolated: job.isolated,
|
|
288
295
|
runInBackground: true,
|
|
@@ -301,7 +308,6 @@ export class SubagentScheduler {
|
|
|
301
308
|
|
|
302
309
|
this.emit({ type: "fired", jobId: id, agentId, name: job.name })
|
|
303
310
|
|
|
304
|
-
const record = manager.getRecord(agentId)
|
|
305
311
|
const finalize = (status: "success" | "error") => {
|
|
306
312
|
const next = this.getNextRun(id)
|
|
307
313
|
const current = store.get(id)
|
|
@@ -316,21 +322,20 @@ export class SubagentScheduler {
|
|
|
316
322
|
// AgentManager's promise resolves either way (its .catch returns ""), so we
|
|
317
323
|
// can't infer success/failure from the promise — read record.status instead.
|
|
318
324
|
// Terminal states: completed/steered = success; error/aborted/stopped = error.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
finalize("
|
|
333
|
-
}
|
|
325
|
+
// awaitStartup first: with isolation: "worktree" the run promise only exists
|
|
326
|
+
// once the repo copy is made, and a failed copy rejects here.
|
|
327
|
+
manager
|
|
328
|
+
.awaitStartup(agentId)
|
|
329
|
+
.then(() => manager.getRecord(agentId)?.promise)
|
|
330
|
+
.then(() => {
|
|
331
|
+
const r = manager.getRecord(agentId)
|
|
332
|
+
const failed =
|
|
333
|
+
r?.status === "error" ||
|
|
334
|
+
r?.status === "aborted" ||
|
|
335
|
+
r?.status === "stopped"
|
|
336
|
+
finalize(failed ? "error" : "success")
|
|
337
|
+
})
|
|
338
|
+
.catch(() => finalize("error"))
|
|
334
339
|
}
|
|
335
340
|
|
|
336
341
|
private emit(event: ScheduleChangeEvent): void {
|
package/src/settings.ts
CHANGED
|
@@ -6,11 +6,36 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
|
6
6
|
import { dirname, join } from "node:path"
|
|
7
7
|
import { getAgentDir } from "@earendil-works/pi-coding-agent"
|
|
8
8
|
import { NO_FALLBACK } from "./agent-types.js"
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
AgentMentionMode,
|
|
11
|
+
JoinMode,
|
|
12
|
+
ViewerMarkdownMode,
|
|
13
|
+
WidgetMode,
|
|
14
|
+
} from "./types.js"
|
|
10
15
|
|
|
11
16
|
export interface SubagentsSettings {
|
|
12
17
|
maxConcurrent?: number
|
|
13
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Max concurrent FOREGROUND (blocking) agents — `0` = unlimited, the default,
|
|
20
|
+
* which preserves the behaviour that has always applied: nothing bounded
|
|
21
|
+
* foreground work, and pi dispatches a message's tool calls through
|
|
22
|
+
* `Promise.all`, so an unqualified fan-out of blocking `Agent` calls runs all
|
|
23
|
+
* at once. Set it to bound that (#253 — on local models, parallel agents
|
|
24
|
+
* thrash the prompt cache).
|
|
25
|
+
*
|
|
26
|
+
* Deliberately independent of `maxConcurrent` rather than folded into it: a
|
|
27
|
+
* foreground agent blocks the parent anyway, so charging it to the background
|
|
28
|
+
* pool would let a saturated pool starve the main session of work it could
|
|
29
|
+
* have done itself.
|
|
30
|
+
*
|
|
31
|
+
* Bounds only spawns a caller is blocking on inline. Nested children are
|
|
32
|
+
* exempt — their parent is blocked awaiting them, so queueing a child behind
|
|
33
|
+
* its own parent would deadlock — and so are detached spawns from
|
|
34
|
+
* cross-extension RPC or `@handle` mentions, which block nobody and are
|
|
35
|
+
* documented to start immediately. Foreground `resume` is also outside the
|
|
36
|
+
* pool: it reuses an existing session and never reaches the spawn path, so
|
|
37
|
+
* several blocking resumes in one message can still exceed the limit.
|
|
38
|
+
*/
|
|
14
39
|
maxConcurrentForeground?: number
|
|
15
40
|
/**
|
|
16
41
|
* 0 = unlimited — the extension's single source of truth for that convention:
|
|
@@ -70,8 +95,14 @@ export interface SubagentsSettings {
|
|
|
70
95
|
scopeModels?: boolean
|
|
71
96
|
/**
|
|
72
97
|
* When true, an unreadable or unparseable agent `.md` aborts extension load
|
|
73
|
-
* instead of being skipped with a warning
|
|
74
|
-
*
|
|
98
|
+
* instead of being skipped with a warning — pi exits, naming the file.
|
|
99
|
+
*
|
|
100
|
+
* Startup only, by design. Mid-session reloads (one per `Agent` call) keep
|
|
101
|
+
* warning: a bad edit at 3pm should not kill the session on the next
|
|
102
|
+
* unrelated spawn, where the failure would look disconnected from its cause.
|
|
103
|
+
* For a checked-in `.pi/agents/`, failing at startup is the point — the
|
|
104
|
+
* alternative is running a *different* agent than the file names.
|
|
105
|
+
* Defaults to false.
|
|
75
106
|
*/
|
|
76
107
|
strictAgentFiles?: boolean
|
|
77
108
|
/**
|
|
@@ -98,6 +129,34 @@ export interface SubagentsSettings {
|
|
|
98
129
|
* the list never registers and the global key handler never captures input.
|
|
99
130
|
*/
|
|
100
131
|
fleetView?: boolean
|
|
132
|
+
/**
|
|
133
|
+
* Whether `@handle message` typed at the prompt is routed to that subagent
|
|
134
|
+
* instead of the main model, and whether `@` offers running agents alongside
|
|
135
|
+
* pi's file completion. Defaults to `model`. Applied live.
|
|
136
|
+
*
|
|
137
|
+
* - `model`: mentioning an agent that is not running asks the main model to
|
|
138
|
+
* spawn it with the `Agent` tool, Claude Code's behaviour. Costs a turn,
|
|
139
|
+
* and the model writes the agent's prompt rather than your text being it.
|
|
140
|
+
* - `direct`: that agent is started here instead, with the typed message as
|
|
141
|
+
* its prompt and no main-model turn spent.
|
|
142
|
+
* - `off`: the input hook falls straight through and the stacked
|
|
143
|
+
* autocomplete provider delegates everything back to pi's built-in one.
|
|
144
|
+
*
|
|
145
|
+
* Messaging a running agent and resuming a finished one are direct in both
|
|
146
|
+
* `model` and `direct`. The legacy booleans are still accepted: `true` reads
|
|
147
|
+
* as `model`, `false` as `off`.
|
|
148
|
+
*/
|
|
149
|
+
agentMentions?: AgentMentionMode
|
|
150
|
+
/**
|
|
151
|
+
* Whether subagents persist their pi session by default, so `@handle` can
|
|
152
|
+
* reopen an agent's conversation long after its in-memory record is gone.
|
|
153
|
+
* Defaults to `true`. Per-agent `persist_session:` frontmatter overrides it
|
|
154
|
+
* in both directions. Turning it off restores the previous behaviour, where
|
|
155
|
+
* a handle stops resolving roughly ten minutes after the agent finishes and
|
|
156
|
+
* mentioning it starts a fresh run instead. Persisted sessions also appear
|
|
157
|
+
* nested under the spawning session in pi's `/resume`.
|
|
158
|
+
*/
|
|
159
|
+
rememberAgents?: boolean
|
|
101
160
|
/**
|
|
102
161
|
* Display mode for the persistent above-editor agent widget:
|
|
103
162
|
* - `all`: show every agent (foreground + background).
|
|
@@ -143,6 +202,27 @@ export interface SubagentsSettings {
|
|
|
143
202
|
* scheduler and the unvalidated cross-extension RPC path.
|
|
144
203
|
*/
|
|
145
204
|
worktreeIsolation?: boolean
|
|
205
|
+
/**
|
|
206
|
+
* Master switch for scripted workflows. Defaults to `true`.
|
|
207
|
+
*
|
|
208
|
+
* Off is not a soft hide: the `SubagentWorkflow` tool is never registered, so
|
|
209
|
+
* the model is not told it exists and cannot call it, the `/agents`
|
|
210
|
+
* Workflows entry is hidden, and `--subagents-workflow-file` is refused.
|
|
211
|
+
*
|
|
212
|
+
* Absent is not the same as `true`. Unset means *auto*: on, but yielding to
|
|
213
|
+
* another extension that already offers a workflow tool, because two
|
|
214
|
+
* orchestrators in one tool spec is a worse default than none — the model
|
|
215
|
+
* has to guess which to call, and pays for both descriptions to find out.
|
|
216
|
+
* Setting it explicitly pins the answer in both directions: `true` keeps
|
|
217
|
+
* ours whatever else is loaded, `false` is off regardless. See
|
|
218
|
+
* `resolveWorkflowCollisions` in index.ts.
|
|
219
|
+
*
|
|
220
|
+
* Read once at extension init, before registration, so flipping it in
|
|
221
|
+
* `/agents → Settings` takes effect on the next pi session — the same
|
|
222
|
+
* contract `schedulingEnabled` has, and for the same reason: a tool spec is
|
|
223
|
+
* fixed once pi has it.
|
|
224
|
+
*/
|
|
225
|
+
workflowsEnabled?: boolean
|
|
146
226
|
/**
|
|
147
227
|
* Hard ceiling on nested subagent delegation, counted from the main session:
|
|
148
228
|
* main = 0, its subagents = 1, their children = 2. Defaults to `2`; `0` or `1`
|
|
@@ -202,9 +282,30 @@ export interface SubagentsSettings {
|
|
|
202
282
|
* what the parent session counts.
|
|
203
283
|
*/
|
|
204
284
|
showCost?: boolean
|
|
205
|
-
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether the widget's running rows name the model driving each agent and the
|
|
288
|
+
* thinking level it is running at.
|
|
289
|
+
*
|
|
290
|
+
* Off by default, unlike the tool result and the conversation viewer, which
|
|
291
|
+
* show the pair unconditionally: those have a line to themselves, while the
|
|
292
|
+
* widget row already carries the description, turns, tool uses, tokens and
|
|
293
|
+
* elapsed time, and every character it gains is one the description loses on a
|
|
294
|
+
* narrow terminal.
|
|
295
|
+
*/
|
|
206
296
|
showModel?: boolean
|
|
207
|
-
/**
|
|
297
|
+
/**
|
|
298
|
+
* How much of the conversation viewer's transcript renders as Markdown.
|
|
299
|
+
* Defaults to `assistant`. Applied live — the viewer's `m` key cycles this
|
|
300
|
+
* same setting, so a choice made in the overlay persists like one made in
|
|
301
|
+
* `/agents → Settings`.
|
|
302
|
+
*
|
|
303
|
+
* Scoped rather than all-or-nothing because the two kinds of content have
|
|
304
|
+
* different contracts: assistant text is authored as Markdown, while a tool
|
|
305
|
+
* result is whatever bytes the tool produced. Rendering the latter as
|
|
306
|
+
* Markdown is lossy in ways that look like the tool misbehaved — see
|
|
307
|
+
* `ViewerMarkdownMode` for the specific rewrites — so `all` is opt-in.
|
|
308
|
+
*/
|
|
208
309
|
viewerMarkdown?: ViewerMarkdownMode
|
|
209
310
|
}
|
|
210
311
|
|
|
@@ -224,9 +325,12 @@ export interface SettingsAppliers {
|
|
|
224
325
|
setDisableDefaultAgents: (b: boolean) => void
|
|
225
326
|
setToolDescriptionMode: (mode: ToolDescriptionMode) => void
|
|
226
327
|
setFleetView: (b: boolean) => void
|
|
328
|
+
setAgentMentions: (mode: AgentMentionMode) => void
|
|
329
|
+
setRememberAgents: (b: boolean) => void
|
|
227
330
|
setWidgetMode: (mode: WidgetMode) => void
|
|
228
331
|
setOutputTranscript: (b: boolean) => void
|
|
229
332
|
setWorktreeIsolation: (b: boolean) => void
|
|
333
|
+
setWorkflowsEnabled: (b: boolean) => void
|
|
230
334
|
setMaxSubagentDepth: (n: number) => void
|
|
231
335
|
setFallbackSubagent: (v: string | undefined) => void
|
|
232
336
|
setReportUsage: (b: boolean) => void
|
|
@@ -252,6 +356,8 @@ const VALID_WIDGET_MODES: ReadonlySet<string> = new Set<WidgetMode>([
|
|
|
252
356
|
])
|
|
253
357
|
const VALID_VIEWER_MARKDOWN_MODES: ReadonlySet<string> =
|
|
254
358
|
new Set<ViewerMarkdownMode>(["off", "assistant", "all"])
|
|
359
|
+
const VALID_AGENT_MENTION_MODES: ReadonlySet<string> =
|
|
360
|
+
new Set<AgentMentionMode>(["model", "direct", "off"])
|
|
255
361
|
|
|
256
362
|
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
257
363
|
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
|
|
@@ -273,6 +379,8 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
273
379
|
) {
|
|
274
380
|
out.maxConcurrent = r.maxConcurrent as number
|
|
275
381
|
}
|
|
382
|
+
// Floor 0, not 1 like maxConcurrent above: 0 is the documented "unlimited"
|
|
383
|
+
// value and the default, so dropping it would silently be unrepresentable.
|
|
276
384
|
if (
|
|
277
385
|
Number.isInteger(r.maxConcurrentForeground) &&
|
|
278
386
|
(r.maxConcurrentForeground as number) >= 0 &&
|
|
@@ -331,6 +439,19 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
331
439
|
if (typeof r.fleetView === "boolean") {
|
|
332
440
|
out.fleetView = r.fleetView
|
|
333
441
|
}
|
|
442
|
+
// Was a boolean before the `model` mode existed. A hand-written or
|
|
443
|
+
// previously-written `true` means "on", which is now the default `model`.
|
|
444
|
+
if (typeof r.agentMentions === "boolean") {
|
|
445
|
+
out.agentMentions = r.agentMentions ? "model" : "off"
|
|
446
|
+
} else if (
|
|
447
|
+
typeof r.agentMentions === "string" &&
|
|
448
|
+
VALID_AGENT_MENTION_MODES.has(r.agentMentions)
|
|
449
|
+
) {
|
|
450
|
+
out.agentMentions = r.agentMentions as AgentMentionMode
|
|
451
|
+
}
|
|
452
|
+
if (typeof r.rememberAgents === "boolean") {
|
|
453
|
+
out.rememberAgents = r.rememberAgents
|
|
454
|
+
}
|
|
334
455
|
if (
|
|
335
456
|
typeof r.widgetMode === "string" &&
|
|
336
457
|
VALID_WIDGET_MODES.has(r.widgetMode)
|
|
@@ -358,6 +479,9 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
358
479
|
) {
|
|
359
480
|
out.viewerMarkdown = r.viewerMarkdown as ViewerMarkdownMode
|
|
360
481
|
}
|
|
482
|
+
if (typeof r.workflowsEnabled === "boolean") {
|
|
483
|
+
out.workflowsEnabled = r.workflowsEnabled
|
|
484
|
+
}
|
|
361
485
|
if (r.fallbackSubagent === false) {
|
|
362
486
|
// The only non-string spelling worth accepting: a boolean would otherwise be
|
|
363
487
|
// dropped, silently leaving the PERMISSIVE default in place. Every string is
|
|
@@ -434,8 +558,9 @@ export function applySettings(
|
|
|
434
558
|
): void {
|
|
435
559
|
if (typeof s.maxConcurrent === "number")
|
|
436
560
|
appliers.setMaxConcurrent(s.maxConcurrent)
|
|
437
|
-
if (typeof s.maxConcurrentForeground === "number")
|
|
561
|
+
if (typeof s.maxConcurrentForeground === "number") {
|
|
438
562
|
appliers.setMaxConcurrentForeground(s.maxConcurrentForeground)
|
|
563
|
+
}
|
|
439
564
|
if (typeof s.defaultMaxTurns === "number")
|
|
440
565
|
appliers.setDefaultMaxTurns(s.defaultMaxTurns)
|
|
441
566
|
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns)
|
|
@@ -456,6 +581,9 @@ export function applySettings(
|
|
|
456
581
|
if (s.toolDescriptionMode)
|
|
457
582
|
appliers.setToolDescriptionMode(s.toolDescriptionMode)
|
|
458
583
|
if (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView)
|
|
584
|
+
if (s.agentMentions) appliers.setAgentMentions(s.agentMentions)
|
|
585
|
+
if (typeof s.rememberAgents === "boolean")
|
|
586
|
+
appliers.setRememberAgents(s.rememberAgents)
|
|
459
587
|
if (s.widgetMode) appliers.setWidgetMode(s.widgetMode)
|
|
460
588
|
if (typeof s.outputTranscript === "boolean")
|
|
461
589
|
appliers.setOutputTranscript(s.outputTranscript)
|
|
@@ -465,6 +593,8 @@ export function applySettings(
|
|
|
465
593
|
if (typeof s.showCost === "boolean") appliers.setShowCost(s.showCost)
|
|
466
594
|
if (typeof s.showModel === "boolean") appliers.setShowModel(s.showModel)
|
|
467
595
|
if (s.viewerMarkdown) appliers.setViewerMarkdown(s.viewerMarkdown)
|
|
596
|
+
if (typeof s.workflowsEnabled === "boolean")
|
|
597
|
+
appliers.setWorkflowsEnabled(s.workflowsEnabled)
|
|
468
598
|
}
|
|
469
599
|
|
|
470
600
|
/**
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* structured-output.ts — the synthetic tool behind `agent(prompt, { schema })`.
|
|
3
|
+
*
|
|
4
|
+
* A workflow script that passes a `schema` wants an *object* back, not prose it
|
|
5
|
+
* has to parse. Claude Code does this by giving the child a `StructuredOutput`
|
|
6
|
+
* tool whose input schema is the caller's schema, so the provider fills the
|
|
7
|
+
* fields, and returning the validated payload as the agent's result.
|
|
8
|
+
*
|
|
9
|
+
* We do the same, with one gap named up front: Claude Code *forces* the call,
|
|
10
|
+
* and we cannot. `toolChoice` exists in pi-ai's provider layer but is not
|
|
11
|
+
* plumbed through `AgentSession`, so an extension has no way to require a
|
|
12
|
+
* particular tool. What we have instead is three softer pressures —
|
|
13
|
+
*
|
|
14
|
+
* 1. `constrainedSampling`, so providers that support it hold the payload to
|
|
15
|
+
* the schema at sampling time;
|
|
16
|
+
* 2. the tool's description, snippet and guideline, which say the answer must
|
|
17
|
+
* come through this call;
|
|
18
|
+
* 3. validation here, answering a bad payload with `isError` so the model
|
|
19
|
+
* sees what was wrong and calls again inside the same run.
|
|
20
|
+
*
|
|
21
|
+
* — and, when all three fail, one more prompt from `runAgent`. See
|
|
22
|
+
* {@link structuredRetryPrompt}.
|
|
23
|
+
*
|
|
24
|
+
* The name matches Claude Code's exactly, so a ported prompt that mentions
|
|
25
|
+
* `StructuredOutput` is still telling the truth.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
defineTool,
|
|
30
|
+
type ToolDefinition,
|
|
31
|
+
} from "@earendil-works/pi-coding-agent"
|
|
32
|
+
import type { CompiledSchema } from "./workflow/json-schema.js"
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deliberately NOT added to `SUBAGENT_TOOL_NAMES`: that list becomes
|
|
36
|
+
* `EXCLUDED_TOOL_NAMES`, which is exactly the denial this tool has to avoid.
|
|
37
|
+
* Nor to `BUILTIN_TOOL_NAMES` — it is ours to inject, never a name a user may
|
|
38
|
+
* ask for in an agent's `tools:` frontmatter.
|
|
39
|
+
*/
|
|
40
|
+
export const STRUCTURED_OUTPUT_TOOL_NAME = "StructuredOutput"
|
|
41
|
+
|
|
42
|
+
/** What the child produced, filled in as the tool is called. */
|
|
43
|
+
export interface StructuredCapture {
|
|
44
|
+
/** The last payload that validated, canonicalised. Absent until one does. */
|
|
45
|
+
json?: string
|
|
46
|
+
/** Why the most recent attempt was rejected, for the retry prompt. */
|
|
47
|
+
lastError?: string
|
|
48
|
+
/** Whether the tool was called at all — "never tried" reads differently. */
|
|
49
|
+
called: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createStructuredCapture(): StructuredCapture {
|
|
53
|
+
return { called: false }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the tool for one child.
|
|
58
|
+
*
|
|
59
|
+
* `capture` is the box the caller reads afterwards. It is passed in rather than
|
|
60
|
+
* returned so `runAgent` owns its lifetime and can consult it on every exit
|
|
61
|
+
* path, including the ones where the tool was never reached.
|
|
62
|
+
*/
|
|
63
|
+
export function createStructuredOutputTool(
|
|
64
|
+
compiled: CompiledSchema,
|
|
65
|
+
capture: StructuredCapture,
|
|
66
|
+
): ToolDefinition {
|
|
67
|
+
return defineTool({
|
|
68
|
+
name: STRUCTURED_OUTPUT_TOOL_NAME,
|
|
69
|
+
label: "Structured Output",
|
|
70
|
+
description:
|
|
71
|
+
"Report your final answer. Call this exactly once, with the complete result, and put everything the " +
|
|
72
|
+
"caller needs inside the arguments — text written outside this call is discarded. If a call is " +
|
|
73
|
+
"rejected for not matching the schema, fix the reported fields and call it again.",
|
|
74
|
+
promptSnippet: "Report your final answer as structured data",
|
|
75
|
+
promptGuidelines: [
|
|
76
|
+
"Your final answer MUST be reported by calling StructuredOutput. Prose outside that call is discarded.",
|
|
77
|
+
],
|
|
78
|
+
// The caller's schema *is* the tool's input schema, verbatim — that is what
|
|
79
|
+
// makes the provider fill the fields. pi types this as TypeBox's `TSchema`,
|
|
80
|
+
// which v1 defines as an open interface, so a plain JSON Schema satisfies
|
|
81
|
+
// it without a cast at runtime or a conversion at author time.
|
|
82
|
+
parameters: compiled.schema as never,
|
|
83
|
+
// "prefer", not "require": a provider that cannot constrain sampling should
|
|
84
|
+
// fall through to validation-and-retry rather than fail the call outright.
|
|
85
|
+
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
|
86
|
+
// Models occasionally send the whole payload as one JSON string instead of
|
|
87
|
+
// an object. Recovering that costs nothing and saves a whole retry.
|
|
88
|
+
prepareArguments: (args: unknown) => {
|
|
89
|
+
if (typeof args !== "string") return args as never
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(args) as never
|
|
92
|
+
} catch {
|
|
93
|
+
return args as never
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
execute: async (_toolCallId, params) => {
|
|
97
|
+
capture.called = true
|
|
98
|
+
const verdict = compiled.check(params)
|
|
99
|
+
if (verdict !== true) {
|
|
100
|
+
capture.lastError = verdict
|
|
101
|
+
// `isError` puts the reason in front of the model as a tool result, so
|
|
102
|
+
// it can correct itself inside this same run. This is where most
|
|
103
|
+
// mismatches are resolved; the prompt-level retry is the backstop.
|
|
104
|
+
return {
|
|
105
|
+
content: [
|
|
106
|
+
{
|
|
107
|
+
type: "text",
|
|
108
|
+
text: `StructuredOutput did not match the required schema:\n${verdict}\nCall it again with a corrected value.`,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
isError: true,
|
|
112
|
+
details: {},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Last valid call wins: a model that calls twice meant the second one.
|
|
116
|
+
capture.json = JSON.stringify(params)
|
|
117
|
+
capture.lastError = undefined
|
|
118
|
+
return { content: [{ type: "text", text: "Recorded." }], details: {} }
|
|
119
|
+
},
|
|
120
|
+
}) as ToolDefinition
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The one extra prompt sent when a run ended with nothing captured.
|
|
125
|
+
*
|
|
126
|
+
* Distinguishes "never called it" from "called it wrongly" — the two need
|
|
127
|
+
* different corrections, and telling a model it got the shape wrong when it
|
|
128
|
+
* never answered at all sends it looking for a mistake it did not make.
|
|
129
|
+
*/
|
|
130
|
+
export function structuredRetryPrompt(capture: StructuredCapture): string {
|
|
131
|
+
const reason =
|
|
132
|
+
capture.called && capture.lastError !== undefined
|
|
133
|
+
? `Your last ${STRUCTURED_OUTPUT_TOOL_NAME} call did not match the required schema: ${capture.lastError}`
|
|
134
|
+
: `You did not call ${STRUCTURED_OUTPUT_TOOL_NAME}, so your answer was not recorded.`
|
|
135
|
+
return `${reason}\n\nCall ${STRUCTURED_OUTPUT_TOOL_NAME} now with your complete final answer. Do not reply with prose.`
|
|
136
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -105,12 +105,75 @@ export type JoinMode = "async" | "group" | "smart"
|
|
|
105
105
|
*/
|
|
106
106
|
export type WidgetMode = "all" | "background" | "off"
|
|
107
107
|
|
|
108
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* How much of the conversation viewer's transcript is rendered as Markdown.
|
|
110
|
+
* - `off`: every line wraps as literal text, as it did before the mode existed.
|
|
111
|
+
* - `assistant`: assistant text renders as Markdown; tool results stay verbatim
|
|
112
|
+
* and dim. The default, because assistant text *is* Markdown by contract
|
|
113
|
+
* while a tool result is arbitrary bytes — a Markdown pass over a log or a
|
|
114
|
+
* diff eats `#` from shell comments, swallows a `---` line into a setext
|
|
115
|
+
* heading, re-fences indented output and redraws `| a | b |` as a table.
|
|
116
|
+
* (Ordered-list renumbering is the one such rewrite actively suppressed —
|
|
117
|
+
* see `MARKDOWN_OPTIONS` — because it silently changes data, not layout.)
|
|
118
|
+
* - `all`: tool results render as Markdown too, for tools that genuinely emit
|
|
119
|
+
* it (#210's `ctx_execute`), accepting the rewrites above on ones that don't.
|
|
120
|
+
*/
|
|
109
121
|
export type ViewerMarkdownMode = "off" | "assistant" | "all"
|
|
110
122
|
|
|
123
|
+
/**
|
|
124
|
+
* How `@handle message` starts an agent that is not already running.
|
|
125
|
+
* - `model`: inject Claude Code's `agent_mention` reminder and let the main
|
|
126
|
+
* model spawn it with the `Agent` tool, which is what Claude Code does.
|
|
127
|
+
* - `direct`: spawn it here, immediately, with the typed message as its prompt
|
|
128
|
+
* and no main-model turn spent.
|
|
129
|
+
* - `off`: `@` means only "attach a file" again.
|
|
130
|
+
*
|
|
131
|
+
* Messaging a running agent and resuming a finished one are direct in every
|
|
132
|
+
* mode — Claude Code only differs from us on the *new* invocation.
|
|
133
|
+
*/
|
|
134
|
+
export type AgentMentionMode = "model" | "direct" | "off"
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What survives a record's eviction so `@handle` keeps working. The live record
|
|
138
|
+
* is discarded after ~10 minutes, but the pi session it wrote is still on disk,
|
|
139
|
+
* and this is the little that is needed to find and describe it again.
|
|
140
|
+
*/
|
|
141
|
+
export interface AgentTombstone {
|
|
142
|
+
handle: string
|
|
143
|
+
alias?: string
|
|
144
|
+
id: string
|
|
145
|
+
type: SubagentType
|
|
146
|
+
description: string
|
|
147
|
+
/** Always set — a record with no session file is never tombstoned. */
|
|
148
|
+
sessionFile: string
|
|
149
|
+
completedAt: number
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* What `@handle` resolved to: an agent still in memory, or the remains of one
|
|
154
|
+
* whose conversation can be reopened from disk.
|
|
155
|
+
*/
|
|
156
|
+
export type MentionResolution =
|
|
157
|
+
| { kind: "live"; record: AgentRecord }
|
|
158
|
+
| { kind: "tombstone"; entry: AgentTombstone }
|
|
159
|
+
|
|
111
160
|
export interface AgentRecord {
|
|
112
161
|
id: string
|
|
113
162
|
type: SubagentType
|
|
163
|
+
/**
|
|
164
|
+
* Typeable name for the `@handle message` prompt mention, derived from the
|
|
165
|
+
* agent type and numbered when siblings collide (`explore`, `explore-2`).
|
|
166
|
+
* Top-level agents only — nested children are hidden from every top-level
|
|
167
|
+
* surface, so nothing can address them.
|
|
168
|
+
*/
|
|
169
|
+
handle?: string
|
|
170
|
+
/**
|
|
171
|
+
* A second, memorable handle from the spawner's `name` (`@auth-audit`), drawn
|
|
172
|
+
* from the same namespace as `handle` so the two can never collide. Purely
|
|
173
|
+
* additive: `handle` is assigned regardless, so a named agent stays reachable
|
|
174
|
+
* by its type and `@explore` never comes to mean "start another one".
|
|
175
|
+
*/
|
|
176
|
+
alias?: string
|
|
114
177
|
description: string
|
|
115
178
|
status:
|
|
116
179
|
| "queued"
|
|
@@ -128,9 +191,21 @@ export interface AgentRecord {
|
|
|
128
191
|
session?: AgentSession
|
|
129
192
|
abortController?: AbortController
|
|
130
193
|
promise?: Promise<string>
|
|
131
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* A caller is awaiting this agent inline (`spawnAndWait`) — what
|
|
196
|
+
* `maxConcurrentForeground` bounds. Distinct from `isBackground === false`,
|
|
197
|
+
* which says only that the agent has an inline result surface: a detached
|
|
198
|
+
* cross-extension RPC spawn is foreground by that measure and yet blocks
|
|
199
|
+
* nobody, so it takes no slot.
|
|
200
|
+
*/
|
|
132
201
|
blocking?: boolean
|
|
133
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* Present only while the record is "queued": resolves when it leaves the
|
|
204
|
+
* queue, started or aborted. `spawnAndWait` waits on this because a queued
|
|
205
|
+
* record has no `promise` yet. Always resolves, never rejects — a rejection
|
|
206
|
+
* would escape into the caller's tool `execute` and take down pi's whole
|
|
207
|
+
* Promise.all tool batch.
|
|
208
|
+
*/
|
|
134
209
|
startGate?: Promise<void>
|
|
135
210
|
groupId?: string
|
|
136
211
|
joinMode?: JoinMode
|
|
@@ -141,11 +216,23 @@ export interface AgentRecord {
|
|
|
141
216
|
/** Worktree info if the agent is running in an isolated worktree. */
|
|
142
217
|
worktree?: { path: string; branch: string; baseSha: string; workPath: string }
|
|
143
218
|
/** Worktree cleanup result after agent completion. */
|
|
144
|
-
worktreeResult?: {
|
|
219
|
+
worktreeResult?: {
|
|
220
|
+
hasChanges: boolean
|
|
221
|
+
branch?: string
|
|
222
|
+
path?: string
|
|
223
|
+
error?: string
|
|
224
|
+
}
|
|
145
225
|
/** The tool_use_id from the original Agent tool call. */
|
|
146
226
|
toolCallId?: string
|
|
147
227
|
/** Path to the streaming output transcript file. */
|
|
148
228
|
outputFile?: string
|
|
229
|
+
/**
|
|
230
|
+
* The agent's pi session file, when it was persisted (`persist_session`, or
|
|
231
|
+
* the `rememberAgents` default). Captured so a mention can reopen the
|
|
232
|
+
* conversation after the record itself has been evicted; undefined for an
|
|
233
|
+
* in-memory session, which leaves nothing to reopen.
|
|
234
|
+
*/
|
|
235
|
+
sessionFile?: string
|
|
149
236
|
/** Cleanup function for the output file stream subscription. */
|
|
150
237
|
outputCleanup?: () => void
|
|
151
238
|
/**
|
|
@@ -171,8 +258,27 @@ export interface AgentRecord {
|
|
|
171
258
|
invocation?: AgentInvocation
|
|
172
259
|
/** Nesting depth: top-level subagent = 1. */
|
|
173
260
|
depth?: number
|
|
261
|
+
/**
|
|
262
|
+
* The validated `StructuredOutput` payload, as canonical JSON.
|
|
263
|
+
*
|
|
264
|
+
* Set only when the spawn asked for a schema. Separate from `result` because
|
|
265
|
+
* `result` is prose for a reader — previewed in the widget, written to the
|
|
266
|
+
* transcript, and appended to with the worktree branch note — and JSON that
|
|
267
|
+
* has been appended to no longer parses.
|
|
268
|
+
*/
|
|
269
|
+
structuredJson?: string
|
|
270
|
+
/** Whether the child needed the extra structured-output prompt. */
|
|
271
|
+
structuredRetried?: boolean
|
|
174
272
|
/** Parent agent ID for ownership-scoped nested controls. */
|
|
175
273
|
parentAgentId?: string
|
|
274
|
+
/**
|
|
275
|
+
* The workflow run that owns this child, when a workflow spawned it.
|
|
276
|
+
*
|
|
277
|
+
* Owned the same way a nested child is owned by its parent: filtered out of
|
|
278
|
+
* every top-level surface, and outside the `maxConcurrent` pool. See
|
|
279
|
+
* `isTopLevelAgent`.
|
|
280
|
+
*/
|
|
281
|
+
workflowId?: string
|
|
176
282
|
/** Effective inherited nesting cap for this branch. */
|
|
177
283
|
maxSubagentDepth?: number
|
|
178
284
|
/**
|
|
@@ -183,17 +289,29 @@ export interface AgentRecord {
|
|
|
183
289
|
rootSessionId?: string
|
|
184
290
|
}
|
|
185
291
|
|
|
186
|
-
/**
|
|
292
|
+
/**
|
|
293
|
+
* What a session reports as its level: pi's `ThinkingLevel` plus the `"off"` a
|
|
294
|
+
* model with thinking disabled reports. Display-only — spawning still takes a
|
|
295
|
+
* `ThinkingLevel`, so this widening cannot leak into an invocation.
|
|
296
|
+
*/
|
|
187
297
|
export type EffectiveThinkingLevel = ThinkingLevel | "off"
|
|
188
298
|
|
|
189
299
|
export interface AgentInvocation {
|
|
190
|
-
/** Short
|
|
300
|
+
/** Short display name for tight rows, e.g. "haiku 4.5". Always set once known. */
|
|
191
301
|
modelName?: string
|
|
192
|
-
/** Canonical provider/
|
|
302
|
+
/** Canonical `provider/id`, for surfaces with room to disambiguate providers. */
|
|
193
303
|
modelId?: string
|
|
304
|
+
/** The level actually in effect, once a session exists to report one. */
|
|
194
305
|
thinking?: EffectiveThinkingLevel
|
|
195
|
-
/**
|
|
306
|
+
/**
|
|
307
|
+
* What the caller asked for, kept only when they did not get it — pi clamped
|
|
308
|
+
* the level to the model's capabilities, or an agent file's frontmatter
|
|
309
|
+
* outranked the parameter (#182). The snapshot exists to answer "did the spawn
|
|
310
|
+
* honor my instructions?" (#62), which it cannot do if the request is lost, so
|
|
311
|
+
* neither `requested*` field is overwritten once set.
|
|
312
|
+
*/
|
|
196
313
|
requestedThinking?: EffectiveThinkingLevel
|
|
314
|
+
/** The caller's `model` parameter, as written, when an agent file's pin won. */
|
|
197
315
|
requestedModel?: string
|
|
198
316
|
maxTurns?: number
|
|
199
317
|
isolated?: boolean
|