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.
@@ -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 remains disabled until
8
- the human runs `/delegate on`. Compatibility is checked through required public SDK
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 human activation; an installed
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
- After the human runs `/delegate on`, the parent calls the `delegate` tool:
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 fail the attempt. There is no automatic retry. Provider exceptions
172
- are reduced to a generic failure message because raw errors may contain sensitive URLs
173
- or content. Missing usage is explicit; failed calls still consume invocation allowance.
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 20 entries. Replaying a retained request returns its stored
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. Its ceilings are two active workers, four batches and 32 SDK model invocations
245
- per process, with two jobs and 8 invocations per batch and four invocations per logical
246
- job including one follow-up. Requested output is 8,192 tokens clamped to the model
247
- maximum. These are experiment limits, not research-derived optimal values.
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's activation choice. The extension
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
- if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
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
- "Worker failed or exhausted a bound; inspect status and use one changed-input follow-up if appropriate.",
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
- // (at most 4 runs + 8 follow-ups + 8 final dispositions). Never evict them.
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 = "Usage: /delegate [on|off|status|limits|timeout [<minutes>|reset]|cancel <batchId>]";
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: strings,
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
- controller.setTimeoutMinutes(timeoutStore.load());
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: "Enable read-only delegation, inspect limits, or save timeout <minutes> (1–60; reset: 10)",
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 2 workers, 4 batches and 32 SDK inference calls per Pi process; ${state.limits.jobMs / 60_000} minutes per job, ${state.limits.batchMs / 60_000} minutes per batch. Configure with /delegate timeout <minutes> 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 retries or compaction. /delegate off cancels workers; SDK requests still settling retain their slots. These limits do not guarantee remote termination or a billing cap.`,
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. Only after human /delegate on. 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.",
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 = 8;
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
- batchCalls: 8,
22
- jobCalls: 4,
38
+ ...budgetLimits(DEFAULT_BUDGET_MULTIPLIER),
23
39
  ...timeoutLimits(DEFAULT_TIMEOUT_MINUTES),
24
- contextBytes: 256 * 1024,
25
- retainedResponseBytes: 256 * 1024,
26
- toolBytes: 64 * 1024,
27
- toolCalls: 12,
40
+ packetBytes: 256 * 1024,
28
41
  resultBytes: 16 * 1024,
29
- outputTokens: 8192,
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.contextBytes
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.contextBytes + 4096) {
216
+ if (bytes(input) > LIMITS.packetBytes + 4096) {
204
217
  throw new DelegationError("Delegation operation exceeds its bound");
205
218
  }
206
219