pi-daddy 0.32.1 → 0.35.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 +83 -0
- package/contracts/ledger-record/v1/governance-event.schema.json +46 -1
- package/dist/advisors/advisor.d.ts +49 -0
- package/dist/advisors/advisor.d.ts.map +1 -0
- package/dist/advisors/advisor.js +76 -0
- package/dist/advisors/advisor.js.map +1 -0
- package/dist/advisors/decider.d.ts +79 -0
- package/dist/advisors/decider.d.ts.map +1 -0
- package/dist/advisors/decider.js +29 -0
- package/dist/advisors/decider.js.map +1 -0
- package/dist/advisors/jev.d.ts +41 -0
- package/dist/advisors/jev.d.ts.map +1 -0
- package/dist/advisors/jev.js +108 -0
- package/dist/advisors/jev.js.map +1 -0
- package/dist/advisors/settings.d.ts +47 -0
- package/dist/advisors/settings.d.ts.map +1 -0
- package/dist/advisors/settings.js +98 -0
- package/dist/advisors/settings.js.map +1 -0
- package/dist/executors/activity-session.d.ts.map +1 -1
- package/dist/executors/activity-session.js +27 -0
- package/dist/executors/activity-session.js.map +1 -1
- package/dist/executors/herdr-stage.d.ts +1 -1
- package/dist/executors/herdr-stage.d.ts.map +1 -1
- package/dist/executors/herdr-stage.js +25 -8
- package/dist/executors/herdr-stage.js.map +1 -1
- package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
- package/dist/governance/ledger-v3-validation.js +1 -0
- package/dist/governance/ledger-v3-validation.js.map +1 -1
- package/dist/governance/ledger.d.ts +14 -0
- package/dist/governance/ledger.d.ts.map +1 -1
- package/dist/governance/ledger.js +1 -0
- package/dist/governance/ledger.js.map +1 -1
- package/dist/kernel/capabilities.d.ts +1 -1
- package/dist/kernel/capabilities.d.ts.map +1 -1
- package/dist/kernel/capabilities.js +5 -1
- package/dist/kernel/capabilities.js.map +1 -1
- package/dist/kernel/catalog.d.ts.map +1 -1
- package/dist/kernel/catalog.js +6 -0
- package/dist/kernel/catalog.js.map +1 -1
- package/dist/kernel/chain.d.ts +2 -0
- package/dist/kernel/chain.d.ts.map +1 -1
- package/dist/kernel/chain.js.map +1 -1
- package/dist/kernel/context-handoff.d.ts +85 -0
- package/dist/kernel/context-handoff.d.ts.map +1 -0
- package/dist/kernel/context-handoff.js +177 -0
- package/dist/kernel/context-handoff.js.map +1 -0
- package/dist/kernel/delegate-types.d.ts +70 -0
- package/dist/kernel/delegate-types.d.ts.map +1 -1
- package/dist/kernel/delegate-types.js.map +1 -1
- package/dist/kernel/delegate.d.ts.map +1 -1
- package/dist/kernel/delegate.js +50 -2
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/env-names.d.ts +24 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +27 -0
- package/dist/kernel/env-names.js.map +1 -1
- package/dist/kernel/propagation.d.ts +1 -1
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +14 -2
- package/dist/kernel/propagation.js.map +1 -1
- package/dist/kernel/refusals.d.ts +1 -1
- package/dist/kernel/refusals.d.ts.map +1 -1
- package/dist/kernel/refusals.js +1 -0
- package/dist/kernel/refusals.js.map +1 -1
- package/dist/kernel/resolve.d.ts.map +1 -1
- package/dist/kernel/resolve.js +4 -0
- package/dist/kernel/resolve.js.map +1 -1
- package/dist/kernel/spawn.d.ts +21 -0
- package/dist/kernel/spawn.d.ts.map +1 -1
- package/dist/kernel/spawn.js +8 -1
- package/dist/kernel/spawn.js.map +1 -1
- package/extensions/advisor-session.ts +64 -0
- package/extensions/chain-plan.ts +7 -1
- package/extensions/context-shape.ts +30 -0
- package/extensions/context-staging.ts +208 -0
- package/extensions/delegate-chain.ts +2 -0
- package/extensions/delegation-ledger.ts +2 -0
- package/extensions/delegation.ts +4 -0
- package/extensions/effort-advice.ts +99 -0
- package/extensions/execute-child.ts +8 -0
- package/extensions/grants-command.ts +10 -0
- package/extensions/grants.ts +9 -0
- package/extensions/pruning-advice.ts +88 -0
- package/extensions/run-delegation.ts +64 -3
- package/extensions/session.ts +59 -1
- package/package.json +1 -1
- package/src/advisors/advisor.ts +123 -0
- package/src/advisors/decider.ts +68 -0
- package/src/advisors/jev.ts +130 -0
- package/src/advisors/settings.ts +113 -0
- package/src/executors/activity-session.ts +28 -0
- package/src/executors/herdr-stage.ts +26 -8
- package/src/governance/ledger-v3-validation.ts +1 -0
- package/src/governance/ledger.ts +15 -0
- package/src/kernel/capabilities.ts +5 -1
- package/src/kernel/catalog.ts +6 -0
- package/src/kernel/chain.ts +2 -0
- package/src/kernel/context-handoff.ts +231 -0
- package/src/kernel/delegate-types.ts +62 -0
- package/src/kernel/delegate.ts +56 -2
- package/src/kernel/env-names.ts +27 -0
- package/src/kernel/propagation.ts +16 -1
- package/src/kernel/refusals.ts +1 -0
- package/src/kernel/resolve.ts +4 -0
- package/src/kernel/spawn.ts +31 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage what a granted handoff hands over (ADR-0078).
|
|
3
|
+
*
|
|
4
|
+
* The kernel decides WHETHER context crosses and in which mode; this decides what the bytes are, because building
|
|
5
|
+
* them means reading files and the parent's own session and the kernel does no I/O. It is called only after the
|
|
6
|
+
* mode has survived the ceiling, the parent's grant and the gate, so a refused handoff reads nothing.
|
|
7
|
+
*
|
|
8
|
+
* **What authorises the read.** `context:files` is the authorisation, not `tool:read`: an operator who grants a
|
|
9
|
+
* definition the right to receive file contents has said so explicitly, in the grant, where a reviewer sees it.
|
|
10
|
+
* Paths are still confined to the session's working directory, because they are MODEL-supplied and the fence must
|
|
11
|
+
* not become a way to read `/etc` without holding a tool that can. That confinement is a bound on the parameter,
|
|
12
|
+
* not a claim of containment: the parent process can already read whatever its own grant allows.
|
|
13
|
+
*
|
|
14
|
+
* **What is not staged.** A mode whose input cannot be read — a missing file, an unreadable session — yields a
|
|
15
|
+
* section saying so rather than silence. A handoff that quietly carried less than it promised would be R-03's
|
|
16
|
+
* shape, a missing result indistinguishable from an empty one, and the child would have no way to know.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { closeSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
20
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
fenceContext,
|
|
23
|
+
selectPrunedTurns,
|
|
24
|
+
type ContextRequest,
|
|
25
|
+
type ContextSection,
|
|
26
|
+
type PrunableTurn,
|
|
27
|
+
} from "../src/kernel/context-handoff.ts";
|
|
28
|
+
|
|
29
|
+
/** The parent's session, as much of it as staging needs. Satisfied by pi's `ReadonlySessionManager`. */
|
|
30
|
+
export interface ParentSession {
|
|
31
|
+
getSessionFile(): string | undefined;
|
|
32
|
+
getEntries(): Array<{ id: string; type: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StagingInput {
|
|
36
|
+
cwd: string;
|
|
37
|
+
/** Where a forked session is written. One directory per fork, private to this uid. */
|
|
38
|
+
forkRoot: string;
|
|
39
|
+
parentSession?: ParentSession;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StagedHandoff {
|
|
43
|
+
contextPrompt?: string;
|
|
44
|
+
forkFrom?: { sessionPath: string; sessionDir: string; sessionId: string };
|
|
45
|
+
/**
|
|
46
|
+
* Why nothing could be staged. Returned rather than thrown: `planDelegation` is pure and no caller expects it to
|
|
47
|
+
* throw, so a throw here escaped as a raw error with no ledger record, or as `APPROVAL_FLOW_FAILED` after the
|
|
48
|
+
* human had already said yes — measured in review.
|
|
49
|
+
*/
|
|
50
|
+
refusal?: string;
|
|
51
|
+
/** Remove anything this staging created. A fork writes a copy of the parent's session and must not outlive it. */
|
|
52
|
+
dispose?: () => void;
|
|
53
|
+
/** For the ledger: what actually crossed, never what was asked for. Absent when nothing was staged. */
|
|
54
|
+
record?: {
|
|
55
|
+
mode: string;
|
|
56
|
+
sections: number;
|
|
57
|
+
bytes: number;
|
|
58
|
+
truncatedBytes: number;
|
|
59
|
+
keptTurns?: number;
|
|
60
|
+
droppedTurns?: number;
|
|
61
|
+
rule?: string;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 64 KiB per file before the fence's own budget sees it, so one large file cannot starve the others. */
|
|
66
|
+
const MAX_FILE_BYTES = 64 * 1024;
|
|
67
|
+
|
|
68
|
+
export function createHandoffStager(input: StagingInput) {
|
|
69
|
+
return (granted: ContextRequest, options: { keepTurnIds?: readonly string[] } = {}): StagedHandoff => {
|
|
70
|
+
if (granted.mode === "fork") return stageFork(input);
|
|
71
|
+
const sections: ContextSection[] = [];
|
|
72
|
+
let keptTurns: number | undefined;
|
|
73
|
+
let droppedTurns: number | undefined;
|
|
74
|
+
let rule: string | undefined;
|
|
75
|
+
|
|
76
|
+
if (granted.mode === "summary")
|
|
77
|
+
sections.push({ label: "what your parent says you need to know", body: granted.summary ?? "" });
|
|
78
|
+
if (granted.mode === "files" || granted.mode === "pruned")
|
|
79
|
+
for (const path of granted.files ?? []) sections.push(readSection(input.cwd, path));
|
|
80
|
+
if (granted.mode === "pruned") {
|
|
81
|
+
const all = parentTurns(input.parentSession);
|
|
82
|
+
const selection = selectPrunedTurns(all, {
|
|
83
|
+
...(granted.turns !== undefined ? { turns: granted.turns } : {}),
|
|
84
|
+
...(granted.files !== undefined ? { files: granted.files } : {}),
|
|
85
|
+
});
|
|
86
|
+
// A selector may only NARROW what the rule offered. Intersecting here rather than trusting the ids is the
|
|
87
|
+
// whole safety property: whatever a selector returns — a turn the rule dropped, a turn from another session,
|
|
88
|
+
// an id it invented — it cannot put a turn in front of a child that the deterministic rule did not surface.
|
|
89
|
+
const chosen =
|
|
90
|
+
options.keepTurnIds === undefined
|
|
91
|
+
? selection.kept
|
|
92
|
+
: selection.kept.filter((turn) => options.keepTurnIds!.includes(turn.id));
|
|
93
|
+
keptTurns = chosen.length;
|
|
94
|
+
droppedTurns = all.length - chosen.length;
|
|
95
|
+
rule = options.keepTurnIds === undefined ? selection.rule : `${selection.rule}+advice`;
|
|
96
|
+
for (const turn of chosen) sections.push({ label: `parent turn ${turn.id}`, body: turn.text });
|
|
97
|
+
if (chosen.length === 0)
|
|
98
|
+
sections.push({ label: "parent turns", body: "(no turn of your parent's session matched the selection)" });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fenced = fenceContext(sections);
|
|
102
|
+
return {
|
|
103
|
+
contextPrompt: fenced.text,
|
|
104
|
+
record: {
|
|
105
|
+
mode: granted.mode,
|
|
106
|
+
sections: sections.length,
|
|
107
|
+
bytes: Buffer.byteLength(fenced.text),
|
|
108
|
+
truncatedBytes: fenced.truncatedBytes,
|
|
109
|
+
...(keptTurns !== undefined ? { keptTurns, droppedTurns, rule } : {}),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A fork replaces the session file the inactivity deadline would otherwise watch, because pi refuses `--fork`
|
|
117
|
+
* beside `--session`. The directory is ours and holds exactly one session, so the activity probe watches the
|
|
118
|
+
* directory instead of a fixed path — pi names the file `<timestamp>_<id>.jsonl` and only the id half is ours.
|
|
119
|
+
*/
|
|
120
|
+
function stageFork(input: StagingInput): StagedHandoff {
|
|
121
|
+
const sessionPath = input.parentSession?.getSessionFile();
|
|
122
|
+
if (!sessionPath)
|
|
123
|
+
// Not a silent downgrade to `none`: the parent asked for its whole session to cross and it has none to give.
|
|
124
|
+
return { refusal: "context: fork needs the parent's session file, and this session is not persisted" };
|
|
125
|
+
const sessionId = randomUUID();
|
|
126
|
+
const sessionDir = resolve(input.forkRoot, `fork-${sessionId}`);
|
|
127
|
+
try {
|
|
128
|
+
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return { refusal: `context: fork could not allocate ${sessionDir} (${String(error)})` };
|
|
131
|
+
}
|
|
132
|
+
// The ledger records the SIZE of what crossed, because a fork is the largest handoff there is and recording it
|
|
133
|
+
// as zero bytes would make the audit trail understate exactly the mode that deserves the most scrutiny.
|
|
134
|
+
let bytes = 0;
|
|
135
|
+
let entries = 0;
|
|
136
|
+
try {
|
|
137
|
+
bytes = statSync(sessionPath).size;
|
|
138
|
+
entries = input.parentSession?.getEntries().length ?? 0;
|
|
139
|
+
} catch {
|
|
140
|
+
/* an unreadable parent session is still a fork pi will attempt; the record says 0 rather than guessing */
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
forkFrom: { sessionPath, sessionDir, sessionId },
|
|
144
|
+
dispose: () => rmSync(sessionDir, { recursive: true, force: true }),
|
|
145
|
+
record: { mode: "fork", sections: 1, bytes, truncatedBytes: 0, keptTurns: entries, droppedTurns: 0 },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Read one named file, confined to the working directory, saying so in the fence when it cannot be read. */
|
|
150
|
+
function readSection(cwd: string, path: string): ContextSection {
|
|
151
|
+
const refuse = (why: string) => ({ label: path, body: `(refused: ${why})` });
|
|
152
|
+
if (isAbsolute(path)) return refuse("an absolute path");
|
|
153
|
+
let absolute: string;
|
|
154
|
+
let root: string;
|
|
155
|
+
try {
|
|
156
|
+
// `realpath`, not `resolve`: `resolve`/`relative` are LEXICAL, so a symlink inside the working directory
|
|
157
|
+
// pointing anywhere at all passed the check — measured in review with `cwd/link.txt -> /tmp/outside.txt`,
|
|
158
|
+
// whose contents duly appeared inside the fence. A repository full of `node_modules/.bin` symlinks makes that
|
|
159
|
+
// the ordinary case rather than a contrived one.
|
|
160
|
+
root = realpathSync(resolve(cwd));
|
|
161
|
+
absolute = realpathSync(resolve(cwd, path));
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return { label: path, body: `(could not be read: ${error instanceof Error ? error.message : String(error)})` };
|
|
164
|
+
}
|
|
165
|
+
const within = relative(root, absolute);
|
|
166
|
+
if (within.startsWith("..") || within === "" || isAbsolute(within))
|
|
167
|
+
return refuse("outside this session's working directory");
|
|
168
|
+
try {
|
|
169
|
+
const stats = statSync(absolute);
|
|
170
|
+
// A FIFO satisfies `statSync` and then never returns from a read, which would block the whole pi session with
|
|
171
|
+
// no watchdog — and the path is model-supplied, so it is reachable rather than theoretical.
|
|
172
|
+
if (!stats.isFile()) return refuse("not a regular file");
|
|
173
|
+
const note = stats.size > MAX_FILE_BYTES ? ` (first ${MAX_FILE_BYTES} of ${stats.size} bytes)` : "";
|
|
174
|
+
return { label: `${path}${note}`, body: readBounded(absolute, MAX_FILE_BYTES) };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return { label: path, body: `(could not be read: ${error instanceof Error ? error.message : String(error)})` };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read at most `budget` BYTES, without pulling the rest of the file into the parent first.
|
|
182
|
+
*
|
|
183
|
+
* `readFileSync(...).slice(budget)` reads the whole file and then slices by UTF-16 code units, so a large file in
|
|
184
|
+
* the repository stalled the parent and the "(first N of M bytes)" label was wrong for any multi-byte content.
|
|
185
|
+
*/
|
|
186
|
+
function readBounded(path: string, budget: number): string {
|
|
187
|
+
const handle = openSync(path, "r");
|
|
188
|
+
try {
|
|
189
|
+
const buffer = Buffer.alloc(budget);
|
|
190
|
+
const read = readSync(handle, buffer, 0, budget, 0);
|
|
191
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(buffer.subarray(0, read)).replace(/\uFFFD+$/, "");
|
|
192
|
+
} finally {
|
|
193
|
+
closeSync(handle);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The parent's message turns, reduced to what the selection rule needs. Never pi's own types past this point. */
|
|
198
|
+
function parentTurns(session?: ParentSession): PrunableTurn[] {
|
|
199
|
+
if (!session) return [];
|
|
200
|
+
try {
|
|
201
|
+
return session
|
|
202
|
+
.getEntries()
|
|
203
|
+
.filter((entry) => entry.type === "message")
|
|
204
|
+
.map((entry) => ({ id: entry.id, text: JSON.stringify((entry as { message?: unknown }).message ?? entry) }));
|
|
205
|
+
} catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -41,6 +41,7 @@ import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/kerne
|
|
|
41
41
|
import { chainApprovalFacts, newChainApprovalAudit, rememberChainApproval } from "./chain-approval-facts.ts";
|
|
42
42
|
import { newExecutionId } from "../src/kernel/execution-id.ts";
|
|
43
43
|
import { planChain, type GateRequest } from "./chain-plan.ts";
|
|
44
|
+
import { contextShape } from "./context-shape.ts";
|
|
44
45
|
import { preflightModel } from "../src/kernel/model-preflight.ts";
|
|
45
46
|
import { assertDelegationAuthority } from "./delegation-authority.ts";
|
|
46
47
|
|
|
@@ -83,6 +84,7 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
83
84
|
{ description: "Requested Pi thinking level; unsupported model/level combinations fail in the child." },
|
|
84
85
|
),
|
|
85
86
|
),
|
|
87
|
+
context: Type.Optional(contextShape()),
|
|
86
88
|
correlation: Type.Optional(correlationShape()),
|
|
87
89
|
workspace: Type.Optional(
|
|
88
90
|
Type.Object({
|
|
@@ -39,6 +39,8 @@ export async function recordDelegationDecision(input: {
|
|
|
39
39
|
taskFrom: input.taskFrom,
|
|
40
40
|
taskFromExecutionId: input.taskFromExecutionId,
|
|
41
41
|
requested: plan.requested,
|
|
42
|
+
// ADR-0078: what crossed, recorded as a fact about this child rather than as the parent's request.
|
|
43
|
+
...(plan.handoffRecord ? { handoff: plan.handoffRecord } : {}),
|
|
42
44
|
parentGrant: session.ownGrant,
|
|
43
45
|
result: plan.result,
|
|
44
46
|
blocked: !plan.ok,
|
package/extensions/delegation.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { type GrantsSession } from "./session.ts";
|
|
|
36
36
|
import { newDelegationOccurrence } from "./execution-occurrence.ts";
|
|
37
37
|
import { correlationShape as buildCorrelationShape } from "./correlation-shape.ts";
|
|
38
38
|
import { assertDelegationAuthority } from "./delegation-authority.ts";
|
|
39
|
+
import { contextShape } from "./context-shape.ts";
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* Wire a set of children to pi's partial-result channel — ADR-0032.
|
|
@@ -189,6 +190,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
189
190
|
tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
|
|
190
191
|
model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
|
|
191
192
|
thinking: thinkingShape,
|
|
193
|
+
context: Type.Optional(contextShape()),
|
|
192
194
|
correlation: Type.Optional(correlationShape),
|
|
193
195
|
workspace: Type.Optional(workspaceShape),
|
|
194
196
|
});
|
|
@@ -221,6 +223,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
221
223
|
}),
|
|
222
224
|
),
|
|
223
225
|
thinking: thinkingShape,
|
|
226
|
+
context: Type.Optional(contextShape()),
|
|
224
227
|
correlation: Type.Optional(correlationShape),
|
|
225
228
|
workspace: Type.Optional(workspaceShape),
|
|
226
229
|
});
|
|
@@ -247,6 +250,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
247
250
|
tools: params.tools,
|
|
248
251
|
model: params.model,
|
|
249
252
|
thinking: params.thinking,
|
|
253
|
+
context: params.context,
|
|
250
254
|
correlation: params.correlation,
|
|
251
255
|
workspace: params.workspace,
|
|
252
256
|
},
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first decision point that consults an advisor (ADR-0077, roadmap PR 8): how hard a child should think.
|
|
3
|
+
*
|
|
4
|
+
* **Why this one.** It is the shape the boundary was designed for. The options are not invented by the advisor —
|
|
5
|
+
* they are the thinking levels this session's own model reports it supports (`supportedModelEfforts`), so the
|
|
6
|
+
* advisor picks among things the caller already had, which is the entire permitted verb list. It touches no
|
|
7
|
+
* capability, no gate and no grant: the worst an advisor can do here is make a child think harder or less hard
|
|
8
|
+
* than a human would have chosen, and the ledger says it did.
|
|
9
|
+
*
|
|
10
|
+
* **Only when the caller said nothing.** An explicit `thinking` on the call is the operator's or the model's own
|
|
11
|
+
* choice and is never second-guessed; advice fills a blank, it does not overrule. With no advisor, no key, no
|
|
12
|
+
* answer, a timeout or an unrecognised response, the blank stays blank and the child is spawned exactly as it is
|
|
13
|
+
* today — which is the property that keeps advisors optional rather than load-bearing.
|
|
14
|
+
*/
|
|
15
|
+
import { supportedModelEfforts } from "../src/kernel/model-preflight.ts";
|
|
16
|
+
import type { Advisor } from "../src/advisors/advisor.ts";
|
|
17
|
+
|
|
18
|
+
/** What pi's resolved catalogue entry carries that decides which efforts exist. */
|
|
19
|
+
interface ResolvedModel {
|
|
20
|
+
reasoning: boolean;
|
|
21
|
+
thinkingLevelMap?: Partial<Record<string, string | null>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const EFFORT_PURPOSE = "child-effort";
|
|
25
|
+
|
|
26
|
+
export async function adviseEffort(input: {
|
|
27
|
+
/** The session, read here rather than passed as an advisor: an argument can be severed and nothing notices. */
|
|
28
|
+
session: { advisorSession: { advisor: Advisor } };
|
|
29
|
+
/** Absent means the caller chose one; nothing is asked and nothing is recorded. */
|
|
30
|
+
requested?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The model the CHILD will run on, `provider/id`, not the session's.
|
|
33
|
+
*
|
|
34
|
+
* Review measured the first version reading the parent session's model while the child was spawned on
|
|
35
|
+
* `spec.model`: the levels offered then came from a model the child would never use, and pi clamps rather than
|
|
36
|
+
* refuses, so the effect was a silently shifted effort rather than a loud failure.
|
|
37
|
+
*/
|
|
38
|
+
model?: string;
|
|
39
|
+
registry: { find(provider: string, modelId: string): unknown };
|
|
40
|
+
task: string;
|
|
41
|
+
agent?: string;
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
}): Promise<string | undefined> {
|
|
44
|
+
const advisor = input.session.advisorSession.advisor;
|
|
45
|
+
const slash = input.model === undefined ? -1 : input.model.indexOf("/");
|
|
46
|
+
if (input.requested !== undefined || slash <= 0) return input.requested;
|
|
47
|
+
const resolved = input.registry.find(input.model!.slice(0, slash), input.model!.slice(slash + 1)) as
|
|
48
|
+
ResolvedModel | undefined;
|
|
49
|
+
if (!resolved) return undefined;
|
|
50
|
+
const levels = supportedModelEfforts(resolved);
|
|
51
|
+
// One option is not a choice, and a model with no reasoning has exactly one. Asking would spend a call and a
|
|
52
|
+
// ledger line to be told the only thing that could be said.
|
|
53
|
+
if (levels.length < 2) return undefined;
|
|
54
|
+
|
|
55
|
+
const advice = await advisor.ask(
|
|
56
|
+
EFFORT_PURPOSE,
|
|
57
|
+
{
|
|
58
|
+
// The task text is what the decision is actually about, and it is the one thing this package has never
|
|
59
|
+
// stored (ADR-0021). It is sent to the advisor because an advisor cannot judge a task it cannot see, and it
|
|
60
|
+
// is NOT recorded: `createAdvisor` writes the question keys and the answer, never the state. An operator who
|
|
61
|
+
// is not willing to send task text to a third party leaves the advisor off, which is the default.
|
|
62
|
+
state: { task: input.task, ...(input.agent ? { definition: input.agent } : {}) },
|
|
63
|
+
questions: {
|
|
64
|
+
effort: {
|
|
65
|
+
kind: "choice",
|
|
66
|
+
instructions:
|
|
67
|
+
"How much reasoning effort does this task need? Choose the cheapest level that would still do it well.",
|
|
68
|
+
options: Object.fromEntries(levels.map((level) => [level, effortDescription(level)])),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
input.signal,
|
|
73
|
+
);
|
|
74
|
+
const chosen = advice?.answers.effort;
|
|
75
|
+
// Belt and braces: `parseAnswer` already refuses a choice outside the options it was given, so this can only
|
|
76
|
+
// fire if a future decider is written that does not. An effort the model does not support would be refused by
|
|
77
|
+
// pi in the child, after the spawn, which is a worse place to find out.
|
|
78
|
+
if (!chosen || chosen.kind !== "choice" || !(levels as readonly string[]).includes(chosen.value)) return undefined;
|
|
79
|
+
return chosen.value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function effortDescription(level: string): string {
|
|
83
|
+
switch (level) {
|
|
84
|
+
case "off":
|
|
85
|
+
return "No reasoning. Mechanical work: a rename, a formatting pass, reading one file back.";
|
|
86
|
+
case "minimal":
|
|
87
|
+
return "Almost none. A single obvious step with no choice in it.";
|
|
88
|
+
case "low":
|
|
89
|
+
return "A little. One decision, or a change confined to one file.";
|
|
90
|
+
case "medium":
|
|
91
|
+
return "Ordinary. Several steps, or a change that has to fit existing code.";
|
|
92
|
+
case "high":
|
|
93
|
+
return "Substantial. Design choices, or work across several files that must stay consistent.";
|
|
94
|
+
case "xhigh":
|
|
95
|
+
return "Very high. A subtle problem where the obvious approach is likely to be wrong.";
|
|
96
|
+
default:
|
|
97
|
+
return "The most this model can do. Reserve it for work that has defeated a lesser effort.";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -148,6 +148,14 @@ export async function executePlannedChild(input: {
|
|
|
148
148
|
return await executeWithActivitySession();
|
|
149
149
|
} finally {
|
|
150
150
|
if (!keepPaneRequested) await activitySession.dispose();
|
|
151
|
+
// ADR-0078: a fork wrote a COPY of the parent's whole session to disk. PR 3e deletes its own temp session on
|
|
152
|
+
// every path and this must too, or every forked child leaves a full transcript behind for good.
|
|
153
|
+
if (!keepPaneRequested)
|
|
154
|
+
try {
|
|
155
|
+
plan.disposeHandoff?.();
|
|
156
|
+
} catch {
|
|
157
|
+
/* teardown must not replace the child's outcome */
|
|
158
|
+
}
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
async function executeWithActivitySession(): Promise<DelegationOutcome> {
|
|
@@ -32,6 +32,8 @@ export interface GrantsCommandContext {
|
|
|
32
32
|
* sentence from the one the session banner printed. Two spellings of one fact is R-28.
|
|
33
33
|
*/
|
|
34
34
|
executor: ExecutorChoice;
|
|
35
|
+
/** ADR-0077: which advisor is in force, or why none is. Reported because an advisor sends task text out. */
|
|
36
|
+
advisor: { decider: string; refusal?: string };
|
|
35
37
|
observed: boolean;
|
|
36
38
|
depth: number;
|
|
37
39
|
maxDepth: number;
|
|
@@ -89,6 +91,7 @@ export const grantsCommand = {
|
|
|
89
91
|
governed,
|
|
90
92
|
ownGrant,
|
|
91
93
|
executor,
|
|
94
|
+
advisor,
|
|
92
95
|
observed,
|
|
93
96
|
depth,
|
|
94
97
|
maxDepth,
|
|
@@ -354,6 +357,13 @@ export const grantsCommand = {
|
|
|
354
357
|
// two facts about what a spawn will be sit together.
|
|
355
358
|
` executor ${executor.disclosure}`,
|
|
356
359
|
` depth ${depth} of max ${maxDepth}${maxDepth <= 0 ? " (spawning disabled)" : ""}`,
|
|
360
|
+
// Rule 8's loud half, which review found missing: an operator who upgraded from 0.34.0, or who mistyped the
|
|
361
|
+
// variable, saw an advisor silently absent and nothing saying why. This is also where an operator sees that
|
|
362
|
+
// task text leaves the machine, which no other surface says.
|
|
363
|
+
advisor.decider === "none"
|
|
364
|
+
? ` advisor off${advisor.refusal ? ` — ${advisor.refusal}` : ""}`
|
|
365
|
+
: ` advisor ${advisor.decider} — a delegation with no thinking level sends it the task text; ` +
|
|
366
|
+
`a pruned context handoff also sends session turns`,
|
|
357
367
|
` ledger ${ledgerPath || "(not recording — set PI_DADDY_LEDGER)"}`,
|
|
358
368
|
` approvals ${sessionApprovals.size} this session, ${valid.size} persisted` +
|
|
359
369
|
`${inheritedApprovals.size > 0 ? `, ${inheritedApprovals.size} inherited` : ""}` +
|
package/extensions/grants.ts
CHANGED
|
@@ -79,6 +79,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
79
79
|
const reload = bindReloadLifecycle(owner, session.reloadLifecycle);
|
|
80
80
|
session.reconcileEnvironment(reload.environment, reload.lifecycle);
|
|
81
81
|
session.ownerBound = true;
|
|
82
|
+
// ADR-0078: the parent's own session, for a granted `pruned` or `fork` handoff. Read-only, and only ever
|
|
83
|
+
// read after the mode has survived the gate.
|
|
84
|
+
const manager = owner as Partial<import("./context-staging.ts").ParentSession>;
|
|
85
|
+
if (typeof manager.getSessionFile === "function" && typeof manager.getEntries === "function")
|
|
86
|
+
session.parentSession = manager as import("./context-staging.ts").ParentSession;
|
|
82
87
|
delegation.refreshSpawnable = registerDelegationTools(pi, session).refreshSpawnable;
|
|
83
88
|
reconcileActiveDelegationTools(pi, session);
|
|
84
89
|
session.cwd = ctx.cwd;
|
|
@@ -369,6 +374,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
369
374
|
depth: session.depth,
|
|
370
375
|
maxDepth: session.maxDepth,
|
|
371
376
|
ledgerPath: session.ledgerPath,
|
|
377
|
+
advisor: {
|
|
378
|
+
decider: session.advisorSession.deciderName,
|
|
379
|
+
...(session.advisorSession.settings.refusal ? { refusal: session.advisorSession.settings.refusal } : {}),
|
|
380
|
+
},
|
|
372
381
|
catalog: session.catalog,
|
|
373
382
|
definitions: session.definitions,
|
|
374
383
|
sessionApprovals: session.sessionApprovals,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The second decision point: which of the parent's turns a `pruned` handoff actually carries (ADR-0077).
|
|
3
|
+
*
|
|
4
|
+
* The deterministic rule keeps the last few turns plus older ones naming the given files. That rule is honest and
|
|
5
|
+
* its recall is unmeasured — it is a proxy for "what matters here", not an answer. An advisor can judge the turns
|
|
6
|
+
* against the task the child is about to be given, which the rule cannot see.
|
|
7
|
+
*
|
|
8
|
+
* **It can only take turns away.** The candidates are exactly what `selectPrunedTurns` produced; the advisor is
|
|
9
|
+
* asked one boolean per candidate and the ids it kept are handed back. Staging then intersects those ids with the
|
|
10
|
+
* candidates again, so even a selector that invented an id, returned one from another session, or returned every
|
|
11
|
+
* id in existence cannot put a turn in front of a child that the rule did not already offer. Narrowing is the only
|
|
12
|
+
* verb available, which is what makes this advice rather than authority.
|
|
13
|
+
*
|
|
14
|
+
* With no advisor, no answer, a timeout or an unrecognised response, nothing is returned and the rule's own
|
|
15
|
+
* selection stands — the same "identical to today" property the effort decision point has.
|
|
16
|
+
*/
|
|
17
|
+
import { selectPrunedTurns, type ContextRequest } from "../src/kernel/context-handoff.ts";
|
|
18
|
+
import type { Advisor } from "../src/advisors/advisor.ts";
|
|
19
|
+
import type { Question } from "../src/advisors/decider.ts";
|
|
20
|
+
|
|
21
|
+
export const PRUNING_PURPOSE = "handoff-pruning";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How many candidates may be judged. One question per turn, and a decision a human is waiting on should not carry
|
|
25
|
+
* an unbounded number of them; beyond this the rule's own selection stands, which is the safe direction.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_JUDGED_TURNS = 12;
|
|
28
|
+
|
|
29
|
+
/** Just enough of the parent's session for the rule; the same shape `context-staging` reduces entries to. */
|
|
30
|
+
export interface ParentTurnSource {
|
|
31
|
+
getEntries(): Array<{ id: string; type: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function advisePruning(input: {
|
|
35
|
+
session: { advisorSession: { advisor: Advisor }; parentSession?: ParentTurnSource };
|
|
36
|
+
granted: ContextRequest;
|
|
37
|
+
task: string;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}): Promise<string[] | undefined> {
|
|
40
|
+
if (input.granted.mode !== "pruned" || !input.session.parentSession) return undefined;
|
|
41
|
+
const all = turnsOf(input.session.parentSession);
|
|
42
|
+
const candidates = selectPrunedTurns(all, {
|
|
43
|
+
...(input.granted.turns !== undefined ? { turns: input.granted.turns } : {}),
|
|
44
|
+
...(input.granted.files !== undefined ? { files: input.granted.files } : {}),
|
|
45
|
+
}).kept;
|
|
46
|
+
// Nothing to narrow, or more than a bounded number to judge: the rule stands and no call is made.
|
|
47
|
+
if (candidates.length < 2 || candidates.length > MAX_JUDGED_TURNS) return undefined;
|
|
48
|
+
|
|
49
|
+
const questions: Record<string, Question> = {};
|
|
50
|
+
for (const [index, turn] of candidates.entries())
|
|
51
|
+
questions[`turn${index}`] = {
|
|
52
|
+
kind: "noul",
|
|
53
|
+
// Both bounded: the task is embedded once per candidate, so an unbounded task became a request twelve times
|
|
54
|
+
// its size, on a two-second budget and the operator's key.
|
|
55
|
+
instructions: `Would a sub-agent doing this task be helped by seeing this part of the parent's session?\n\nTASK: ${input.task.slice(0, 2000)}\n\nPART:\n${turn.text.slice(0, 2000)}`,
|
|
56
|
+
whenTrue: "It bears on the task: a decision, a constraint, a fact the task depends on.",
|
|
57
|
+
whenFalse: "It does not: unrelated work, chatter, or something the task already states.",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const advice = await input.session.advisorSession.advisor.ask(
|
|
61
|
+
PRUNING_PURPOSE,
|
|
62
|
+
// The state is empty: everything the advisor needs is already in the questions, and a question carries the
|
|
63
|
+
// task and one turn rather than the whole session. Nothing here is recorded — `createAdvisor` writes keys.
|
|
64
|
+
{ state: {}, questions },
|
|
65
|
+
input.signal,
|
|
66
|
+
);
|
|
67
|
+
if (!advice) return undefined;
|
|
68
|
+
// Every candidate or none. A response missing eleven of twelve answers would otherwise read as "drop eleven",
|
|
69
|
+
// which is a narrowing nobody asked for rather than the "unrecognised response means no advice" contract.
|
|
70
|
+
if (candidates.some((_, index) => advice.answers[`turn${index}`]?.kind !== "noul")) return undefined;
|
|
71
|
+
const kept = candidates
|
|
72
|
+
.filter((_, index) => (advice.answers[`turn${index}`] as { value: boolean }).value)
|
|
73
|
+
.map((turn) => turn.id);
|
|
74
|
+
// An advisor that drops everything is answering a different question from the one that was asked; the rule's
|
|
75
|
+
// selection stands rather than handing a child a handoff with nothing in it.
|
|
76
|
+
return kept.length === 0 ? undefined : kept;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function turnsOf(session: ParentTurnSource): Array<{ id: string; text: string }> {
|
|
80
|
+
try {
|
|
81
|
+
return session
|
|
82
|
+
.getEntries()
|
|
83
|
+
.filter((entry) => entry.type === "message")
|
|
84
|
+
.map((entry) => ({ id: entry.id, text: JSON.stringify((entry as { message?: unknown }).message ?? entry) }));
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { nativeDelegationContext } from "./delegation-native.ts";
|
|
15
|
+
import { adviseEffort } from "./effort-advice.ts";
|
|
16
|
+
import { advisePruning } from "./pruning-advice.ts";
|
|
15
17
|
import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/kernel/approval.ts";
|
|
16
18
|
import { planDelegation } from "../src/kernel/delegate.ts";
|
|
17
19
|
import {
|
|
@@ -45,6 +47,8 @@ interface ChildSpec {
|
|
|
45
47
|
tools?: string[];
|
|
46
48
|
model?: string;
|
|
47
49
|
thinking?: string;
|
|
50
|
+
/** ADR-0078: what of the parent's session crosses. Validated in the kernel, never here. */
|
|
51
|
+
context?: unknown;
|
|
48
52
|
correlation?: CorrelationMetadata;
|
|
49
53
|
workspace?: DelegationWorkspaceSpec;
|
|
50
54
|
}
|
|
@@ -247,7 +251,10 @@ export async function runOneDelegation(
|
|
|
247
251
|
agent: spec.agent,
|
|
248
252
|
tools: spec.tools,
|
|
249
253
|
model: spec.model ?? defaultModel,
|
|
254
|
+
// Filled below, once the refusals that doom a delegation are known: asking first shipped the task text for a
|
|
255
|
+
// child that never starts, which is the ordering this module already fixed for the approval dialog.
|
|
250
256
|
thinking: spec.thinking,
|
|
257
|
+
context: spec.context,
|
|
251
258
|
correlation: spec.workspace
|
|
252
259
|
? { ...(spec.correlation ?? {}), workspace_id: spec.workspace.workspace_id }
|
|
253
260
|
: spec.correlation,
|
|
@@ -286,6 +293,28 @@ export async function runOneDelegation(
|
|
|
286
293
|
Boolean(executorRefusal || modelRefusal),
|
|
287
294
|
);
|
|
288
295
|
executorRefusal ||= nativeRefusal;
|
|
296
|
+
|
|
297
|
+
// ADR-0077's first decision point, after the refusal checks for the reason above. Fills a blank from the levels
|
|
298
|
+
// the CHILD's model reports; never overrules a caller, and yields today's behaviour whenever there is no answer.
|
|
299
|
+
if (!executorRefusal && !modelRefusal)
|
|
300
|
+
request.thinking = await adviseEffort({
|
|
301
|
+
session,
|
|
302
|
+
requested: spec.thinking,
|
|
303
|
+
model: spec.model ?? defaultModel,
|
|
304
|
+
registry: ctx.modelRegistry,
|
|
305
|
+
task: spec.task,
|
|
306
|
+
agent: spec.agent,
|
|
307
|
+
signal,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const planContext = await handoffPlanContext({
|
|
311
|
+
session,
|
|
312
|
+
base: extra,
|
|
313
|
+
task: spec.task,
|
|
314
|
+
blocked: Boolean(executorRefusal || modelRefusal),
|
|
315
|
+
preview: () => planWithApprovals(session, request, extra, null, signal, preApproved).then((r) => r.plan),
|
|
316
|
+
...(signal ? { signal } : {}),
|
|
317
|
+
});
|
|
289
318
|
let preparedWorkspace: PreparedWorkspace | undefined;
|
|
290
319
|
let approvalOutcome: ApprovalOutcome | undefined;
|
|
291
320
|
let plan: ReturnType<typeof planDelegation>;
|
|
@@ -294,7 +323,7 @@ export async function runOneDelegation(
|
|
|
294
323
|
// Check non-liftable refusals before taking a lease, and take the lease before asking a human. This
|
|
295
324
|
// preserves both anti-race rules: a doomed spawn cannot bank approval, and a conflicting writer starts
|
|
296
325
|
// no child process.
|
|
297
|
-
const preview = await planWithApprovals(session, request,
|
|
326
|
+
const preview = await planWithApprovals(session, request, planContext, null, signal, preApproved);
|
|
298
327
|
plan = preview.plan;
|
|
299
328
|
if (plan.ok || shouldSeekApproval(plan.result)) {
|
|
300
329
|
try {
|
|
@@ -308,7 +337,7 @@ export async function runOneDelegation(
|
|
|
308
337
|
ledgerPath: session.ledgerPath,
|
|
309
338
|
});
|
|
310
339
|
request.correlation = preparedWorkspace.correlation;
|
|
311
|
-
const gated = await planWithApprovals(session, request,
|
|
340
|
+
const gated = await planWithApprovals(session, request, planContext, ctx, signal, preApproved);
|
|
312
341
|
plan = gated.plan;
|
|
313
342
|
approvalOutcome = gated.approval;
|
|
314
343
|
} catch (error) {
|
|
@@ -323,7 +352,7 @@ export async function runOneDelegation(
|
|
|
323
352
|
const gated = await planWithApprovals(
|
|
324
353
|
session,
|
|
325
354
|
request,
|
|
326
|
-
|
|
355
|
+
planContext,
|
|
327
356
|
executorRefusal || modelRefusal ? null : ctx,
|
|
328
357
|
signal,
|
|
329
358
|
preApproved,
|
|
@@ -406,3 +435,35 @@ export async function runOneDelegation(
|
|
|
406
435
|
onProgress,
|
|
407
436
|
});
|
|
408
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The planner context for one delegation, including a `pruned` handoff narrowed by an advisor (ADR-0077).
|
|
441
|
+
*
|
|
442
|
+
* **Exported and taking its own `preview`, so the ordering is forced by a test rather than by a reviewer.** Three
|
|
443
|
+
* properties live here and each was, at some point in this change's history, true only because somebody had
|
|
444
|
+
* checked it by hand: an advisor is not asked for a delegation that is already refused; it is not asked until a
|
|
445
|
+
* plan says the `pruned` handoff actually survived the ceiling, the grant and the gate; and the ids it returns
|
|
446
|
+
* reach the planner. Reviewers measured all three by mutating the source and finding the suite still green. A
|
|
447
|
+
* function with a seam is the only version of this that a test can hold.
|
|
448
|
+
*/
|
|
449
|
+
export async function handoffPlanContext(input: {
|
|
450
|
+
session: Parameters<typeof advisePruning>[0]["session"];
|
|
451
|
+
base: Record<string, unknown>;
|
|
452
|
+
task: string;
|
|
453
|
+
/** A refusal is already certain, so nothing may be asked. */
|
|
454
|
+
blocked: boolean;
|
|
455
|
+
/** Plans with no human in the loop; its result decides whether an advisor is consulted at all. */
|
|
456
|
+
preview: () => Promise<{ handoff?: { mode: string } }>;
|
|
457
|
+
signal?: AbortSignal;
|
|
458
|
+
}): Promise<Record<string, unknown>> {
|
|
459
|
+
if (input.blocked) return { ...input.base };
|
|
460
|
+
const plan = await input.preview();
|
|
461
|
+
if (plan.handoff?.mode !== "pruned") return { ...input.base };
|
|
462
|
+
const ids = await advisePruning({
|
|
463
|
+
session: input.session,
|
|
464
|
+
granted: plan.handoff as Parameters<typeof advisePruning>[0]["granted"],
|
|
465
|
+
task: input.task,
|
|
466
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
467
|
+
});
|
|
468
|
+
return ids ? { ...input.base, handoffTurnIds: ids } : { ...input.base };
|
|
469
|
+
}
|