specpi 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 +9 -0
- package/README.md +30 -202
- package/SECURITY_MODEL.md +32 -3
- package/THIRD_PARTY.md +3 -1
- package/docs/delegation/README.md +62 -9
- package/docs/delegation/protocol.md +24 -8
- package/extensions/delegation/core.mjs +37 -6
- package/extensions/delegation/errors.mjs +64 -0
- package/extensions/delegation/extension.mjs +53 -7
- 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
|
@@ -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,
|
|
@@ -137,7 +142,13 @@ export function createDelegationExtension(
|
|
|
137
142
|
let timeoutLoaded = !timeoutStore;
|
|
138
143
|
const loadTimeout = () => {
|
|
139
144
|
if (!timeoutLoaded) {
|
|
140
|
-
|
|
145
|
+
const minutes = timeoutStore.load();
|
|
146
|
+
const multiplier = timeoutStore.loadBudget?.();
|
|
147
|
+
if (multiplier !== undefined) {
|
|
148
|
+
controller.setBudgetMultiplier(multiplier);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
controller.setTimeoutMinutes(minutes);
|
|
141
152
|
timeoutLoaded = true;
|
|
142
153
|
}
|
|
143
154
|
};
|
|
@@ -400,13 +411,19 @@ export function createDelegationExtension(
|
|
|
400
411
|
});
|
|
401
412
|
pi.registerCommand("delegate", {
|
|
402
413
|
description:
|
|
403
|
-
"Control
|
|
414
|
+
"Control delegation, inspect limits, or save budget <multiplier> (1–64; default 8) and timeout <minutes> (1–60; default 10)",
|
|
404
415
|
getArgumentCompletions: (prefix) =>
|
|
405
416
|
[
|
|
406
417
|
"on",
|
|
407
418
|
"off",
|
|
408
419
|
"status",
|
|
409
420
|
"limits",
|
|
421
|
+
"budget",
|
|
422
|
+
"budget 8",
|
|
423
|
+
"budget 16",
|
|
424
|
+
"budget 32",
|
|
425
|
+
"budget 64",
|
|
426
|
+
"budget reset",
|
|
410
427
|
"timeout",
|
|
411
428
|
"timeout 5",
|
|
412
429
|
"timeout 10",
|
|
@@ -428,7 +445,7 @@ export function createDelegationExtension(
|
|
|
428
445
|
currentContext = ctx;
|
|
429
446
|
const [action = "status", id, extra] = args.trim().split(/\s+/u).filter(Boolean);
|
|
430
447
|
try {
|
|
431
|
-
if (extra || (id && !["cancel", "timeout"].includes(action))) {
|
|
448
|
+
if (extra || (id && !["cancel", "timeout", "budget"].includes(action))) {
|
|
432
449
|
throw new DelegationError(USAGE);
|
|
433
450
|
}
|
|
434
451
|
|
|
@@ -454,7 +471,36 @@ export function createDelegationExtension(
|
|
|
454
471
|
|
|
455
472
|
syncActiveTool(true);
|
|
456
473
|
ctx.ui.notify(
|
|
457
|
-
`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.`,
|
|
458
504
|
"info",
|
|
459
505
|
);
|
|
460
506
|
} else if (action === "timeout") {
|
|
@@ -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
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { DelegationError } from "./errors.mjs";
|
|
2
|
-
import { MAX_JOB_MS } from "./protocol.mjs";
|
|
1
|
+
import { DelegationError, workerFailure } from "./errors.mjs";
|
|
2
|
+
import { MAX_JOB_MS, MAX_BUDGET_MULTIPLIER, budgetLimits } from "./protocol.mjs";
|
|
3
3
|
|
|
4
4
|
const MAX_CONTEXT_BYTES = 256 * 1024;
|
|
5
|
-
const
|
|
5
|
+
const MAX_RUN_CONTEXT_BYTES = budgetLimits(MAX_BUDGET_MULTIPLIER).contextBytes;
|
|
6
|
+
const MAX_RETAINED_RESPONSE_BYTES = budgetLimits(MAX_BUDGET_MULTIPLIER).retainedResponseBytes;
|
|
6
7
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
7
8
|
const MODEL_FIELDS = [
|
|
8
9
|
"id",
|
|
@@ -35,16 +36,16 @@ export function getPiSessionCompatibilityError(sdk) {
|
|
|
35
36
|
: undefined;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
function bounded(value, maximum = MAX_CONTEXT_BYTES) {
|
|
39
|
+
function bounded(value, maximum = MAX_CONTEXT_BYTES, stage) {
|
|
39
40
|
let serialized;
|
|
40
41
|
try {
|
|
41
42
|
serialized = JSON.stringify(value);
|
|
42
43
|
} catch {
|
|
43
|
-
throw new DelegationError("Delegation data is not serializable");
|
|
44
|
+
throw stage ? workerFailure(stage) : new DelegationError("Delegation data is not serializable");
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
if (typeof serialized !== "string" || Buffer.byteLength(serialized, "utf8") > maximum) {
|
|
47
|
-
throw new DelegationError("Delegation data exceeds its retained data allowance");
|
|
48
|
+
throw stage ? workerFailure(stage) : new DelegationError("Delegation data exceeds its retained data allowance");
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
return serialized;
|
|
@@ -306,7 +307,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
306
307
|
|
|
307
308
|
const check = (streaming = false) => {
|
|
308
309
|
if (!current(streaming)) {
|
|
309
|
-
throw
|
|
310
|
+
throw workerFailure("lease");
|
|
310
311
|
}
|
|
311
312
|
|
|
312
313
|
if (
|
|
@@ -459,7 +460,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
459
460
|
active.controls.signal.aborted ||
|
|
460
461
|
Date.now() >= active.controls.deadline
|
|
461
462
|
) {
|
|
462
|
-
throw
|
|
463
|
+
throw workerFailure("lease");
|
|
463
464
|
}
|
|
464
465
|
|
|
465
466
|
active.controls.assertLive(streaming);
|
|
@@ -478,7 +479,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
478
479
|
try {
|
|
479
480
|
assertRun();
|
|
480
481
|
if (!names.includes(call.toolCall.name)) {
|
|
481
|
-
throw
|
|
482
|
+
throw workerFailure("policy");
|
|
482
483
|
}
|
|
483
484
|
|
|
484
485
|
const result = await beforeToolCall?.call(session.agent, call, signal);
|
|
@@ -499,27 +500,30 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
499
500
|
if (
|
|
500
501
|
requestModel !== childModel ||
|
|
501
502
|
!sameValue(descriptor(requestModel), selected) ||
|
|
502
|
-
context.tools?.some((tool) => !names.includes(tool.name))
|
|
503
|
-
context.messages.some((message) => message.role === "toolResult" && message.isError)
|
|
503
|
+
context.tools?.some((tool) => !names.includes(tool.name))
|
|
504
504
|
) {
|
|
505
|
-
throw
|
|
505
|
+
throw workerFailure("policy");
|
|
506
506
|
}
|
|
507
507
|
|
|
508
|
-
bounded(context, run.controls.limits.contextBytes);
|
|
508
|
+
bounded(context, run.controls.limits.contextBytes, "context");
|
|
509
509
|
run.controls.admitCall();
|
|
510
510
|
const requestOptions = {
|
|
511
511
|
...options,
|
|
512
512
|
signal: AbortSignal.any(
|
|
513
513
|
[options.signal, run.controls.signal, run.controller.signal].filter(Boolean),
|
|
514
514
|
),
|
|
515
|
-
|
|
515
|
+
// null uses Pi's normal provider/model output and thinking budget.
|
|
516
|
+
maxTokens:
|
|
517
|
+
run.controls.limits.outputTokens === null
|
|
518
|
+
? options.maxTokens
|
|
519
|
+
: Math.min(childModel.maxTokens, run.controls.limits.outputTokens),
|
|
516
520
|
maxRetries: 0,
|
|
517
521
|
timeoutMs: Math.max(1, Math.min(MAX_JOB_MS, run.controls.deadline - Date.now())),
|
|
518
522
|
onPayload: async (payload, providerModel) => {
|
|
519
523
|
assertRun();
|
|
520
524
|
const result = (await options.onPayload?.(payload, providerModel)) ?? payload;
|
|
521
525
|
assertRun();
|
|
522
|
-
bounded(result, run.controls.limits.contextBytes);
|
|
526
|
+
bounded(result, run.controls.limits.contextBytes, "context");
|
|
523
527
|
|
|
524
528
|
return result;
|
|
525
529
|
},
|
|
@@ -536,7 +540,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
536
540
|
.then(
|
|
537
541
|
(result) => {
|
|
538
542
|
run.controls.onUsage(result?.usage);
|
|
539
|
-
bounded(result, run.controls.limits.retainedResponseBytes);
|
|
543
|
+
bounded(result, run.controls.limits.retainedResponseBytes, "response");
|
|
540
544
|
if (
|
|
541
545
|
result?.role !== "assistant" ||
|
|
542
546
|
!Array.isArray(result.content) ||
|
|
@@ -544,9 +548,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
544
548
|
(part) => part.type === "toolCall" && !names.includes(part.name),
|
|
545
549
|
)
|
|
546
550
|
) {
|
|
547
|
-
throw
|
|
548
|
-
"Provider returned an invalid response or unavailable tool",
|
|
549
|
-
);
|
|
551
|
+
throw workerFailure("response");
|
|
550
552
|
}
|
|
551
553
|
|
|
552
554
|
return result;
|
|
@@ -575,7 +577,11 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
575
577
|
try {
|
|
576
578
|
for await (const event of stream) {
|
|
577
579
|
assertRun(event.type?.endsWith("_delta") === true);
|
|
578
|
-
|
|
580
|
+
try {
|
|
581
|
+
inspectEvent(event);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
throw workerFailure("stream", error);
|
|
584
|
+
}
|
|
579
585
|
|
|
580
586
|
yield event;
|
|
581
587
|
}
|
|
@@ -621,14 +627,15 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
621
627
|
["assertLive", "admitCall", "onUsage", "abort"].some(
|
|
622
628
|
(key) => typeof controls[key] !== "function",
|
|
623
629
|
) ||
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
630
|
+
(controls.limits?.outputTokens !== null &&
|
|
631
|
+
(!Number.isSafeInteger(controls.limits?.outputTokens) ||
|
|
632
|
+
controls.limits.outputTokens < 1)) ||
|
|
627
633
|
["contextBytes", "retainedResponseBytes"].some(
|
|
628
634
|
(key) =>
|
|
629
635
|
!Number.isInteger(controls.limits[key]) ||
|
|
630
636
|
controls.limits[key] < 1 ||
|
|
631
|
-
controls.limits[key] >
|
|
637
|
+
controls.limits[key] >
|
|
638
|
+
(key === "contextBytes" ? MAX_RUN_CONTEXT_BYTES : MAX_RETAINED_RESPONSE_BYTES),
|
|
632
639
|
)
|
|
633
640
|
) {
|
|
634
641
|
throw new DelegationError("Invalid bounded delegation session run");
|
|
@@ -636,13 +643,13 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
636
643
|
|
|
637
644
|
const run = { controls, controller: new AbortController(), pending: [], failure: undefined };
|
|
638
645
|
active = run;
|
|
639
|
-
const cancel = () => fail(
|
|
646
|
+
const cancel = () => fail(workerFailure("lease"));
|
|
640
647
|
controls.signal.addEventListener("abort", cancel, { once: true });
|
|
641
648
|
const timer = setTimeout(cancel, Math.max(1, Math.min(MAX_JOB_MS, controls.deadline - Date.now())));
|
|
642
649
|
timer.unref?.();
|
|
643
650
|
try {
|
|
644
651
|
assertRun();
|
|
645
|
-
bounded(prompt, controls.limits.contextBytes);
|
|
652
|
+
bounded(prompt, controls.limits.contextBytes, "context");
|
|
646
653
|
await session.prompt(prompt, { expandPromptTemplates: false });
|
|
647
654
|
await Promise.allSettled(run.pending);
|
|
648
655
|
if (run.failure) {
|
|
@@ -656,12 +663,14 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
656
663
|
!Array.isArray(result.content) ||
|
|
657
664
|
!["stop", "length"].includes(result.stopReason)
|
|
658
665
|
) {
|
|
659
|
-
throw
|
|
666
|
+
throw workerFailure("incomplete");
|
|
660
667
|
}
|
|
661
668
|
|
|
662
|
-
bounded(result, controls.limits.retainedResponseBytes);
|
|
669
|
+
bounded(result, controls.limits.retainedResponseBytes, "response");
|
|
663
670
|
|
|
664
671
|
return result;
|
|
672
|
+
} catch (error) {
|
|
673
|
+
throw workerFailure("provider", run.failure ?? error);
|
|
665
674
|
} finally {
|
|
666
675
|
clearTimeout(timer);
|
|
667
676
|
controls.signal.removeEventListener("abort", cancel);
|
|
@@ -679,7 +688,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
679
688
|
|
|
680
689
|
released = true;
|
|
681
690
|
if (active) {
|
|
682
|
-
fail(
|
|
691
|
+
fail(workerFailure("lease"));
|
|
683
692
|
} else {
|
|
684
693
|
dispose();
|
|
685
694
|
}
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
-
import { DEFAULT_TIMEOUT_MINUTES, timeoutLimits } from "./protocol.mjs";
|
|
5
|
+
import { DEFAULT_TIMEOUT_MINUTES, DEFAULT_BUDGET_MULTIPLIER, timeoutLimits, budgetLimits } from "./protocol.mjs";
|
|
6
6
|
import { DelegationError } from "./errors.mjs";
|
|
7
7
|
|
|
8
8
|
const MAX_SETTINGS_BYTES = 4096;
|
|
@@ -94,18 +94,59 @@ export function createTimeoutStore(
|
|
|
94
94
|
if (
|
|
95
95
|
!settings ||
|
|
96
96
|
settings.schema !== 1 ||
|
|
97
|
-
Object.keys(settings).
|
|
97
|
+
Object.keys(settings).some((key) => !["schema", "timeoutMinutes", "budgetMultiplier"].includes(key)) ||
|
|
98
98
|
!Object.hasOwn(settings, "timeoutMinutes")
|
|
99
99
|
) {
|
|
100
100
|
throw new Error("Invalid settings schema");
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
timeoutLimits(settings.timeoutMinutes);
|
|
104
|
+
if (Object.hasOwn(settings, "budgetMultiplier")) {
|
|
105
|
+
budgetLimits(settings.budgetMultiplier);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { content, minutes: settings.timeoutMinutes, budgetMultiplier: settings.budgetMultiplier };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const save = (patch) => {
|
|
112
|
+
const previous = read();
|
|
113
|
+
const { dir, file } = location();
|
|
114
|
+
directory(dir, true);
|
|
115
|
+
if (previous) {
|
|
116
|
+
atomicWrite(
|
|
117
|
+
`${file}.bak`,
|
|
118
|
+
`${JSON.stringify({ sha256: createHash("sha256").update(previous.content).digest("hex"), content: previous.content })}\n`,
|
|
119
|
+
MAX_BACKUP_BYTES,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
104
122
|
|
|
105
|
-
|
|
123
|
+
const settings = {
|
|
124
|
+
schema: 1,
|
|
125
|
+
timeoutMinutes: previous?.minutes ?? DEFAULT_TIMEOUT_MINUTES,
|
|
126
|
+
...(previous?.budgetMultiplier === undefined ? {} : { budgetMultiplier: previous.budgetMultiplier }),
|
|
127
|
+
...patch,
|
|
128
|
+
};
|
|
129
|
+
atomicWrite(file, `${JSON.stringify(settings)}\n`);
|
|
106
130
|
};
|
|
107
131
|
|
|
108
132
|
return {
|
|
133
|
+
loadBudget() {
|
|
134
|
+
try {
|
|
135
|
+
return read()?.budgetMultiplier ?? DEFAULT_BUDGET_MULTIPLIER;
|
|
136
|
+
} catch {
|
|
137
|
+
throw new DelegationError(
|
|
138
|
+
"Cannot read delegation budget settings; check the owned settings file and restart Pi.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
saveBudget(multiplier) {
|
|
143
|
+
budgetLimits(multiplier);
|
|
144
|
+
try {
|
|
145
|
+
save({ budgetMultiplier: multiplier });
|
|
146
|
+
} catch {
|
|
147
|
+
throw new DelegationError("Cannot save delegation budget settings; the active budget is unchanged.");
|
|
148
|
+
}
|
|
149
|
+
},
|
|
109
150
|
load() {
|
|
110
151
|
try {
|
|
111
152
|
return read()?.minutes ?? DEFAULT_TIMEOUT_MINUTES;
|
|
@@ -118,18 +159,7 @@ export function createTimeoutStore(
|
|
|
118
159
|
save(minutes) {
|
|
119
160
|
timeoutLimits(minutes);
|
|
120
161
|
try {
|
|
121
|
-
|
|
122
|
-
const { dir, file } = location();
|
|
123
|
-
directory(dir, true);
|
|
124
|
-
if (previous) {
|
|
125
|
-
atomicWrite(
|
|
126
|
-
`${file}.bak`,
|
|
127
|
-
`${JSON.stringify({ sha256: createHash("sha256").update(previous.content).digest("hex"), content: previous.content })}\n`,
|
|
128
|
-
MAX_BACKUP_BYTES,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
atomicWrite(file, `${JSON.stringify({ schema: 1, timeoutMinutes: minutes })}\n`);
|
|
162
|
+
save({ timeoutMinutes: minutes });
|
|
133
163
|
} catch {
|
|
134
164
|
throw new DelegationError(
|
|
135
165
|
"Cannot save delegation settings. Check <agent-dir>/specpi/delegation/settings.json and its permissions; the active timeout is unchanged.",
|