pi-daddy 0.16.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 +28 -0
- package/README.md +19 -0
- 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/delegate.d.ts.map +1 -1
- package/dist/delegate.js +6 -2
- package/dist/delegate.js.map +1 -1
- 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/ledger.d.ts +14 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +1 -0
- package/dist/ledger.js.map +1 -1
- package/extensions/delegate-chain.ts +357 -0
- package/extensions/delegation.ts +8 -2
- package/extensions/run-delegation.ts +71 -16
- package/package.json +5 -1
- package/src/chain.ts +174 -0
- package/src/delegate.ts +6 -2
- package/src/fanout.ts +10 -0
- package/src/ledger.ts +15 -0
|
@@ -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
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
throttle,
|
|
23
23
|
type ChildProgress,
|
|
24
24
|
} from "../src/progress.ts";
|
|
25
|
+
import { registerChainTool } from "./delegate-chain.ts";
|
|
25
26
|
import { runOneDelegation } from "./run-delegation.ts";
|
|
26
27
|
import { type GrantsSession } from "./session.ts";
|
|
27
28
|
|
|
@@ -208,7 +209,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
208
209
|
session.fanoutBudget,
|
|
209
210
|
ctx,
|
|
210
211
|
signal,
|
|
211
|
-
progress.sink(0),
|
|
212
|
+
{ onProgress: progress.sink(0) },
|
|
212
213
|
);
|
|
213
214
|
progress.settle([outcome]);
|
|
214
215
|
|
|
@@ -278,7 +279,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
278
279
|
split.perChild,
|
|
279
280
|
ctx,
|
|
280
281
|
signal,
|
|
281
|
-
progress.sink(index),
|
|
282
|
+
{ onProgress: progress.sink(index) },
|
|
282
283
|
),
|
|
283
284
|
),
|
|
284
285
|
);
|
|
@@ -315,6 +316,11 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
315
316
|
},
|
|
316
317
|
});
|
|
317
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
|
+
|
|
318
324
|
return {
|
|
319
325
|
// Written through the CONSTRUCTED schema (`properties.agent`) rather than the object handed to
|
|
320
326
|
// `Type.Optional`, because `Optional` shallow-copies — mutating the input would update a discarded
|
|
@@ -22,6 +22,7 @@ import type { Capability } from "../src/resolve.ts";
|
|
|
22
22
|
import { ENV_CHILD_TIMEOUT, runChild, timeoutFromEnv } from "../src/run-child.ts";
|
|
23
23
|
import { runHerdrPane } from "../src/run-herdr.ts";
|
|
24
24
|
import { obtainApprovals, republishable, snapshotOf, type ApprovalOutcome, type ApprovalUIContext } from "./approvals.ts";
|
|
25
|
+
import type { InheritableApproval } from "../src/approval.ts";
|
|
25
26
|
import { resolveWorkspace } from "../src/herdr-cli.ts";
|
|
26
27
|
import { ENV_HERDR_KEEP_PANE, type GrantsSession } from "./session.ts";
|
|
27
28
|
|
|
@@ -72,12 +73,23 @@ export async function planWithApprovals(
|
|
|
72
73
|
extra: Record<string, unknown>,
|
|
73
74
|
ctx: ApprovalUIContext | null,
|
|
74
75
|
signal?: AbortSignal,
|
|
76
|
+
/**
|
|
77
|
+
* Approvals a caller has ALREADY obtained, so this plan does not ask again — ADR-0033's upfront gate.
|
|
78
|
+
*
|
|
79
|
+
* `delegate_chain` collects the union of its steps' gated capabilities and asks once, then hands the answer to
|
|
80
|
+
* every step. Without this each step would re-open the dialog *after* the operator had already answered for the
|
|
81
|
+
* whole chain, which is R-25's fatigue shape with nothing bought.
|
|
82
|
+
*
|
|
83
|
+
* It cannot widen anything: `planDelegation` intersects `approved` with the grant on every path, so a
|
|
84
|
+
* pre-approval for something the session does not hold is still refused. What it changes is who is asked.
|
|
85
|
+
*/
|
|
86
|
+
preApproved?: InheritableApproval[],
|
|
75
87
|
): Promise<GatedPlan> {
|
|
76
88
|
// Spelled ONCE. It is asked for twice — when the human is prompted, and when the answer is fed back into
|
|
77
89
|
// the re-plan — and two spellings of one argument is the defect R-28 was.
|
|
78
90
|
const approvalSubject = request.agent ?? DELEGATE_SUBJECT;
|
|
79
91
|
|
|
80
|
-
let plan = planDelegation(request, { ...(await session.delegationContext()), ...extra });
|
|
92
|
+
let plan = planDelegation(request, { ...(await session.delegationContext(preApproved)), ...extra });
|
|
81
93
|
if (plan.ok || !shouldSeekApproval(plan.result)) return { plan };
|
|
82
94
|
|
|
83
95
|
let approval: ApprovalOutcome | undefined;
|
|
@@ -163,17 +175,50 @@ export async function runOneDelegation(
|
|
|
163
175
|
* One sink for both executors: the herdr path additionally reports a pane id, and the process path never
|
|
164
176
|
* has one. Every field is display-only — the child's answer is still the returned outcome.
|
|
165
177
|
*/
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
178
|
+
/**
|
|
179
|
+
* The optional tail, as ONE object rather than positional arguments.
|
|
180
|
+
*
|
|
181
|
+
* **R-28's lesson, applied before it cost anything.** Adding `preApproved` as a seventh positional parameter put
|
|
182
|
+
* it in front of `onProgress`, and two existing call sites silently passed a progress sink where approvals were
|
|
183
|
+
* expected. TypeScript caught it only because the types happen to differ — which is luck, not a control, and
|
|
184
|
+
* R-28 was precisely "a defect in an argument list that 226 pure tests could not see". An object makes the
|
|
185
|
+
* mistake unspellable.
|
|
186
|
+
*/
|
|
187
|
+
options: {
|
|
188
|
+
/** Progress for the parent's status block (ADR-0032). Display only. */
|
|
189
|
+
onProgress?: (update: {
|
|
190
|
+
/** Appended (process executor: a genuine byte stream). */
|
|
191
|
+
chunk?: string;
|
|
192
|
+
/** Replaces (herdr executor: a snapshot of a bounded terminal). The two are NOT interchangeable. */
|
|
193
|
+
snapshot?: string[];
|
|
194
|
+
paneId?: string;
|
|
195
|
+
/** The name herdr actually knows this child by — minted in `runHerdrPane`, so it cannot be derived. */
|
|
196
|
+
agentName?: string;
|
|
197
|
+
state?: "running" | "completed" | "failed";
|
|
198
|
+
}) => void;
|
|
199
|
+
/** Approvals already obtained by the caller — see `planWithApprovals`. `delegate_chain` uses it. */
|
|
200
|
+
preApproved?: InheritableApproval[];
|
|
201
|
+
/**
|
|
202
|
+
* The child whose output composed this child's task (ADR-0033).
|
|
203
|
+
*
|
|
204
|
+
* Recorded, never acted on: it exists so "who wrote this instruction?" is answerable from the trail, which is
|
|
205
|
+
* the question the chain's framed-rather-than-enforced handoff makes worth asking.
|
|
206
|
+
*/
|
|
207
|
+
taskFrom?: string;
|
|
208
|
+
/**
|
|
209
|
+
* What the caller's own gate decided, for the LEDGER — not for the plan.
|
|
210
|
+
*
|
|
211
|
+
* **Required because pre-filling `approved` silences the record.** The doc comment on `planWithApprovals` above
|
|
212
|
+
* warns about exactly this: satisfying the gate on the first plan means `obtainApprovals` never runs, so
|
|
213
|
+
* `approval` is undefined and this record writes no `approved`, `approvalSources`, `approvalScopes` or
|
|
214
|
+
* `humanDenied`. Measured: a chain step that spent `tool:bash` on a human's click was indistinguishable from one
|
|
215
|
+
* where nothing was ever gated — `/grants ledger` counted it in neither `bySource` nor `unattributed`, so it did
|
|
216
|
+
* not even show up as a gap, and ADR-0010's compensating control was blind to every chain step.
|
|
217
|
+
*/
|
|
218
|
+
approvalFacts?: Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "humanDenied">;
|
|
219
|
+
} = {},
|
|
176
220
|
): Promise<DelegationOutcome> {
|
|
221
|
+
const { onProgress, preApproved, taskFrom, approvalFacts } = options;
|
|
177
222
|
// pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
|
|
178
223
|
// alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
|
|
179
224
|
const defaultModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
@@ -198,7 +243,14 @@ export async function runOneDelegation(
|
|
|
198
243
|
// Planning and the gate live in `planWithApprovals`, shared with the `/grants` preview so the two cannot
|
|
199
244
|
// disagree (R-38). This call is the enforcing one when a human may be asked: `ctx` is passed unless the
|
|
200
245
|
// executor has already made the outcome certain.
|
|
201
|
-
let { plan, approval: approvalOutcome } = await planWithApprovals(
|
|
246
|
+
let { plan, approval: approvalOutcome } = await planWithApprovals(
|
|
247
|
+
session,
|
|
248
|
+
request,
|
|
249
|
+
extra,
|
|
250
|
+
refusal ? null : ctx,
|
|
251
|
+
signal,
|
|
252
|
+
preApproved,
|
|
253
|
+
);
|
|
202
254
|
|
|
203
255
|
// Applied in front of the ledger write below, so the record describes a refusal rather than a spawn. Turning
|
|
204
256
|
// `plan.ok` off reuses the existing blocked-record path, so this adds a reason rather than a second refusal
|
|
@@ -222,15 +274,18 @@ export async function runOneDelegation(
|
|
|
222
274
|
// ADR-0031: where this child actually ran. Read off the live session, which the probe has settled
|
|
223
275
|
// by now, so the record and the executor cannot disagree.
|
|
224
276
|
executor: session.executor.kind,
|
|
277
|
+
taskFrom,
|
|
225
278
|
requested: plan.requested,
|
|
226
279
|
parentGrant: session.ownGrant,
|
|
227
280
|
result: plan.result,
|
|
228
281
|
blocked: !plan.ok,
|
|
229
282
|
reason: plan.reason,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
283
|
+
// `approvalOutcome` when this call's own gate ran; `approvalFacts` when a caller gated upfront on our behalf
|
|
284
|
+
// (a chain). Without the second, an approved chain step recorded nothing about the human who authorised it.
|
|
285
|
+
approved: approvalOutcome?.approved ?? approvalFacts?.approved,
|
|
286
|
+
approvalSources: approvalOutcome?.sources ?? approvalFacts?.sources,
|
|
287
|
+
approvalScopes: approvalOutcome?.scopes ?? approvalFacts?.scopes,
|
|
288
|
+
humanDenied: approvalOutcome?.humanDenied ?? approvalFacts?.humanDenied,
|
|
234
289
|
gateOutcome: approvalOutcome?.gateOutcome,
|
|
235
290
|
// ADR-0018: taken from the PLAN, never re-derived here. The B-I3 lesson — a call site that
|
|
236
291
|
// recomputed the digest could record one the planner never used.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -85,6 +85,10 @@
|
|
|
85
85
|
"types": "./dist/progress.d.ts",
|
|
86
86
|
"default": "./dist/progress.js"
|
|
87
87
|
},
|
|
88
|
+
"./chain": {
|
|
89
|
+
"types": "./dist/chain.d.ts",
|
|
90
|
+
"default": "./dist/chain.js"
|
|
91
|
+
},
|
|
88
92
|
"./run-child": {
|
|
89
93
|
"types": "./dist/run-child.d.ts",
|
|
90
94
|
"default": "./dist/run-child.js"
|