specpi 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 +15 -0
- package/README.md +30 -202
- package/SECURITY_MODEL.md +36 -7
- package/THIRD_PARTY.md +3 -1
- package/docs/delegation/README.md +74 -14
- package/docs/delegation/design.md +1 -1
- package/docs/delegation/protocol.md +33 -13
- package/extensions/delegation/core.mjs +37 -6
- package/extensions/delegation/errors.mjs +64 -0
- package/extensions/delegation/extension.mjs +78 -12
- package/extensions/delegation/native.mjs +1 -1
- package/extensions/delegation/protocol.mjs +24 -11
- package/extensions/delegation/provider.mjs +38 -29
- package/extensions/delegation/settings.mjs +45 -15
- package/extensions/delegation/snapshot.mjs +103 -25
- package/extensions/delegation/worker.mjs +122 -40
- package/package.json +1 -1
- package/scripts/site-browser.mjs +30 -11
|
@@ -4,8 +4,12 @@ This is the implemented in-process API. It has no HTTP listener, daemon, child p
|
|
|
4
4
|
or child session store. The broader [target protocol](design-protocol.md) remains a
|
|
5
5
|
proposal; its stronger transport/attempt/cost gates are not supplied by this version.
|
|
6
6
|
|
|
7
|
-
The extension loads through normal `pi` package discovery and
|
|
8
|
-
the
|
|
7
|
+
The extension loads through normal `pi` package discovery and enables delegation at
|
|
8
|
+
the first session start of each Pi process, including noninteractive modes. Startup
|
|
9
|
+
preflights the host but launches no workers or model inference; Pi-owned authentication
|
|
10
|
+
preparation may occur. `/delegate off` and safety revocations survive reloads and session
|
|
11
|
+
switches; `/delegate on` re-enables it, and restarting Pi reapplies the on default.
|
|
12
|
+
Compatibility is checked through required public SDK
|
|
9
13
|
capabilities; there is no exact-version allowlist. Missing APIs prevent activation and
|
|
10
14
|
are named in the error. The runtime also verifies the created session's thinking,
|
|
11
15
|
tools and streaming interface. Tested versions are evidence, not an activation gate.
|
|
@@ -18,7 +22,7 @@ overrides, model-specific headers, startup proxy configuration and safe model-de
|
|
|
18
22
|
mismatches fail preflight. Parent request hooks,
|
|
19
23
|
ephemeral runtime settings, session affinity and ambient resources are not inherited.
|
|
20
24
|
|
|
21
|
-
Command Guard is optional. Absent and Off states permit
|
|
25
|
+
Command Guard is optional. Absent and Off states permit activation; an installed
|
|
22
26
|
Guard's Strict approvals and explicit locks remain enforced. Unready or duplicate
|
|
23
27
|
Guard responders prevent activation with a specific error. Guard state changes revoke
|
|
24
28
|
the current delegation generation. Snapshot tools and resource limits are enforced
|
|
@@ -51,7 +55,7 @@ policy rather than assuming the shipped default.
|
|
|
51
55
|
|
|
52
56
|
## Submit a batch
|
|
53
57
|
|
|
54
|
-
|
|
58
|
+
When delegation is enabled, the parent calls the `delegate` tool:
|
|
55
59
|
|
|
56
60
|
```json
|
|
57
61
|
{
|
|
@@ -168,9 +172,18 @@ job selection and valid line ranges. `p1` denotes the submitted inline context;
|
|
|
168
172
|
does not denote the parent transcript or prove repository behavior. `observed` requires
|
|
169
173
|
at least one reference. At most eight findings and 16 KiB are retained.
|
|
170
174
|
|
|
171
|
-
Malformed reports
|
|
172
|
-
|
|
173
|
-
|
|
175
|
+
Malformed reports receive a correction prompt in the same child session. Previously
|
|
176
|
+
read passages remain available; each correction must pass the normal inference admission
|
|
177
|
+
and context/deadline checks. Ordinary selected-source argument errors return feedback
|
|
178
|
+
and count their delivered JSON toward the tool-byte allowance. SDK argument-validation
|
|
179
|
+
errors can also be corrected; source changes, revocation and quota exhaustion still abort.
|
|
180
|
+
Provider requests are not automatically retried. Diagnostics distinguish
|
|
181
|
+
source-tool failures, provider requests, stream/context/response bounds, output-token
|
|
182
|
+
truncation, empty reports, JSON parsing, and schema/evidence validation. Only fixed stage
|
|
183
|
+
messages and allowlisted validation reasons are returned; raw provider errors and rejected
|
|
184
|
+
report text may contain sensitive URLs or content and are withheld. The original tool
|
|
185
|
+
diagnostic survives SDK cancellation. Missing usage is explicit; failed calls still
|
|
186
|
+
consume invocation allowance.
|
|
174
187
|
|
|
175
188
|
## Follow up and resolve
|
|
176
189
|
|
|
@@ -219,7 +232,8 @@ automatic tool execution, verified completion, or wishlist authorization.
|
|
|
219
232
|
|
|
220
233
|
All mutations require `requestId`. Successful runs, follow-ups and final dispositions
|
|
221
234
|
retain their replay receipts for the process lifetime; the fixed batch/job ceilings
|
|
222
|
-
bound these to at most
|
|
235
|
+
bound these to at most five entries per admitted batch (160 at the default budget;
|
|
236
|
+
1,280 at the maximum). Replaying a retained request returns its stored
|
|
223
237
|
response without another request or transition. Reusing a retained ID with a different
|
|
224
238
|
payload fails. Failed requests do not reserve IDs and may be corrected or retried.
|
|
225
239
|
Successful cancellation and `needs_check` responses use a separate 128-entry oldest-first
|
|
@@ -241,14 +255,20 @@ SDK-visible stream/result and prompt settlement. Old payloads are discarded. Nei
|
|
|
241
255
|
terminal receipt delivery nor SDK settlement proves physical remote execution has ended.
|
|
242
256
|
|
|
243
257
|
The same in-memory controller survives `/reload` and session switches within the Pi
|
|
244
|
-
process.
|
|
245
|
-
per process, with two jobs and
|
|
246
|
-
job including one follow-up.
|
|
247
|
-
|
|
258
|
+
process. Defaults are two active workers, 32 batches and 256 SDK model invocations
|
|
259
|
+
per process, with two jobs and 64 invocations per batch and 32 invocations per logical
|
|
260
|
+
job including one follow-up. Source reading allows 96 calls and 512 KiB delivered
|
|
261
|
+
JSON per job. Human `/delegate budget <multiplier>` (1–64, default 8) while off scales
|
|
262
|
+
batch/model/tool counts, source-output bytes, serialized child context and SDK response bytes; it is
|
|
263
|
+
saved across restarts and never resets spent counters or extends old jobs. Exhausted
|
|
264
|
+
jobs reject follow-up admission rather than opening a child with no usable allowance.
|
|
265
|
+
Requested output uses Pi's normal provider/model settings (`outputTokens: null`).
|
|
266
|
+
SDK response acceptance defaults to 1 MiB and scales to 8 MiB at multiplier 64.
|
|
267
|
+
These are experiment limits, not research-derived optimal values.
|
|
248
268
|
Human off/on, task changes,
|
|
249
269
|
branch navigation, model selection, guard changes and reloads revoke old generations;
|
|
250
270
|
they do not create a new resource allowance. Normal parent turns do not revoke a job.
|
|
251
|
-
Model and thinking selections retain the human
|
|
271
|
+
Model and thinking selections retain the default or human activation choice. The extension
|
|
252
272
|
preflights the latest selected host and resumes dispatch automatically, without
|
|
253
273
|
replaying old jobs. Unsupported selections pause dispatch and report `pauseReason`;
|
|
254
274
|
a compatible selection resumes it. Status exposes `requested`, `updating` and
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createSnapshot } from "./snapshot.mjs";
|
|
3
|
-
import { LIMITS, digest, timeoutLimits, validateOperation } from "./protocol.mjs";
|
|
3
|
+
import { LIMITS, digest, timeoutLimits, budgetLimits, validateOperation } from "./protocol.mjs";
|
|
4
4
|
import { runWorker } from "./worker.mjs";
|
|
5
|
-
import { DelegationError, publicErrorMessage } from "./errors.mjs";
|
|
5
|
+
import { DelegationError, publicErrorMessage, workerFailureMessage } from "./errors.mjs";
|
|
6
6
|
|
|
7
7
|
const terminal = new Set(["complete", "partial", "needs_context", "failed", "cancelled", "expired", "stale"]);
|
|
8
8
|
const quiet = new Set(["cancelled", "expired", "stale"]);
|
|
@@ -21,7 +21,11 @@ export function createDelegationController({
|
|
|
21
21
|
Object.fromEntries(
|
|
22
22
|
Object.entries(LIMITS).map(([key, maximum]) => {
|
|
23
23
|
const value = limits[key] ?? maximum;
|
|
24
|
-
|
|
24
|
+
const modelDefault = key === "outputTokens" && value === null;
|
|
25
|
+
if (
|
|
26
|
+
!modelDefault &&
|
|
27
|
+
(!Number.isSafeInteger(value) || value < 1 || (maximum !== null && value > maximum))
|
|
28
|
+
) {
|
|
25
29
|
throw new DelegationError("Limits may only lower the fixed delegation ceilings");
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -137,6 +141,7 @@ export function createDelegationController({
|
|
|
137
141
|
!batch.retired &&
|
|
138
142
|
!quiet.has(job.state) &&
|
|
139
143
|
!job.disposition &&
|
|
144
|
+
!job.limitReason &&
|
|
140
145
|
Date.now() < Math.min(job.deadline, batch.deadline) &&
|
|
141
146
|
(!terminal.has(job.state) || job.followUps < 1);
|
|
142
147
|
|
|
@@ -346,6 +351,7 @@ export function createDelegationController({
|
|
|
346
351
|
batch.calls >= policy.batchCalls ||
|
|
347
352
|
totalCalls >= policy.sessionCalls
|
|
348
353
|
) {
|
|
354
|
+
job.limitReason = "model-call allowance";
|
|
349
355
|
throw new DelegationError("Delegation model-call allowance exhausted");
|
|
350
356
|
}
|
|
351
357
|
|
|
@@ -370,14 +376,16 @@ export function createDelegationController({
|
|
|
370
376
|
assertJobLive();
|
|
371
377
|
checkSources(batch);
|
|
372
378
|
finish(batch, job, result.status, result);
|
|
373
|
-
} catch {
|
|
379
|
+
} catch (error) {
|
|
374
380
|
// Provider errors can contain URLs, credentials, or user content. Do not return them.
|
|
375
381
|
finish(
|
|
376
382
|
batch,
|
|
377
383
|
job,
|
|
378
384
|
"failed",
|
|
379
385
|
undefined,
|
|
380
|
-
|
|
386
|
+
job.limitReason
|
|
387
|
+
? `Worker exhausted its ${job.limitReason}. Review /delegate limits; a human can raise /delegate budget while off and start a fresh batch.`
|
|
388
|
+
: workerFailureMessage(error),
|
|
381
389
|
);
|
|
382
390
|
} finally {
|
|
383
391
|
job.controller.abort();
|
|
@@ -520,6 +528,19 @@ export function createDelegationController({
|
|
|
520
528
|
|
|
521
529
|
const { batch, job } = findBoundJob(input);
|
|
522
530
|
if (input.operation === "follow_up") {
|
|
531
|
+
if (
|
|
532
|
+
job.limitReason ||
|
|
533
|
+
job.calls >= policy.jobCalls ||
|
|
534
|
+
batch.calls >= policy.batchCalls ||
|
|
535
|
+
totalCalls >= policy.sessionCalls ||
|
|
536
|
+
job.toolCalls >= policy.toolCalls ||
|
|
537
|
+
job.toolBytes >= policy.toolBytes
|
|
538
|
+
) {
|
|
539
|
+
throw new DelegationError(
|
|
540
|
+
"Follow-up has no usable budget remaining. A human can raise /delegate budget while off, then start a fresh batch.",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
523
544
|
if (
|
|
524
545
|
job.settling ||
|
|
525
546
|
job.followUps >= 1 ||
|
|
@@ -685,7 +706,7 @@ export function createDelegationController({
|
|
|
685
706
|
try {
|
|
686
707
|
entry.value = mutate(input);
|
|
687
708
|
// Spending and final-disposition receipts fit the fixed batch/job ceilings
|
|
688
|
-
// (
|
|
709
|
+
// (one run and at most two follow-ups/two dispositions per batch). Never evict them.
|
|
689
710
|
if (input.operation === "cancel" || (input.operation === "resolve" && input.decision === "needs_check")) {
|
|
690
711
|
recentRequests.set(input.requestId, entry);
|
|
691
712
|
if (recentRequests.size > 128) {
|
|
@@ -708,6 +729,16 @@ export function createDelegationController({
|
|
|
708
729
|
presentation,
|
|
709
730
|
invalidate,
|
|
710
731
|
// Human command only; never exposed through execute() or the model schema.
|
|
732
|
+
setBudgetMultiplier(multiplier) {
|
|
733
|
+
const budget = budgetLimits(multiplier);
|
|
734
|
+
if (enabled) {
|
|
735
|
+
throw new DelegationError("Turn delegation off before changing its budget: /delegate off");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
invalidate("budget policy changed");
|
|
739
|
+
policy = Object.freeze({ ...policy, ...budget });
|
|
740
|
+
changed();
|
|
741
|
+
},
|
|
711
742
|
setTimeoutMinutes(minutes) {
|
|
712
743
|
const times = timeoutLimits(minutes);
|
|
713
744
|
if (enabled) {
|
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
/** Only code-owned diagnostics may cross the delegation UI/model boundary. */
|
|
2
2
|
export class DelegationError extends Error {}
|
|
3
3
|
|
|
4
|
+
const workerStages = new Map(
|
|
5
|
+
Object.entries({
|
|
6
|
+
setup: "Worker session setup failed; check Pi provider configuration.",
|
|
7
|
+
provider: "Worker provider request failed; raw provider details were withheld.",
|
|
8
|
+
stream: "Worker response stream failed validation or exceeded its retained-response allowance.",
|
|
9
|
+
context: "Worker request exceeded its context allowance or could not be serialized.",
|
|
10
|
+
response: "Worker provider response exceeded its retained-response allowance or was invalid.",
|
|
11
|
+
tool: "Worker source-tool request failed; check the selected source IDs and tool argument limits.",
|
|
12
|
+
policy: "Worker requested an unavailable tool or changed its inference policy.",
|
|
13
|
+
lease: "Worker lease was cancelled or expired; check the original deadline and current model/Guard state.",
|
|
14
|
+
incomplete: "Worker provider returned no complete assistant response.",
|
|
15
|
+
output: "Worker hit the provider output-token limit before completing its report; narrow the review or reduce thinking effort.",
|
|
16
|
+
empty: "Worker returned no report text.",
|
|
17
|
+
json: "Worker report was not valid JSON; the follow-up must request only the required JSON object without Markdown fences.",
|
|
18
|
+
result: "Worker report failed schema or evidence validation.",
|
|
19
|
+
size: "Worker report exceeded the final-result byte allowance; request a shorter report.",
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
const workerDetails = new Set([
|
|
23
|
+
"Invalid closed delegation object",
|
|
24
|
+
"Invalid delegation text",
|
|
25
|
+
"Invalid delegation identifier",
|
|
26
|
+
"Invalid delegation list",
|
|
27
|
+
"Invalid delegation integer",
|
|
28
|
+
"Invalid worker result size or status",
|
|
29
|
+
"Worker referenced an unknown source",
|
|
30
|
+
"Worker requirement coverage is invalid",
|
|
31
|
+
"Worker must cover each requirement exactly once",
|
|
32
|
+
"Invalid finding confidence",
|
|
33
|
+
"Observed findings require a source reference",
|
|
34
|
+
"Worker finding identifiers must be unique",
|
|
35
|
+
"Source is not selected for this job",
|
|
36
|
+
"Snapshot read request rejected.",
|
|
37
|
+
"Snapshot search request rejected.",
|
|
38
|
+
"Snapshot search scope rejected.",
|
|
39
|
+
"Snapshot line exceeds response quota.",
|
|
40
|
+
"Snapshot source changed.",
|
|
41
|
+
"Snapshot source unavailable or changed.",
|
|
42
|
+
"Snapshot path binding changed.",
|
|
43
|
+
"Snapshot closed.",
|
|
44
|
+
]);
|
|
45
|
+
const workerMessages = new WeakMap();
|
|
46
|
+
|
|
47
|
+
// Retain only fixed diagnostics, never an SDK cause, stack, response, or arbitrary message.
|
|
48
|
+
export function workerFailure(stage, error) {
|
|
49
|
+
if (workerMessages.has(error)) {
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const detail =
|
|
54
|
+
error instanceof DelegationError && workerDetails.has(error.message)
|
|
55
|
+
? ` ${error.message.replace(/\.$/u, "")}.`
|
|
56
|
+
: "";
|
|
57
|
+
const message = (workerStages.get(stage) ?? "Worker failed; no safe diagnostic was available.") + detail;
|
|
58
|
+
const failure = new DelegationError(message);
|
|
59
|
+
workerMessages.set(failure, message);
|
|
60
|
+
|
|
61
|
+
return failure;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function workerFailureMessage(error) {
|
|
65
|
+
return workerMessages.get(error) ?? "Worker failed; no safe diagnostic was available.";
|
|
66
|
+
}
|
|
67
|
+
|
|
4
68
|
export function publicErrorMessage(error) {
|
|
5
69
|
return error instanceof DelegationError
|
|
6
70
|
? error.message
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { DelegationError, publicErrorMessage } from "./errors.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { createDelegationController } from "./core.mjs";
|
|
4
|
-
import { DEFAULT_TIMEOUT_MINUTES, timeoutLimits } from "./protocol.mjs";
|
|
4
|
+
import { DEFAULT_TIMEOUT_MINUTES, DEFAULT_BUDGET_MULTIPLIER, timeoutLimits, budgetLimits } from "./protocol.mjs";
|
|
5
5
|
import { createLivePanel, createToolRenderers, readableLimits, readableStatus } from "./presentation.mjs";
|
|
6
6
|
|
|
7
|
-
const USAGE =
|
|
7
|
+
const USAGE =
|
|
8
|
+
"Usage: /delegate [on|off|status|limits|budget [<multiplier>|reset]|timeout [<minutes>|reset]|cancel <batchId>]";
|
|
8
9
|
|
|
9
10
|
const integer = { type: "integer", minimum: 0 };
|
|
10
11
|
const string = { type: "string" };
|
|
@@ -58,7 +59,11 @@ export const DELEGATE_SCHEMA = {
|
|
|
58
59
|
mode: { type: "string", enum: ["review", "scout"] },
|
|
59
60
|
question: string,
|
|
60
61
|
context: string,
|
|
61
|
-
sources:
|
|
62
|
+
sources: {
|
|
63
|
+
...strings,
|
|
64
|
+
description:
|
|
65
|
+
"Exact workspace-relative text filenames. Ordinary auth, credential, session and history source names are allowed. Private runtime storage, credential stores and keys are excluded; no directories, globs or absolute paths.",
|
|
66
|
+
},
|
|
62
67
|
requirements: {
|
|
63
68
|
type: "array",
|
|
64
69
|
minItems: 1,
|
|
@@ -104,6 +109,7 @@ export function createDelegationExtension(
|
|
|
104
109
|
let detach = () => {};
|
|
105
110
|
|
|
106
111
|
let requested = false;
|
|
112
|
+
let startupActivation = true;
|
|
107
113
|
let requestedGuard;
|
|
108
114
|
let boundHost;
|
|
109
115
|
let pending;
|
|
@@ -136,7 +142,13 @@ export function createDelegationExtension(
|
|
|
136
142
|
let timeoutLoaded = !timeoutStore;
|
|
137
143
|
const loadTimeout = () => {
|
|
138
144
|
if (!timeoutLoaded) {
|
|
139
|
-
|
|
145
|
+
const minutes = timeoutStore.load();
|
|
146
|
+
const multiplier = timeoutStore.loadBudget?.();
|
|
147
|
+
if (multiplier !== undefined) {
|
|
148
|
+
controller.setBudgetMultiplier(multiplier);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
controller.setTimeoutMinutes(minutes);
|
|
140
152
|
timeoutLoaded = true;
|
|
141
153
|
}
|
|
142
154
|
};
|
|
@@ -166,7 +178,11 @@ export function createDelegationExtension(
|
|
|
166
178
|
panel?.update();
|
|
167
179
|
}
|
|
168
180
|
|
|
169
|
-
function invalidate(reason) {
|
|
181
|
+
function invalidate(reason, preserveStartupDefault = false) {
|
|
182
|
+
if (!preserveStartupDefault) {
|
|
183
|
+
startupActivation = false;
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
requested = false;
|
|
171
187
|
pending = undefined;
|
|
172
188
|
boundHost = undefined;
|
|
@@ -197,7 +213,7 @@ export function createDelegationExtension(
|
|
|
197
213
|
const isBound = () => issuedEpoch === bindingEpoch;
|
|
198
214
|
toolsReady = false;
|
|
199
215
|
prepareContext(undefined, true);
|
|
200
|
-
invalidate("runtime factory rebound");
|
|
216
|
+
invalidate("runtime factory rebound", true);
|
|
201
217
|
currentPi = pi;
|
|
202
218
|
currentContext = undefined;
|
|
203
219
|
const refreshSelection = async (ctx) => {
|
|
@@ -336,7 +352,7 @@ export function createDelegationExtension(
|
|
|
336
352
|
});
|
|
337
353
|
}
|
|
338
354
|
|
|
339
|
-
pi.on("session_start", (_event, ctx) => {
|
|
355
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
340
356
|
if (!isBound()) {
|
|
341
357
|
return;
|
|
342
358
|
}
|
|
@@ -344,13 +360,27 @@ export function createDelegationExtension(
|
|
|
344
360
|
currentContext = ctx;
|
|
345
361
|
toolsReady = true;
|
|
346
362
|
panel?.bind(ctx);
|
|
347
|
-
invalidate("session started or resources reloaded");
|
|
363
|
+
invalidate("session started or resources reloaded", true);
|
|
348
364
|
try {
|
|
349
365
|
loadTimeout();
|
|
350
366
|
prepareContext(ctx, true);
|
|
367
|
+
// Consume the default only after synchronous preparation, which
|
|
368
|
+
// may revoke it. Off and safety events cancel it even before startup.
|
|
369
|
+
const activate = startupActivation;
|
|
370
|
+
startupActivation = false;
|
|
371
|
+
if (activate && isBound()) {
|
|
372
|
+
requested = true;
|
|
373
|
+
requestedGuard = getGuard();
|
|
374
|
+
await refreshSelection(ctx);
|
|
375
|
+
if (isBound() && requested && pauseReason) {
|
|
376
|
+
ctx.ui.notify(`Delegation paused: ${pauseReason}`, "warning");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
351
379
|
} catch (error) {
|
|
352
380
|
pauseReason = publicErrorMessage(error);
|
|
353
381
|
ctx.ui.notify(`Delegation unavailable: ${pauseReason}`, "warning");
|
|
382
|
+
} finally {
|
|
383
|
+
startupActivation = false;
|
|
354
384
|
}
|
|
355
385
|
});
|
|
356
386
|
pi.on("session_shutdown", () => {
|
|
@@ -380,13 +410,20 @@ export function createDelegationExtension(
|
|
|
380
410
|
}
|
|
381
411
|
});
|
|
382
412
|
pi.registerCommand("delegate", {
|
|
383
|
-
description:
|
|
413
|
+
description:
|
|
414
|
+
"Control delegation, inspect limits, or save budget <multiplier> (1–64; default 8) and timeout <minutes> (1–60; default 10)",
|
|
384
415
|
getArgumentCompletions: (prefix) =>
|
|
385
416
|
[
|
|
386
417
|
"on",
|
|
387
418
|
"off",
|
|
388
419
|
"status",
|
|
389
420
|
"limits",
|
|
421
|
+
"budget",
|
|
422
|
+
"budget 8",
|
|
423
|
+
"budget 16",
|
|
424
|
+
"budget 32",
|
|
425
|
+
"budget 64",
|
|
426
|
+
"budget reset",
|
|
390
427
|
"timeout",
|
|
391
428
|
"timeout 5",
|
|
392
429
|
"timeout 10",
|
|
@@ -408,7 +445,7 @@ export function createDelegationExtension(
|
|
|
408
445
|
currentContext = ctx;
|
|
409
446
|
const [action = "status", id, extra] = args.trim().split(/\s+/u).filter(Boolean);
|
|
410
447
|
try {
|
|
411
|
-
if (extra || (id && !["cancel", "timeout"].includes(action))) {
|
|
448
|
+
if (extra || (id && !["cancel", "timeout", "budget"].includes(action))) {
|
|
412
449
|
throw new DelegationError(USAGE);
|
|
413
450
|
}
|
|
414
451
|
|
|
@@ -434,7 +471,36 @@ export function createDelegationExtension(
|
|
|
434
471
|
|
|
435
472
|
syncActiveTool(true);
|
|
436
473
|
ctx.ui.notify(
|
|
437
|
-
`Delegation enabled: Pi child sessions for review and scout, ${state.model.provider}/${state.model.id}, thinking ${state.model.thinkingLevel ?? "Pi configured"}. At most
|
|
474
|
+
`Delegation enabled: Pi child sessions for review and scout, ${state.model.provider}/${state.model.id}, thinking ${state.model.thinkingLevel ?? "Pi configured"}. At most ${state.limits.concurrency} workers, ${state.limits.sessionBatches} batches and ${state.limits.sessionCalls} SDK inference calls per Pi process; ${state.limits.jobMs / 60_000} minutes per job, ${state.limits.batchMs / 60_000} minutes per batch. Configure /delegate budget and /delegate timeout while off (saved across restarts). Workers see only supplied text and selected snapshots. Pi owns configured authentication; temporary parent provider/auth overrides and parent hooks are not inherited. No shell, edits, recursion, automatic provider retries or compaction. Workers can correct tool arguments and reports within their existing budgets. /delegate off cancels workers; SDK requests still settling retain their slots. These limits do not guarantee remote termination or a billing cap.`,
|
|
475
|
+
"info",
|
|
476
|
+
);
|
|
477
|
+
} else if (action === "budget") {
|
|
478
|
+
if (id) {
|
|
479
|
+
if (!ctx.hasUI) {
|
|
480
|
+
throw new DelegationError("Budget changes require a human interactive command");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (requested || pending || controller.status().enabled) {
|
|
484
|
+
throw new DelegationError(
|
|
485
|
+
"Turn delegation off before changing its budget: /delegate off",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const multiplier =
|
|
490
|
+
id === "reset" ? DEFAULT_BUDGET_MULTIPLIER : /^\d+$/u.test(id) ? Number(id) : NaN;
|
|
491
|
+
budgetLimits(multiplier);
|
|
492
|
+
if (!timeoutStore?.saveBudget) {
|
|
493
|
+
throw new DelegationError("Persistent delegation settings are unavailable");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
timeoutStore.saveBudget(multiplier);
|
|
497
|
+
controller.setBudgetMultiplier(multiplier);
|
|
498
|
+
} else {
|
|
499
|
+
loadTimeout();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
ctx.ui.notify(
|
|
503
|
+
`${readableLimits(status())}\n${id ? "Budget saved. " : ""}Use /delegate budget <multiplier> (1–64; default 8) while off. Counts, source-output bytes and model-context bytes scale together; spent usage and deadlines do not reset.`,
|
|
438
504
|
"info",
|
|
439
505
|
);
|
|
440
506
|
} else if (action === "timeout") {
|
|
@@ -496,7 +562,7 @@ export function createDelegationExtension(
|
|
|
496
562
|
name: "delegate",
|
|
497
563
|
label: "Delegate",
|
|
498
564
|
description:
|
|
499
|
-
"Delegate an independent frozen review or substantial selected-source analysis to a real Pi child session.
|
|
565
|
+
"Delegate an independent frozen review or substantial selected-source analysis to a real Pi child session. Enabled by default at Pi startup; respect /delegate off and safety revocations. review: check artifacts against assigned requirements in fresh context; scout: answer a distinct evidence question over selected sources. Prefer one worker and parent-only execution for small, sequential or routine work. No shell, edits or live web. run returns immediately; collect waits for advisory evidence, then resolve findings after verification. One changed-input follow_up shares the original budget/deadline. Never grants permission or proves task completion.",
|
|
500
566
|
parameters: DELEGATE_SCHEMA,
|
|
501
567
|
...(presentation ? createToolRenderers(presentation) : {}),
|
|
502
568
|
execute: async (_id, input, signal, _update, ctx) => {
|
|
@@ -7,7 +7,7 @@ import { DelegationError } from "./errors.mjs";
|
|
|
7
7
|
import { createTimeoutStore } from "./settings.mjs";
|
|
8
8
|
|
|
9
9
|
const stateKey = Symbol.for("specpi.delegation.native.v1");
|
|
10
|
-
const revision =
|
|
10
|
+
const revision = 9;
|
|
11
11
|
|
|
12
12
|
export async function withPiCompatibility(sdk, loadCompatibility) {
|
|
13
13
|
if (typeof sdk.clampThinkingLevel === "function") {
|
|
@@ -4,6 +4,25 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
export const DEFAULT_TIMEOUT_MINUTES = 10;
|
|
5
5
|
export const MAX_TIMEOUT_MINUTES = 60;
|
|
6
6
|
export const MAX_JOB_MS = MAX_TIMEOUT_MINUTES * 60_000;
|
|
7
|
+
export const DEFAULT_BUDGET_MULTIPLIER = 8;
|
|
8
|
+
export const MAX_BUDGET_MULTIPLIER = 64;
|
|
9
|
+
|
|
10
|
+
export function budgetLimits(multiplier) {
|
|
11
|
+
if (!Number.isSafeInteger(multiplier) || multiplier < 1 || multiplier > MAX_BUDGET_MULTIPLIER) {
|
|
12
|
+
throw new DelegationError(`Delegation budget must be a whole multiplier from 1 to ${MAX_BUDGET_MULTIPLIER}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
sessionBatches: 4 * multiplier,
|
|
17
|
+
sessionCalls: 32 * multiplier,
|
|
18
|
+
batchCalls: 8 * multiplier,
|
|
19
|
+
jobCalls: 4 * multiplier,
|
|
20
|
+
toolBytes: 64 * 1024 * multiplier,
|
|
21
|
+
toolCalls: 12 * multiplier,
|
|
22
|
+
contextBytes: 256 * 1024 * multiplier,
|
|
23
|
+
retainedResponseBytes: 128 * 1024 * multiplier,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
7
26
|
|
|
8
27
|
export function timeoutLimits(minutes) {
|
|
9
28
|
if (!Number.isSafeInteger(minutes) || minutes < 1 || minutes > MAX_TIMEOUT_MINUTES) {
|
|
@@ -15,18 +34,12 @@ export function timeoutLimits(minutes) {
|
|
|
15
34
|
|
|
16
35
|
export const LIMITS = Object.freeze({
|
|
17
36
|
concurrency: 2,
|
|
18
|
-
sessionBatches: 4,
|
|
19
|
-
sessionCalls: 32,
|
|
20
37
|
batchJobs: 2,
|
|
21
|
-
|
|
22
|
-
jobCalls: 4,
|
|
38
|
+
...budgetLimits(DEFAULT_BUDGET_MULTIPLIER),
|
|
23
39
|
...timeoutLimits(DEFAULT_TIMEOUT_MINUTES),
|
|
24
|
-
|
|
25
|
-
retainedResponseBytes: 256 * 1024,
|
|
26
|
-
toolBytes: 64 * 1024,
|
|
27
|
-
toolCalls: 12,
|
|
40
|
+
packetBytes: 256 * 1024,
|
|
28
41
|
resultBytes: 16 * 1024,
|
|
29
|
-
outputTokens:
|
|
42
|
+
outputTokens: null,
|
|
30
43
|
});
|
|
31
44
|
|
|
32
45
|
export function bytes(value) {
|
|
@@ -162,7 +175,7 @@ function validatePacketShape(packet) {
|
|
|
162
175
|
if (
|
|
163
176
|
!packet.jobs.length ||
|
|
164
177
|
new Set(packet.jobs.map((job) => job.id)).size !== packet.jobs.length ||
|
|
165
|
-
bytes(packet) > LIMITS.
|
|
178
|
+
bytes(packet) > LIMITS.packetBytes
|
|
166
179
|
) {
|
|
167
180
|
throw new DelegationError("Delegation packet exceeds its bound or repeats job identifiers");
|
|
168
181
|
}
|
|
@@ -200,7 +213,7 @@ export function validateOperation(input) {
|
|
|
200
213
|
operations[input.operation],
|
|
201
214
|
operations[input.operation].filter((key) => !optional.includes(key)),
|
|
202
215
|
);
|
|
203
|
-
if (bytes(input) > LIMITS.
|
|
216
|
+
if (bytes(input) > LIMITS.packetBytes + 4096) {
|
|
204
217
|
throw new DelegationError("Delegation operation exceeds its bound");
|
|
205
218
|
}
|
|
206
219
|
|