pi-daddy 0.15.0 → 0.17.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 +119 -0
- package/README.md +46 -12
- package/dist/chain.d.ts +94 -0
- package/dist/chain.d.ts.map +1 -0
- package/dist/chain.js +161 -0
- package/dist/chain.js.map +1 -0
- package/dist/cli.js +0 -0
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +6 -2
- package/dist/delegate.js.map +1 -1
- package/dist/executor.d.ts +38 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +93 -0
- package/dist/executor.js.map +1 -0
- package/dist/fanout.d.ts +9 -0
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +9 -0
- package/dist/fanout.js.map +1 -1
- package/dist/herdr-cli.d.ts +78 -0
- package/dist/herdr-cli.d.ts.map +1 -0
- package/dist/herdr-cli.js +113 -0
- package/dist/herdr-cli.js.map +1 -0
- package/dist/herdr-name.d.ts +37 -0
- package/dist/herdr-name.d.ts.map +1 -0
- package/dist/herdr-name.js +59 -0
- package/dist/herdr-name.js.map +1 -0
- package/dist/herdr-poll.d.ts +104 -0
- package/dist/herdr-poll.d.ts.map +1 -0
- package/dist/herdr-poll.js +150 -0
- package/dist/herdr-poll.js.map +1 -0
- package/dist/herdr-stage.d.ts +40 -0
- package/dist/herdr-stage.d.ts.map +1 -0
- package/dist/herdr-stage.js +54 -0
- package/dist/herdr-stage.js.map +1 -0
- package/dist/ledger-report.d.ts +18 -0
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +10 -0
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +31 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +2 -0
- package/dist/ledger.js.map +1 -1
- package/dist/pane-reaper.d.ts +66 -4
- package/dist/pane-reaper.d.ts.map +1 -1
- package/dist/pane-reaper.js +131 -9
- package/dist/pane-reaper.js.map +1 -1
- package/dist/progress.d.ts +96 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +167 -0
- package/dist/progress.js.map +1 -0
- package/dist/run-child.d.ts +27 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +84 -7
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +41 -28
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +150 -167
- package/dist/run-herdr.js.map +1 -1
- package/extensions/delegate-chain.ts +357 -0
- package/extensions/delegation.ts +100 -2
- package/extensions/grants-command.ts +26 -1
- package/extensions/grants.ts +85 -163
- package/extensions/run-delegation.ts +130 -13
- package/extensions/session-report.ts +231 -0
- package/extensions/session.ts +63 -11
- package/extensions/tripwire.ts +44 -0
- package/package.json +21 -1
- package/src/chain.ts +174 -0
- package/src/delegate.ts +6 -2
- package/src/executor.ts +122 -0
- package/src/fanout.ts +10 -0
- package/src/herdr-cli.ts +125 -0
- package/src/herdr-name.ts +61 -0
- package/src/herdr-poll.ts +185 -0
- package/src/herdr-stage.ts +55 -0
- package/src/ledger-report.ts +21 -0
- package/src/ledger.ts +33 -0
- package/src/pane-reaper.ts +147 -9
- package/src/progress.ts +206 -0
- package/src/run-child.ts +96 -7
- package/src/run-herdr.ts +170 -174
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `delegate_chain` — a governed sequential pipeline, planned and gated as one unit (ADR-0033).
|
|
3
|
+
*
|
|
4
|
+
* Its own file rather than a third tool in `delegation.ts`, which is near the 400-line ceiling. The seam is real
|
|
5
|
+
* anyway: `delegate` and `delegate_all` differ only in cardinality, while a chain differs in **composition** — each
|
|
6
|
+
* step's task is built from the previous step's output, and the whole thing is planned before any of it runs.
|
|
7
|
+
*
|
|
8
|
+
* **Nothing here re-implements a governance rule.** Every step goes through `runOneDelegation`, so the grant, the
|
|
9
|
+
* ceiling, `agent:<name>` authorisation, the depth bound, the ledger and `--tools` enforcement are exactly what a
|
|
10
|
+
* single `delegate` gets. What a chain adds is the handoff (`src/chain.ts`), one gate instead of N, a budget unit
|
|
11
|
+
* per step, and abort-on-failure.
|
|
12
|
+
*
|
|
13
|
+
* **Why the upfront gate is exact rather than an approximation**, which is the least obvious thing here: an approval
|
|
14
|
+
* is keyed `capability@subject` and **the task is never part of it** (ADR-0021 — the task is not stored anywhere). So
|
|
15
|
+
* the union of a chain's gated capabilities is fully determined before any step's task exists, even though steps
|
|
16
|
+
* 2..N have no task until their predecessor runs. That is what makes asking once honest instead of optimistic.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
import type { InheritableApproval } from "../src/approval.ts";
|
|
22
|
+
import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
|
|
23
|
+
import { chainStepSpec, PLACEHOLDER } from "../src/chain.ts";
|
|
24
|
+
import { planDelegation } from "../src/delegate.ts";
|
|
25
|
+
import { MAX_CHAIN_STEPS, childSpawnId, splitBudget } from "../src/fanout.ts";
|
|
26
|
+
import { PAINT_INTERVAL_MS, appendTail, emptyTail, renderProgress, replaceTail, throttle, type ChildProgress } from "../src/progress.ts";
|
|
27
|
+
import type { Capability } from "../src/resolve.ts";
|
|
28
|
+
import { appendRecord, buildRecord } from "../src/ledger.ts";
|
|
29
|
+
import { obtainApprovals, snapshotOf } from "./approvals.ts";
|
|
30
|
+
import { runOneDelegation } from "./run-delegation.ts";
|
|
31
|
+
import type { GrantsSession } from "./session.ts";
|
|
32
|
+
|
|
33
|
+
/** One step of a chain, as the model describes it. */
|
|
34
|
+
interface StepSpec {
|
|
35
|
+
task: string;
|
|
36
|
+
agent?: string;
|
|
37
|
+
tools?: string[];
|
|
38
|
+
model?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One dialog's worth of gate: a single capability, for a single subject, described by the step that needs it.
|
|
43
|
+
*
|
|
44
|
+
* **One request per `capability@subject`, not per subject.** Grouping by subject alone merged every `tools:`-only
|
|
45
|
+
* step under the constant `<delegate>` and froze the *first* step's task into the dialog — so an operator approved
|
|
46
|
+
* `tool:write` while reading a task that only listed files. The dialog is the one place a human learns what they are
|
|
47
|
+
* authorising, so it names the step that actually needs the capability.
|
|
48
|
+
*/
|
|
49
|
+
interface GateRequest {
|
|
50
|
+
subject: string;
|
|
51
|
+
/** `"definition"` offers `always`; `"delegate"` must not (ADR-0019). Derived from the subject, never assumed. */
|
|
52
|
+
path: "definition" | "delegate";
|
|
53
|
+
capability: Capability;
|
|
54
|
+
/** The task of the step that needs this capability. */
|
|
55
|
+
task: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Either every gate the chain will hit, or the first step that can never run and why. */
|
|
59
|
+
interface ChainPlan {
|
|
60
|
+
requests: GateRequest[];
|
|
61
|
+
/** Set when a step is refused for a reason no approval can fix. The chain must refuse before asking anyone. */
|
|
62
|
+
doomed?: { step: number; reason: string };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Plan every step and collect the gates — or find a step that can never run.
|
|
67
|
+
*
|
|
68
|
+
* **`plan.ok` is honoured here, and ignoring it was a privilege path.** The first version read only
|
|
69
|
+
* `gatedBlocked`, so a step refused for an unheld `agent:` id, an unknown definition, an empty task or a universal
|
|
70
|
+
* capability still raised its gate and the answer was still banked. Measured: `delegate({tools:["bash","agent:x"]})`
|
|
71
|
+
* asks nobody and refuses, while the same step inside a chain raised a dialog, took *Allow for this session*, refused
|
|
72
|
+
* anyway — and left `tool:bash` pre-approved for every later delegation in the session. On the `agent:` path it
|
|
73
|
+
* banked a **30-day** entry, including for a step whose task was whitespace, whose dialog therefore read `task:`
|
|
74
|
+
* followed by nothing.
|
|
75
|
+
*
|
|
76
|
+
* `shouldSeekApproval` is the rule every other gate in this package already applies, and its own docstring names
|
|
77
|
+
* this hazard: *"both banked against a spawn that never happened, and both reachable by a model that appends one
|
|
78
|
+
* unheld capability to an otherwise ordinary request."* The chain reimplemented the decision beside it instead of
|
|
79
|
+
* routing through it. So a doomed step now refuses the whole chain **before any dialog**, which is the same
|
|
80
|
+
* principle the executor check follows.
|
|
81
|
+
*
|
|
82
|
+
* Planned with no UI: `planWithApprovals` would open a dialog per step during the very phase whose purpose is to ask
|
|
83
|
+
* upfront. A step's task is unknown for everything after the first, which is fine — an approval key never contains
|
|
84
|
+
* the task (ADR-0021), so the set of gates is fully determined without it.
|
|
85
|
+
*/
|
|
86
|
+
async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudget: number): Promise<ChainPlan> {
|
|
87
|
+
const requests: GateRequest[] = [];
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
const context = await session.delegationContext();
|
|
90
|
+
|
|
91
|
+
for (const [index, step] of steps.entries()) {
|
|
92
|
+
// The real task, not a placeholder: the planner's own empty-task guard must run here rather than at spawn time,
|
|
93
|
+
// or a step with a whitespace task raises a dialog and is refused afterwards. The handoff is absent at planning
|
|
94
|
+
// time and cannot change which capabilities are gated.
|
|
95
|
+
const plan = planDelegation(
|
|
96
|
+
{ task: step.task, agent: step.agent, tools: step.tools, model: step.model },
|
|
97
|
+
{ ...context, spawnId: session.ownSpawnId, childSpawnId: childSpawnId(session.ownSpawnId, index) },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// A gate is the ONLY refusal an approval can lift. Anything else is doomed, and asking about it banks authority
|
|
101
|
+
// for a spawn that will never happen.
|
|
102
|
+
if (!plan.ok && !shouldSeekApproval(plan.result)) {
|
|
103
|
+
return { requests: [], doomed: { step: index + 1, reason: plan.reason ?? "this step cannot run" } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const subject = step.agent ?? DELEGATE_SUBJECT;
|
|
107
|
+
for (const capability of plan.result.gatedBlocked) {
|
|
108
|
+
const key = `${capability}@${subject}`;
|
|
109
|
+
if (seen.has(key)) continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
requests.push({ subject, path: step.agent ? "definition" : "delegate", capability, task: step.task });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { requests };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): void {
|
|
118
|
+
// No `mayDelegate` guard here: `registerDelegationTools` already returns before calling this, so a second check
|
|
119
|
+
// was dead code that made the leaf half of a wiring test pass for the wrong reason. The guard lives in one place.
|
|
120
|
+
|
|
121
|
+
const stepShape = Type.Object({
|
|
122
|
+
task: Type.String({
|
|
123
|
+
description:
|
|
124
|
+
`What this step should do. Write ${PLACEHOLDER} where the previous step's output belongs; if you omit it, ` +
|
|
125
|
+
`that output is appended instead. The first step never receives one.`,
|
|
126
|
+
}),
|
|
127
|
+
agent: Type.Optional(Type.String({ description: "Definition to spawn for this step." })),
|
|
128
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
|
|
129
|
+
model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const params = Type.Object({
|
|
133
|
+
steps: Type.Array(stepShape, {
|
|
134
|
+
minItems: 1,
|
|
135
|
+
maxItems: MAX_CHAIN_STEPS,
|
|
136
|
+
description: "The steps to run IN ORDER. Each one sees the previous one's output.",
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
pi.registerTool({
|
|
141
|
+
name: "delegate_chain",
|
|
142
|
+
label: "Delegate a chain of sub-agents (governed, sequential)",
|
|
143
|
+
description:
|
|
144
|
+
"Run sub-agents ONE AFTER ANOTHER, each receiving the previous one's output. Use this when a step needs " +
|
|
145
|
+
"what an earlier step produced — a decision feeding a design feeding an implementation. Use `delegate_all` " +
|
|
146
|
+
"instead when the tasks are independent and can run at the same time, and `delegate` for a single child. " +
|
|
147
|
+
`At most ${MAX_CHAIN_STEPS} steps, each spending one unit of the session's fan-out budget. Every step is ` +
|
|
148
|
+
"governed exactly as a single `delegate` is: it holds only what you grant it, and you cannot grant what you " +
|
|
149
|
+
"do not hold. A failed step ABORTS the rest, and you still receive everything that completed.",
|
|
150
|
+
parameters: params,
|
|
151
|
+
async execute(_toolCallId, args, signal, onUpdate, ctx) {
|
|
152
|
+
const steps = args.steps ?? [];
|
|
153
|
+
|
|
154
|
+
// **Every cheap refusal happens before any human is asked.** Yesterday's lesson on the `delegate` path: with
|
|
155
|
+
// `PI_GRANTS_HERDR=1` and herdr down, the gate ran first, an operator approved `bash`, a 30-day entry was
|
|
156
|
+
// written, and the delegation was then refused anyway. `runOneDelegation` checks the executor before its own
|
|
157
|
+
// gate; a chain hoists its gate above `runOneDelegation`, so the check has to be repeated here or that
|
|
158
|
+
// ordering is simply bypassed.
|
|
159
|
+
if (session.executor.refusal) throw new Error(`chain refused: ${session.executor.refusal}`);
|
|
160
|
+
|
|
161
|
+
// Cardinality next, still before the gate. `splitBudget` is reused rather than re-derived, so a chain and a
|
|
162
|
+
// fan-out cannot disagree about what the budget means.
|
|
163
|
+
const split = splitBudget(session.fanoutBudget, steps.length);
|
|
164
|
+
if (!split.ok) throw new Error(`chain refused: ${split.reason}`);
|
|
165
|
+
|
|
166
|
+
// Plan every step first. A step that can never run refuses the chain HERE, before anyone is asked — see
|
|
167
|
+
// `planChain`.
|
|
168
|
+
const chainPlan = await planChain(session, steps, split.perChild);
|
|
169
|
+
if (chainPlan.doomed) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
|
|
172
|
+
`asked to approve anything — a step that cannot run must not bank authority for a spawn that will ` +
|
|
173
|
+
`never happen.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// One dialog per `capability@subject`, each naming the step that needs it, all before the first step runs.
|
|
178
|
+
const preApproved: InheritableApproval[] = [];
|
|
179
|
+
let declined: { capability: Capability; subject: string } | undefined;
|
|
180
|
+
let humanDenied = false;
|
|
181
|
+
|
|
182
|
+
for (const request of chainPlan.requests) {
|
|
183
|
+
const outcome = await obtainApprovals(session, [request.capability], request.subject, request.path, ctx, request.task, signal);
|
|
184
|
+
humanDenied = humanDenied || outcome.humanDenied;
|
|
185
|
+
if (!outcome.approved.includes(request.capability)) {
|
|
186
|
+
// **Stop asking.** The chain's outcome is already fixed, and every further dialog banks authority — a
|
|
187
|
+
// `session` yes into `sessionApprovals` and an `always` yes onto disk for 30 days — for a chain that will
|
|
188
|
+
// not run. The single-delegate path breaks on the first no for the same reason.
|
|
189
|
+
declined = { capability: request.capability, subject: request.subject };
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
preApproved.push({
|
|
193
|
+
capability: request.capability,
|
|
194
|
+
subject: request.subject,
|
|
195
|
+
scope: outcome.scopes[request.capability] ?? ("once" as const),
|
|
196
|
+
// Pinned to THIS subject's body (ADR-0022). Stamped from a single shared subject, the pin was verified
|
|
197
|
+
// against one definition's instructions while the capability was spent on another's.
|
|
198
|
+
bodySha256: snapshotOf(session, request.subject)?.bodySha256,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (declined) {
|
|
203
|
+
// Recorded before it is thrown, and recorded against the subject that was actually refused. The first version
|
|
204
|
+
// hardcoded step 1's identity, so the trail asserted a human had denied a capability for `digger` when they
|
|
205
|
+
// had *approved* it for `digger` and denied it for `shaper` — the ledger and the approval store asserting
|
|
206
|
+
// opposite facts about the same key, which is R-28's shape.
|
|
207
|
+
if (session.ledgerPath) {
|
|
208
|
+
const at = steps.findIndex((step) => (step.agent ?? DELEGATE_SUBJECT) === declined.subject);
|
|
209
|
+
await appendRecord(
|
|
210
|
+
{ path: session.ledgerPath, strict: true },
|
|
211
|
+
buildRecord({
|
|
212
|
+
parentId: session.ownSpawnId,
|
|
213
|
+
childId: childSpawnId(session.ownSpawnId, Math.max(0, at)),
|
|
214
|
+
depth: session.depth + 1,
|
|
215
|
+
agentType: declined.subject === DELEGATE_SUBJECT ? "delegate" : declined.subject,
|
|
216
|
+
requested: [declined.capability],
|
|
217
|
+
parentGrant: session.ownGrant,
|
|
218
|
+
result: { effective: [], denied: [], clipped: [], gatedBlocked: [declined.capability], universal: [], subsumedBy: [] },
|
|
219
|
+
blocked: true,
|
|
220
|
+
humanDenied,
|
|
221
|
+
reason: `chain refused: ${declined.capability} not approved for ${declined.subject}; no step ran`,
|
|
222
|
+
executor: session.executor.kind,
|
|
223
|
+
now: new Date(),
|
|
224
|
+
}),
|
|
225
|
+
).catch((error) => {
|
|
226
|
+
// Not swallowed. Everywhere else a `strict` ledger failure fails closed, and losing the one line that
|
|
227
|
+
// records a human's refusal is the direction rule 8 forbids — the chain refuses either way, so saying so
|
|
228
|
+
// costs nothing.
|
|
229
|
+
ctx.ui?.notify?.(
|
|
230
|
+
`grants: the chain was refused AND its ledger line could not be written (${String(error)}) — the ` +
|
|
231
|
+
`refusal happened, but this audit trail does not show it.`,
|
|
232
|
+
"error",
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
throw new Error(
|
|
237
|
+
`chain refused: ${declined.capability} was not approved for ${declined.subject}, so no step ran. A chain ` +
|
|
238
|
+
`is gated as a unit — running only its approved steps would return a partial result that reads like a ` +
|
|
239
|
+
`complete one.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const children: ChildProgress[] = steps.map((step) => ({
|
|
244
|
+
label: step.agent ?? "delegate",
|
|
245
|
+
state: "starting",
|
|
246
|
+
startedAt: Date.now(),
|
|
247
|
+
tail: emptyTail,
|
|
248
|
+
}));
|
|
249
|
+
const paint = throttle(() => {
|
|
250
|
+
(onUpdate as ((partial: { content: Array<{ type: "text"; text: string }> }) => void) | undefined)?.({
|
|
251
|
+
content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
|
|
252
|
+
});
|
|
253
|
+
}, PAINT_INTERVAL_MS);
|
|
254
|
+
|
|
255
|
+
const outcomes: Array<{ ok: boolean; text: string; reason?: string; step: number; agent?: string }> = [];
|
|
256
|
+
let previous: string | undefined;
|
|
257
|
+
let aborted = false;
|
|
258
|
+
/**
|
|
259
|
+
* Approvals still available to later steps.
|
|
260
|
+
*
|
|
261
|
+
* **`once` is consumed by the first step that spends it, and not honouring that was a confused deputy.**
|
|
262
|
+
* Measured: three steps of one definition, one dialog naming step 1's task — *"survey the north field"* — and
|
|
263
|
+
* three children spawned, the last of which had been told *"…and burn the evidence"*. Steps 2 and 3 were never
|
|
264
|
+
* described to anyone. Two sequential plain `delegate` calls raise two dialogs, because a `once` answer never
|
|
265
|
+
* enters `sessionApprovals`; the chain was the outlier. R-29 exists for this exact shape one level down.
|
|
266
|
+
*
|
|
267
|
+
* A later step needing the same capability now reaches its own gate and prompts with its OWN task, which is
|
|
268
|
+
* what `once` means.
|
|
269
|
+
*/
|
|
270
|
+
let available = [...preApproved];
|
|
271
|
+
|
|
272
|
+
for (const [index, step] of steps.entries()) {
|
|
273
|
+
const childId = childSpawnId(session.ownSpawnId, index);
|
|
274
|
+
const outcome = await runOneDelegation(
|
|
275
|
+
session,
|
|
276
|
+
chainStepSpec(step, previous),
|
|
277
|
+
{ parentId: session.ownSpawnId, childId },
|
|
278
|
+
split.perChild,
|
|
279
|
+
ctx,
|
|
280
|
+
signal,
|
|
281
|
+
{
|
|
282
|
+
preApproved: available,
|
|
283
|
+
approvalFacts: {
|
|
284
|
+
// Only this step's subject, so the record says what was authorised for THIS child rather than for the
|
|
285
|
+
// chain as a whole.
|
|
286
|
+
approved: available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => a.capability),
|
|
287
|
+
sources: Object.fromEntries(
|
|
288
|
+
available
|
|
289
|
+
.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT))
|
|
290
|
+
.map((a) => [a.capability, "prompt" as const]),
|
|
291
|
+
),
|
|
292
|
+
scopes: Object.fromEntries(
|
|
293
|
+
available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => [a.capability, a.scope]),
|
|
294
|
+
),
|
|
295
|
+
humanDenied: false,
|
|
296
|
+
},
|
|
297
|
+
onProgress: (update) => {
|
|
298
|
+
const child = children[index];
|
|
299
|
+
if (!child) return;
|
|
300
|
+
if (update.paneId) child.paneId = update.paneId;
|
|
301
|
+
if (update.agentName) child.agentName = update.agentName;
|
|
302
|
+
if (update.chunk) child.tail = appendTail(child.tail, update.chunk);
|
|
303
|
+
if (update.snapshot) child.tail = replaceTail(update.snapshot);
|
|
304
|
+
if (update.state) child.state = update.state;
|
|
305
|
+
else if (child.state === "starting") child.state = "running";
|
|
306
|
+
paint.call();
|
|
307
|
+
},
|
|
308
|
+
// Provenance: which child's output composed THIS step's task (ADR-0033). Absent for step 1.
|
|
309
|
+
taskFrom: index === 0 ? undefined : childSpawnId(session.ownSpawnId, index - 1),
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
children[index].state = outcome.ok ? "completed" : "failed";
|
|
314
|
+
children[index].settledAt = Date.now();
|
|
315
|
+
outcomes.push({ ok: outcome.ok, text: outcome.text, reason: outcome.reason, step: index + 1, agent: step.agent });
|
|
316
|
+
|
|
317
|
+
// Spend any `once` this step was handed, before the next step sees the list.
|
|
318
|
+
const spentSubject = step.agent ?? DELEGATE_SUBJECT;
|
|
319
|
+
available = available.filter((a) => !(a.scope === "once" && a.subject === spentSubject));
|
|
320
|
+
|
|
321
|
+
if (!outcome.ok) {
|
|
322
|
+
// **Abort, and mark the rest.** Continuing would make the next step's task an error message, which is
|
|
323
|
+
// never what an orchestrator wants. Everything completed is still returned — R-03's rule.
|
|
324
|
+
aborted = true;
|
|
325
|
+
for (let rest = index + 1; rest < children.length; rest += 1) children[rest].state = "failed";
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
previous = outcome.text;
|
|
329
|
+
}
|
|
330
|
+
paint.flush();
|
|
331
|
+
|
|
332
|
+
const report = outcomes
|
|
333
|
+
.map((o) => {
|
|
334
|
+
const label = `### step ${o.step}${o.agent ? ` (${o.agent})` : ""}`;
|
|
335
|
+
return o.ok ? `${label} — completed\n\n${o.text || "(no output)"}` : `${label} — FAILED: ${o.reason}${o.text ? `\n\n${o.text}` : ""}`;
|
|
336
|
+
})
|
|
337
|
+
.join("\n\n---\n\n");
|
|
338
|
+
|
|
339
|
+
const skipped = steps.length - outcomes.length;
|
|
340
|
+
const tail = aborted
|
|
341
|
+
? `\n\n---\n\n**The chain stopped at step ${outcomes.length}.** ${skipped} later step(s) did not run, ` +
|
|
342
|
+
`because each one's task is built from the previous step's output and there was none to pass on.`
|
|
343
|
+
: "";
|
|
344
|
+
|
|
345
|
+
if (outcomes.length === 1 && !outcomes[0].ok) {
|
|
346
|
+
// Nothing completed at all, so there is no partial result to hand back — and a tool that returns text when
|
|
347
|
+
// nothing ran is how a wrong summary gets written.
|
|
348
|
+
throw new Error(`chain failed at its first step.\n\n${report}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
content: [{ type: "text", text: `${report}${tail}` }],
|
|
353
|
+
details: { steps: steps.length, completed: outcomes.filter((o) => o.ok).length, aborted, budgetPerStep: split.perChild },
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
}
|
package/extensions/delegation.ts
CHANGED
|
@@ -13,9 +13,91 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
13
13
|
import { Type } from "typebox";
|
|
14
14
|
import { maySpawnDefinition } from "../src/delegate.ts";
|
|
15
15
|
import { MAX_CHILDREN_PER_CALL, childSpawnId, splitBudget } from "../src/fanout.ts";
|
|
16
|
+
import {
|
|
17
|
+
PAINT_INTERVAL_MS,
|
|
18
|
+
appendTail,
|
|
19
|
+
emptyTail,
|
|
20
|
+
renderProgress,
|
|
21
|
+
replaceTail,
|
|
22
|
+
throttle,
|
|
23
|
+
type ChildProgress,
|
|
24
|
+
} from "../src/progress.ts";
|
|
25
|
+
import { registerChainTool } from "./delegate-chain.ts";
|
|
16
26
|
import { runOneDelegation } from "./run-delegation.ts";
|
|
17
27
|
import { type GrantsSession } from "./session.ts";
|
|
18
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Wire a set of children to pi's partial-result channel — ADR-0032.
|
|
31
|
+
*
|
|
32
|
+
* **One block for the whole call**, not one per child: `onUpdate` replaces the tool's rendered result, so N
|
|
33
|
+
* independent painters would each overwrite the others and the operator would see one child flickering.
|
|
34
|
+
* `delegate` is simply the one-child case of the same thing, which is why both tools come through here.
|
|
35
|
+
*
|
|
36
|
+
* Painting is throttled, and the trailing frame matters more than the throttling: without it the final paint —
|
|
37
|
+
* the one showing every child settled — is the one most likely to be dropped, so the block would freeze
|
|
38
|
+
* mid-run. `flush()` at the end is what guarantees the last state is the one left on screen.
|
|
39
|
+
*
|
|
40
|
+
* **Nothing here is the result.** The tool's content still comes from each child's returned text.
|
|
41
|
+
*/
|
|
42
|
+
function progressReporter(
|
|
43
|
+
session: GrantsSession,
|
|
44
|
+
labels: string[],
|
|
45
|
+
onUpdate: ((partial: { content: Array<{ type: "text"; text: string }> }) => void) | undefined,
|
|
46
|
+
) {
|
|
47
|
+
const started = Date.now();
|
|
48
|
+
const children: ChildProgress[] = labels.map((label) => ({
|
|
49
|
+
label,
|
|
50
|
+
state: "starting",
|
|
51
|
+
startedAt: started,
|
|
52
|
+
tail: emptyTail,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const paint = throttle(() => {
|
|
56
|
+
onUpdate?.({
|
|
57
|
+
content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
|
|
58
|
+
});
|
|
59
|
+
}, PAINT_INTERVAL_MS);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
/** One sink per child index, shaped for `runOneDelegation`'s `onProgress`. */
|
|
63
|
+
sink: (index: number) => (update: {
|
|
64
|
+
chunk?: string;
|
|
65
|
+
snapshot?: string[];
|
|
66
|
+
paneId?: string;
|
|
67
|
+
agentName?: string;
|
|
68
|
+
state?: ChildProgress["state"];
|
|
69
|
+
}) => {
|
|
70
|
+
const child = children[index];
|
|
71
|
+
if (!child) return;
|
|
72
|
+
if (update.paneId) child.paneId = update.paneId;
|
|
73
|
+
// **Reported, not derived.** It used to be rebuilt here from the label and the child id, which was true
|
|
74
|
+
// until `runHerdrPane` began uniquifying the name to stop herdr's `agent_name_taken` — after which the
|
|
75
|
+
// block named an agent that did not exist, and an operator following it would find nothing. The name is
|
|
76
|
+
// known only where it is minted, so it travels from there.
|
|
77
|
+
if (update.agentName) child.agentName = update.agentName;
|
|
78
|
+
// Two shapes, deliberately distinct, because the two executors are: a subprocess APPENDS bytes, a pane
|
|
79
|
+
// reports a SNAPSHOT that replaces. Collapsing them into one "output" field is what caused the herdr
|
|
80
|
+
// path's amplification, so the difference is spelled out in the type rather than remembered.
|
|
81
|
+
if (update.chunk) child.tail = appendTail(child.tail, update.chunk);
|
|
82
|
+
if (update.snapshot) child.tail = replaceTail(update.snapshot);
|
|
83
|
+
if (update.state) child.state = update.state;
|
|
84
|
+
else if (child.state === "starting") child.state = "running";
|
|
85
|
+
paint.call();
|
|
86
|
+
},
|
|
87
|
+
/** Record how each child ended and leave the final frame on screen. */
|
|
88
|
+
settle: (outcomes: Array<{ ok: boolean }>) => {
|
|
89
|
+
const at = Date.now();
|
|
90
|
+
outcomes.forEach((outcome, index) => {
|
|
91
|
+
const child = children[index];
|
|
92
|
+
if (!child) return;
|
|
93
|
+
child.state = outcome.ok ? "completed" : "failed";
|
|
94
|
+
child.settledAt = at;
|
|
95
|
+
});
|
|
96
|
+
paint.flush();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
19
101
|
export interface DelegationRegistration {
|
|
20
102
|
/**
|
|
21
103
|
* Re-derive "which definitions may this session spawn?" into the registered tool schemas.
|
|
@@ -113,7 +195,10 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
113
195
|
"written by the operator. Use 'tools' only when no definition fits. Grant 'delegate' if the " +
|
|
114
196
|
"sub-agent must itself delegate further; withhold it to make the sub-agent a leaf.",
|
|
115
197
|
parameters: delegateParams,
|
|
116
|
-
async execute(_toolCallId, params, signal,
|
|
198
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
199
|
+
// ADR-0032: one child, same block. `_onUpdate` was discarded here, so a delegation showed the bare word
|
|
200
|
+
// `delegate` for up to DEFAULT_TIMEOUT_MS — ten minutes of nothing.
|
|
201
|
+
const progress = progressReporter(session, [params.agent ?? "delegate"], onUpdate as never);
|
|
117
202
|
const outcome = await runOneDelegation(
|
|
118
203
|
session,
|
|
119
204
|
{ task: params.task, agent: params.agent, tools: params.tools, model: params.model },
|
|
@@ -124,7 +209,9 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
124
209
|
session.fanoutBudget,
|
|
125
210
|
ctx,
|
|
126
211
|
signal,
|
|
212
|
+
{ onProgress: progress.sink(0) },
|
|
127
213
|
);
|
|
214
|
+
progress.settle([outcome]);
|
|
128
215
|
|
|
129
216
|
if (!outcome.ok) {
|
|
130
217
|
// THROW, do not return. `AgentToolResult` has no `isError` field: pi sets it only when `execute`
|
|
@@ -168,7 +255,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
168
255
|
"when independent tasks can proceed in parallel — several reviewers over one diff, say — and read " +
|
|
169
256
|
"every child's outcome, because one can be refused while the others succeed.",
|
|
170
257
|
parameters: delegateAllParams,
|
|
171
|
-
async execute(_toolCallId, params, signal,
|
|
258
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
172
259
|
const children = params.children ?? [];
|
|
173
260
|
const split = splitBudget(session.fanoutBudget, children.length);
|
|
174
261
|
if (!split.ok) {
|
|
@@ -177,6 +264,10 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
177
264
|
throw new Error(`fan-out refused: ${split.reason}`);
|
|
178
265
|
}
|
|
179
266
|
|
|
267
|
+
// ADR-0032: ONE status block covering every child. `onUpdate` replaces the tool's rendered result, so a
|
|
268
|
+
// painter per child would have each overwriting the others.
|
|
269
|
+
const progress = progressReporter(session, children.map((c) => c.agent ?? "delegate"), onUpdate as never);
|
|
270
|
+
|
|
180
271
|
// Concurrent by construction. Each child gets its own budget share and its own ledger id, so the
|
|
181
272
|
// records form a tree and two siblings can never be confused for one another.
|
|
182
273
|
const outcomes = await Promise.all(
|
|
@@ -188,9 +279,11 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
188
279
|
split.perChild,
|
|
189
280
|
ctx,
|
|
190
281
|
signal,
|
|
282
|
+
{ onProgress: progress.sink(index) },
|
|
191
283
|
),
|
|
192
284
|
),
|
|
193
285
|
);
|
|
286
|
+
progress.settle(outcomes);
|
|
194
287
|
|
|
195
288
|
const failed = outcomes.filter((o) => !o.ok);
|
|
196
289
|
// Every child is reported, including the ones that failed. R-03's rule: a missing result must never
|
|
@@ -223,6 +316,11 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
223
316
|
},
|
|
224
317
|
});
|
|
225
318
|
|
|
319
|
+
// ADR-0033. Registered here so all three tools appear together and share the `mayDelegate` guard, but its logic
|
|
320
|
+
// lives in its own file: `delegate` and `delegate_all` differ only in cardinality, while a chain differs in
|
|
321
|
+
// composition, and this file is near the 400-line ceiling.
|
|
322
|
+
registerChainTool(pi, session);
|
|
323
|
+
|
|
226
324
|
return {
|
|
227
325
|
// Written through the CONSTRUCTED schema (`properties.agent`) rather than the object handed to
|
|
228
326
|
// `Type.Optional`, because `Optional` shallow-copies — mutating the input would update a discarded
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { Capability } from "../src/resolve.ts";
|
|
16
|
+
import type { ExecutorChoice } from "../src/executor.ts";
|
|
16
17
|
import type { Catalog } from "../src/catalog.ts";
|
|
17
18
|
import type { SkillDefinition } from "../src/definitions.ts";
|
|
18
19
|
import type { GatedPlan } from "./run-delegation.ts";
|
|
@@ -23,6 +24,13 @@ export interface GrantsCommandContext {
|
|
|
23
24
|
cwd: string;
|
|
24
25
|
governed: boolean;
|
|
25
26
|
ownGrant: Capability[];
|
|
27
|
+
/**
|
|
28
|
+
* Which executor this session settled on, and why — ADR-0031.
|
|
29
|
+
*
|
|
30
|
+
* The whole `ExecutorChoice` rather than a rendered string, so this command cannot compose a different
|
|
31
|
+
* sentence from the one the session banner printed. Two spellings of one fact is R-28.
|
|
32
|
+
*/
|
|
33
|
+
executor: ExecutorChoice;
|
|
26
34
|
observed: boolean;
|
|
27
35
|
depth: number;
|
|
28
36
|
maxDepth: number;
|
|
@@ -71,7 +79,7 @@ handler: async (args: string, ctx: any) => {
|
|
|
71
79
|
// Everything this command may see, named in one place. Previously these were whatever happened to be in
|
|
72
80
|
// the enclosing closure — which is how a diagnostic came to disagree with the enforcer (R-28).
|
|
73
81
|
const {
|
|
74
|
-
cwd, governed, ownGrant, observed, depth, maxDepth, ledgerPath,
|
|
82
|
+
cwd, governed, ownGrant, executor, observed, depth, maxDepth, ledgerPath,
|
|
75
83
|
catalog, definitions, sessionApprovals, inheritedApprovals, snapshotOf, previewDelegation,
|
|
76
84
|
} = ctx.grants as GrantsCommandContext;
|
|
77
85
|
|
|
@@ -102,6 +110,17 @@ handler: async (args: string, ctx: any) => {
|
|
|
102
110
|
` integrity ${report.ok ? "OK" : `${report.corrupt.length} UNPARSEABLE LINE(S)`}`,
|
|
103
111
|
];
|
|
104
112
|
for (const bad of report.corrupt.slice(0, 5)) lines.push(` line ${bad.line}: ${bad.text}`);
|
|
113
|
+
// ADR-0031, and R-51's lesson applied on the day the field was added rather than a release later: a field
|
|
114
|
+
// the writer sets and no diagnostic reads is one that needs `jq`, and `docs/SPEC.md` claims the executor is
|
|
115
|
+
// announced "per child in the ledger". `unknown` is shown only when present, because on a fresh ledger it
|
|
116
|
+
// is always zero and a permanent zero is noise; on an upgraded one it is the count of pre-0.16 lines, which
|
|
117
|
+
// is worth seeing.
|
|
118
|
+
lines.push(
|
|
119
|
+
` executors ${report.executors.herdr} herdr pane(s) · ${report.executors.process} subprocess(es)` +
|
|
120
|
+
(report.executors.unknown > 0
|
|
121
|
+
? ` · ${report.executors.unknown} not recorded (written before 0.16.0, which added the field)`
|
|
122
|
+
: ""),
|
|
123
|
+
);
|
|
105
124
|
// R-51. ADR-0018 advertises that the ledger answers "did these four children run the same
|
|
106
125
|
// instructions?" and "has this definition changed since?" — and nothing read `definitionDigest`, so
|
|
107
126
|
// both needed hand-written jq and the second was not reproducible with `sha256sum` (the digest covers
|
|
@@ -300,6 +319,12 @@ handler: async (args: string, ctx: any) => {
|
|
|
300
319
|
const lines = [
|
|
301
320
|
governed ? "grants: ACTIVE" : "grants: inactive (set PI_GRANTS_GRANT to govern this session)",
|
|
302
321
|
` holding ${ownGrant.join(", ") || "(nothing)"}${observed ? " (observed)" : " (inherited, not yet observed)"}`,
|
|
322
|
+
// ADR-0031/0032. This screen named the grant, the depth, the ledger, the approvals and the catalog, and
|
|
323
|
+
// never said WHERE children run — so an operator on a machine where herdr hosts their whole workspace
|
|
324
|
+
// could not discover that their children were invisible subprocesses. That gap is what produced
|
|
325
|
+
// ADR-0031, and its probe is only defensible because this line exists. Placed next to `holding` so the
|
|
326
|
+
// two facts about what a spawn will be sit together.
|
|
327
|
+
` executor ${executor.disclosure}`,
|
|
303
328
|
` depth ${depth} of max ${maxDepth}${maxDepth <= 0 ? " (spawning disabled)" : ""}`,
|
|
304
329
|
` ledger ${ledgerPath ?? "(not recording — set PI_GRANTS_LEDGER)"}`,
|
|
305
330
|
` approvals ${sessionApprovals.size} this session, ${valid.size} persisted` +
|