pi-background-tasks 1.0.4 → 1.0.7

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.
@@ -5,8 +5,9 @@ mode: mixed
5
5
  review_policy: behavioral
6
6
  stability: stable
7
7
  covers_surfaces: [renderer:fusion-result, workflow:investigate, workflow:reason, workflow:research, workflow:validate]
8
- covers_sources: [extensions/fusion-child.ts, src/core/fusion/artifacts.ts, src/core/fusion/budget.ts, src/core/fusion/child-protocol.ts, src/core/fusion/clean-context.ts, src/core/fusion/config.ts, src/core/fusion/context.ts, src/core/fusion/evaluation.ts, src/core/fusion/orchestrator.ts, src/core/fusion/pi-child.ts, src/core/fusion/prompts.ts, src/core/fusion/source-policy.ts, src/core/fusion/types.ts, src/core/fusion/web-fetch.ts, src/core/fusion/workflows.ts, src/fusion-child-extension.ts, src/fusion-extension.ts, src/ui/fusion-model-selector.ts]
8
+ covers_sources: [extensions/fusion-child.ts, src/core/fusion/artifacts.ts, src/core/fusion/budget.ts, src/core/fusion/child-protocol.ts, src/core/fusion/claude-cache.ts, src/core/fusion/clean-context.ts, src/core/fusion/config.ts, src/core/fusion/context.ts, src/core/fusion/evaluation.ts, src/core/fusion/orchestrator.ts, src/core/fusion/pi-child.ts, src/core/fusion/prompts.ts, src/core/fusion/source-policy.ts, src/core/fusion/types.ts, src/core/fusion/web-fetch.ts, src/core/fusion/workflows.ts, src/fusion-child-extension.ts, src/fusion-extension.ts, src/ui/fusion-model-selector.ts]
9
9
  ---
10
+
10
11
  # Fusion subsystem
11
12
 
12
13
  <!-- pi-docs:begin name="fusion-workflows" generator="scripts/docs/generate.mjs" -->
@@ -60,18 +61,18 @@ Do not describe Fusion as unconditionally exactly five model calls. A completed
60
61
 
61
62
  Candidate tool policies are fixed by workflow:
62
63
 
63
- | Workflow | Candidate capability | Candidate tools |
64
- |---|---:|---|
65
- | reason | `reason` | none (`--no-tools`) |
66
- | investigate | `inspect` | `read`, `grep`, `find`, `ls` |
67
- | research | `research` | `read`, `grep`, `find`, `ls`, `fusion_web_fetch` |
68
- | validate | `inspect` | `read`, `grep`, `find`, `ls` |
64
+ | Workflow | Candidate capability | Candidate tools |
65
+ | ----------- | -------------------: | ------------------------------------------------ |
66
+ | reason | `reason` | none (`--no-tools`) |
67
+ | investigate | `inspect` | `read`, `grep`, `find`, `ls` |
68
+ | research | `research` | `read`, `grep`, `find`, `ls`, `fusion_web_fetch` |
69
+ | validate | `inspect` | `read`, `grep`, `find`, `ls` |
69
70
 
70
71
  Evaluator, evaluator-repair, and merger always use capability `reason` and empty tool lists. Tool-enabled children run with built-in tools disabled and an explicit allowlist plus a denylist that includes shell/write/edit, Fusion recursion, and background/delegate tools.
71
72
 
72
73
  ## Validation specifics
73
74
 
74
- `fusion_validate` enforces a strict public verification contract: `provided` requires non-empty evidence and no reason; `not_run` requires a reason and empty/omitted evidence. Reviewers return closed candidate-report JSON. The host assigns stable finding ids after anonymization, the evaluator must account for every source finding exactly once, and the host renders the final report from validated accounting after the merger. Validation is advisory and read-only: it never edits files, runs tests, gates a release, or replaces builds, linters, scanners, or human review.
75
+ `fusion_validate` enforces a strict public verification contract: `provided` requires non-empty evidence and no reason; `not_run` requires a reason and empty/omitted evidence. Reviewers return exactly one bare, closed candidate-report JSON object. The host keeps its shared JSON parser strict; a single complete `json` fence can be removed only by the validation-specific audited recovery path, which writes a contract-event artifact and surfaces a limitation. One irrecoverable minority report is also recorded and surfaced as a limitation, while two invalid reports fail the workflow. The host assigns stable finding ids after anonymization, the evaluator must account for every source finding exactly once, and the host renders the final report from validated accounting after the merger. Validation is advisory and read-only: it never edits files, runs tests, gates a release, or replaces builds, linters, scanners, or human review.
75
76
 
76
77
  ## Research specifics
77
78
 
@@ -81,21 +82,23 @@ Research is targeted fetch, not search. The public caller declares exact non-dup
81
82
 
82
83
  Research intentionally combines read-only file tools and network fetch in one child. This supports source-backed synthesis but is security-sensitive: operators must not supply secret-bearing URLs or ask children to put private data in URL strings. The package blocks common SSRF targets and credential URLs, but its deny rules are not an exhaustive network sandbox; fetched content remains untrusted and caller-declared public URLs can still disclose access through remote logs/timing.
83
84
 
84
- Inspect/research candidates write sealed tool-call audit logs. The log contains schema version, ordinal, tool name, argument/result byte counts and SHA-256 digests, status, duration, and fetch provenance. Raw arguments, raw results, page content, and rejected raw URLs are not persisted. The parent requires the log and seal, verifies hashes/counts/ordinals/status, enforces the 8 MiB aggregate result-byte cap, and rejects non-allowlisted tools.
85
+ Inspect/research candidates write sealed tool-call audit logs. The log contains schema version, ordinal, tool name, argument/result byte counts and SHA-256 digests, status, duration, and fetch provenance. Raw arguments, raw results, page content, and rejected raw URLs are not persisted. The parent requires the log and seal, verifies hashes/counts/ordinals/status, enforces the 8 MiB aggregate result-byte cap, and rejects non-allowlisted tools. A child may attempt at most 192 tool calls; crossing that limit aborts the run, emits structured refusal evidence, and prevents a complete audit seal.
85
86
 
86
87
  ## Child process isolation
87
88
 
88
- Fusion never calls direct completion APIs. It launches direct child `pi --mode text` processes and writes the prompt over stdin. Child argv includes `--no-session`, `--no-extensions`, `--no-skills`, `--no-prompt-templates`, `--no-themes`, and `--no-context-files`; explicit extensions still load, so the package-owned compact metadata extension is always supplied. Anthropic children additionally receive the `@ravshansbox/pi-anthropic-sps` sanitizer extension because discovery is disabled and Claude routes need Pi system-prompt sanitization.
89
+ Fusion never calls direct completion APIs. It launches direct child `pi --mode text` processes and writes the prompt over stdin. Child argv includes `--no-session`, `--no-extensions`, `--no-skills`, `--no-prompt-templates`, `--no-themes`, and `--no-context-files`; explicit extensions still load, so the package-owned compact metadata extension is always supplied. Anthropic children additionally receive the `@ravshansbox/pi-anthropic-sps` sanitizer extension because discovery is disabled and Claude routes need Pi system-prompt sanitization. That dependency only sanitizes rejected system-prompt lines and preserves existing cache fields; it does not choose Fusion's cache policy.
90
+
91
+ Child text mode writes the final full answer to stdout. The private child extension emits compact reasoning-free metadata frames to stderr for finalized assistant messages: provider/model, stop reason, text block byte counts and hashes, aggregate text hash, the complete Pi `Usage` object, and a closed cache-policy observation. It also governs every final `before_provider_request` payload after earlier extensions have transformed it. Claude's sanitizer therefore loads before the package governor. For Anthropic routes, the package normalizes only cache breakpoints already selected by Pi's adapter: default retention is `long` (`ttl: "1h"`), while inherited `PI_CACHE_RETENTION=short|none|long` explicitly selects the policy. Long retention falls back to short when model compatibility rejects it. Existing no-marker payloads remain unmarked so Pi's call-level `cacheRetention="none"` compaction requests are never overridden. Malformed controls, invalid policy values, or more than four breakpoints abort before transport. Non-Anthropic payloads remain byte-equivalent apart from the governor's existing JSON normalization.
89
92
 
90
- Child text mode writes the final full answer to stdout. The private child extension emits compact reasoning-free metadata frames to stderr for finalized assistant messages: provider/model, stop reason, text block byte counts and hashes, aggregate text hash, and the complete Pi `Usage` object. The parent reconstructs and validates stdout against the final metadata, requires final stop reason `stop` and non-final stop reason `toolUse`, verifies model identity, and preserves usage/cost exactly.
93
+ After cache normalization, the governor serializes and hashes the exact payload, applies the shared conservative estimator, reserves the model's declared maximum output plus 4,096 safety tokens, and aborts before transport if the payload cannot fit or if the child exceeds 128 provider requests. Pi's provider-hook behavior is characterized through the same `openai-codex-responses` transport adapter used by subscription Codex routes in a real local HTTP agent loop: transforms chain in extension load order and `ctx.abort()` prevents network transport. Cache observations use `pi-background-tasks.fusion-claude-cache-observation.v1`, state requested/effective retention, source, breakpoint count, and provider-request ordinal, and are hash-bound inside `pi-background-tasks.fusion-child-result.v3` attempt event artifacts. At terminal `agent_settled`, the extension emits exactly one `pi-background-tasks.fusion-child-settlement.v2` frame binding the complete ordered metadata stream by count and SHA-256, the final record/hash, and any recovered retry-marker ordinals. The parent validates closed cache evidence and increasing request ordinals, reconstructs stdout against the final metadata, requires final stop reason `stop`, verifies model identity, and preserves usage/cost exactly. Non-final `toolUse` records remain normal. A non-final `error` is accepted only when it is a zero-content, empty-hash, zero-usage retry marker, a later final `stop` exists, and the terminal settlement hash/accounts for that exact ordinal. `length`, `aborted`, `pending`, final `error`, error records carrying text or usage, missing/duplicate/tampered settlement, and settlement before terminal idleness all fail loudly.
91
94
 
92
95
  Fusion child environments strip session/model/provider variables plus metered credential/base-url variables for OpenRouter, OpenAI, Anthropic, Azure OpenAI, and generic Pi API credentials before launch. Frontier model routes are admitted only when the registry reports subscription OAuth for trusted `anthropic` or `openai-codex` endpoints. There is no fallback, model substitution, endpoint override, or metered API-key route.
93
96
 
94
97
  ## Budgets and output contracts
95
98
 
96
- Budget planning is per route and per stage. Every configured candidate, evaluator, and merger route must have a usable context window. The affine estimator from the shared token-budget layer accounts for byte classes plus a 512-token intercept; backed model-family calibrations are used only where applicable, unknown/unbacked providers are reported in artifacts/result details, and multibyte/dense ASCII diagnostics are preserved.
99
+ Budget planning is per route and per stage. Every configured candidate, evaluator, and merger route must have a usable context window. The affine estimator from the shared token-budget layer accounts for byte classes plus a 512-token intercept; backed model-family calibrations are used only where applicable, unknown/unbacked providers are reported in artifacts/result details, and multibyte/dense ASCII diagnostics are preserved. Post-run calibration compares that one-request forecast only with the first provider request; cumulative agent-loop and cache usage is retained as total usage but is never misclassified as a prompt under-forecast.
97
100
 
98
- `budget-plan.json` records route capacities, stage forecasts for candidate/evaluation/evaluation-repair/merge, conditional repair reservation, warnings, blockers, empty-request counterfactuals, and remediation. Fatal preflight blockers launch zero children. High utilization or worst-case reservation pressure is a warning when input still fits. Exact rendered prompt checks happen again immediately before candidate, evaluation, repair, and merge launches.
101
+ `budget-plan.json` uses `pi-background-tasks.fusion-budget-plan.v4` and records route capacities, stage forecasts for candidate/evaluation/evaluation-repair/merge, conditional repair reservation, warnings, blockers, empty-request counterfactuals, and remediation. Each route reserves the larger of Fusion's 32,768-token output contract reserve and the resolved model's declared maximum output; a model advertising a 128,000-token maximum therefore receives the full 128,000-token reserve. Fatal preflight blockers launch zero children. High utilization or worst-case reservation pressure is a warning when input still fits. Exact rendered prompt checks happen again immediately before candidate, evaluation, repair, and merge launches.
99
102
 
100
103
  Output contracts are checked after durable attempt recording: candidate responses up to 48 KiB JSON-rendered bytes, evaluator up to 64 KiB, merger/final report up to 64 KiB, diagnostics contract 8 KiB, child stdout cap 32 MiB, child stderr cap 4 MiB. Oversized child output fails loudly and preserves evidence; Fusion never clips or silently forwards truncated content.
101
104
 
@@ -105,7 +108,7 @@ Run artifacts are private local evidence under `.pi/fusion/<session-id>-<pid>/<r
105
108
 
106
109
  Artifact writes use durable private temp-file/fsync/rename. Manifests enforce legal state transitions and record config, resolved models, fixed capabilities, context policy, tool policy, anonymous map, attempts, artifact refs, cumulative usage, and errors. Successful, failed, and cancelled observed attempts preserve complete Pi usage/cost components; public tool results clone the same `Usage` shape.
107
110
 
108
- For tool-enabled children, the private audit journal remains open across every low-level `agent_end`, because Pi may still retry, compact and retry, or process a queued continuation. Only terminal `agent_settled` can exclusively publish the complete hash/count/byte seal. Tool activity after finalization, duplicate settlement, pre-settlement shutdown, extension diagnostics, and missing/failed/stale seals are fatal. This lifecycle requires Pi 0.81.1 or newer; older Pi lines do not expose the required terminal event and are not claimed as compatible.
111
+ For tool-enabled children, the private audit journal remains open across every low-level `agent_end`, because Pi may still retry, compact and retry, or process a queued continuation. Only terminal `agent_settled` can exclusively publish the complete hash/count/byte seal. Runtime-guard refusal latches process failure, makes that seal incomplete, and forces the result settlement to failed. The child emits one closed `pi-background-tasks.fusion-runtime-guard.v1` stderr frame containing the refusal code, route capacities, request/tool ordinals, exact payload byte count and SHA-256, conservative token estimate, and a bounded message; it never emits the payload itself. The parent validates this frame and reports typed `child_runtime_budget_exceeded` for runtime capacity/loop refusals or `child_cache_policy_invalid` for Claude cache-policy refusal, instead of accepting a later clean-looking result or reducing it to an unexplained exit code. Tool activity after finalization, duplicate settlement, pre-settlement shutdown, extension diagnostics, malformed/duplicate runtime-guard frames, and missing/failed/stale seals are fatal. This lifecycle requires Pi 0.81.1 or newer; older Pi lines do not expose the required terminal event and are not claimed as compatible.
109
112
 
110
113
  Cancellation and shutdown are loud and durable when a run store exists. The extension tracks active runs, links external abort signals, aborts on session shutdown/reload, and waits for settlement. Child processes have a 30 minute wall timeout, 20 minute idle watchdog, SIGTERM grace, SIGKILL wait, process-group kill on POSIX, bounded stdout/stderr, and cleanup-error propagation.
111
114
 
@@ -116,6 +119,8 @@ Cancellation and shutdown are loud and durable when a run store exists. The exte
116
119
  - Frontier/API route rejected: use Pi Anthropic or Codex subscription OAuth, not OpenAI/OpenRouter/Azure/API-key routes.
117
120
  - `prompt_budget_exceeded_forecast`: inspect `budget-plan.json`; the error says whether shortening the request can help or whether session history/scope/model context window is the blocker.
118
121
  - `prompt_budget_exceeded_measured`: an exact rendered prompt exceeded capacity after upstream output was known; split the workflow or choose a larger-context subscription route.
122
+ - `child_runtime_budget_exceeded`: a later provider payload, provider-request loop, or tool-call loop crossed a child runtime guard after launch. Inspect the attempt stderr guard frame and failed tool seal; narrow the task or select a subscription route with more safe input headroom. Do not ignore intermediate provider errors or weaken the guard.
123
+ - `child_cache_policy_invalid`: `PI_CACHE_RETENTION` or Claude cache-control evidence was malformed. Use exactly `none`, `short`, or `long`; do not remove the final-payload guard.
119
124
  - `evaluation schema repair failed`: both evaluator attempts failed the closed JSON contract; inspect `evaluation.attempt-*.response.txt` and errors.
120
125
  - `tool-call log invalid`: inspect the candidate `*.tool-calls.jsonl` and `*.seal.json`; missing/partial/unsealed logs, non-allowlisted tools, hash/count mismatches, and over-budget tool output fail by design.
121
126
  - Research fetch failures are typed and do not retry via other URLs or extraction modes; verify the declared URL is public, reachable, supported content, and within caps.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background-tasks",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
4
4
  "description": "Pi extension for durable background shell tasks, read-only delegated agents, local attested Pi runs, and fixed-purpose Fusion workflows through child Pi processes.",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -68,6 +68,7 @@
68
68
  "test:hook-contract": "tsx --test --test-concurrency=1 tests/scripted-provider/pi-hook-contract.test.ts",
69
69
  "docs:generate": "node scripts/docs/generate.mjs",
70
70
  "docs:verify": "node scripts/docs/verify.mjs",
71
+ "docs:verify:attestations": "node scripts/docs/verify.mjs --require-attestations",
71
72
  "docs:attest/record": "node scripts/docs/attest.mjs",
72
73
  "docs:attest": "npm run docs:attest/record",
73
74
  "test:docs": "tsx --test tests/unit/docs-gate.test.ts tests/package/docs-contract.test.ts",
@@ -7,6 +7,7 @@ import { replaceFileDurable } from '../durable-fs.js';
7
7
  import {
8
8
  EMPTY_FUSION_USAGE,
9
9
  FUSION_MANIFEST_SCHEMA_VERSION,
10
+ FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION,
10
11
  FusionError,
11
12
  cloneFusionUsage,
12
13
  type FusionArtifactManifest,
@@ -83,14 +84,39 @@ export interface CreateFusionArtifactStoreOptions {
83
84
 
84
85
  export interface RecordFusionChildAttemptInput {
85
86
  result: FusionChildRunResult;
87
+ systemPrompt: string;
86
88
  prompt: string;
87
89
  responseKind: 'md' | 'txt';
88
90
  }
89
91
 
92
+ export type RecordValidationCandidateContractEventInput =
93
+ | {
94
+ candidateId: FusionCandidateId;
95
+ slot: 1 | 2 | 3;
96
+ status: 'normalized';
97
+ detail: {
98
+ normalization: 'markdown_json_fence' | 'prose_then_markdown_json_fence';
99
+ original_sha256: string;
100
+ forwarded_sha256: string;
101
+ warning: string;
102
+ };
103
+ }
104
+ | {
105
+ candidateId: FusionCandidateId;
106
+ slot: 1 | 2 | 3;
107
+ status: 'dropped';
108
+ detail: {
109
+ response_sha256: string;
110
+ error: string;
111
+ warning: string;
112
+ };
113
+ };
114
+
90
115
  export interface RecordFusionFailedAttemptInput {
91
116
  stage: FusionStage;
92
117
  slot?: 1 | 2 | 3;
93
118
  attempt: number;
119
+ systemPrompt: string;
94
120
  prompt: string;
95
121
  events: Buffer;
96
122
  partialResponse: Buffer;
@@ -398,6 +424,7 @@ export class FusionArtifactStore {
398
424
 
399
425
  async recordChildAttempt(input: RecordFusionChildAttemptInput): Promise<void> {
400
426
  const prefix = attemptPrefix(input.result.stage, input.result.slot, input.result.attempt);
427
+ await this.writeArtifact(`${prefix}.system-prompt.txt`, input.systemPrompt);
401
428
  const promptRef = await this.writeArtifact(`${prefix}.prompt.txt`, input.prompt);
402
429
  const eventsRef = await this.writeArtifact(`${prefix}.events.jsonl`, input.result.events);
403
430
  const stderrRef = await this.writeArtifact(`${prefix}.stderr.txt`, input.result.stderr);
@@ -442,8 +469,22 @@ export class FusionArtifactStore {
442
469
  return this.writeArtifact(calibrationViolationName(prefix), `${canonicalJson(input.violation)}\n`);
443
470
  }
444
471
 
472
+ async recordValidationCandidateContractEvent(
473
+ input: RecordValidationCandidateContractEventInput,
474
+ ): Promise<FusionArtifactRef> {
475
+ const name = `candidate-${String(input.slot)}.output-contract-${input.status}.json`;
476
+ return this.writeArtifact(name, `${canonicalJson({
477
+ schema_version: FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION,
478
+ ...input.detail,
479
+ candidate_id: input.candidateId,
480
+ slot: input.slot,
481
+ status: input.status,
482
+ })}\n`);
483
+ }
484
+
445
485
  async recordFailedAttempt(input: RecordFusionFailedAttemptInput): Promise<void> {
446
486
  const prefix = attemptPrefix(input.stage, input.slot, input.attempt);
487
+ await this.writeArtifact(`${prefix}.system-prompt.txt`, input.systemPrompt);
447
488
  const promptRef = await this.writeArtifact(`${prefix}.prompt.txt`, input.prompt);
448
489
  const eventsRef = await this.writeArtifact(`${prefix}.events.jsonl`, input.events);
449
490
  const stderrRef = await this.writeArtifact(`${prefix}.stderr.txt`, input.stderr);
@@ -88,7 +88,8 @@ export const FUSION_MIN_CONTEXT_WINDOW_TOKENS =
88
88
  FUSION_SAFETY_RESERVE_TOKENS;
89
89
 
90
90
  export const FUSION_BUDGET_POLICY: FusionBudgetPolicyDescriptor = {
91
- id: 'fusion-budget-policy-v3',
91
+ id: 'fusion-budget-policy-v4',
92
+ route_output_reserve_strategy: 'max_fusion_contract_or_model_max',
92
93
  calibration_version: TOKEN_BUDGET_CALIBRATION_VERSION,
93
94
  calibration_table: FUSION_CALIBRATED_BYTES_PER_TOKEN,
94
95
  reserved_output_tokens: FUSION_RESERVED_OUTPUT_TOKENS,
@@ -288,14 +289,20 @@ function routeCapacity(
288
289
  role: FusionRouteCapacity['role'],
289
290
  ): FusionRouteCapacity {
290
291
  const contextWindow = requirePositiveContextWindow(model, role);
292
+ const reservedOutputTokens = Math.max(FUSION_RESERVED_OUTPUT_TOKENS, model.maxOutputTokens);
291
293
  const allowed = allowedInputTokens(contextWindow, {
292
- reservedOutputTokens: FUSION_RESERVED_OUTPUT_TOKENS,
294
+ reservedOutputTokens,
293
295
  framingReserveTokens: FUSION_FRAMING_RESERVE_TOKENS,
294
296
  safetyReserveTokens: FUSION_SAFETY_RESERVE_TOKENS,
295
297
  });
296
298
  if (allowed < FUSION_MIN_CANONICAL_INPUT_TOKENS) {
299
+ const minimumContextWindow =
300
+ reservedOutputTokens +
301
+ FUSION_FRAMING_RESERVE_TOKENS +
302
+ FUSION_SAFETY_RESERVE_TOKENS +
303
+ FUSION_MIN_CANONICAL_INPUT_TOKENS;
297
304
  throw new FusionError(
298
- `fusion ${role} route ${model.qualifiedId} has a ${String(contextWindow)}-token context window, but Fusion requires at least ${String(FUSION_MIN_CONTEXT_WINDOW_TOKENS)} tokens per configured route: ${String(FUSION_RESERVED_OUTPUT_TOKENS)} output + ${String(FUSION_FRAMING_RESERVE_TOKENS)} framing + ${String(FUSION_SAFETY_RESERVE_TOKENS)} safety + ${String(FUSION_MIN_CANONICAL_INPUT_TOKENS)} usable input. Choose a larger-context model for this slot with /fusion-models.`,
305
+ `fusion ${role} route ${model.qualifiedId} has a ${String(contextWindow)}-token context window, but Fusion requires at least ${String(minimumContextWindow)} tokens: ${String(reservedOutputTokens)} reserved for the route's configured maximum output + ${String(FUSION_FRAMING_RESERVE_TOKENS)} framing + ${String(FUSION_SAFETY_RESERVE_TOKENS)} safety + ${String(FUSION_MIN_CANONICAL_INPUT_TOKENS)} usable input. Choose a larger-context or lower-max-output subscription model for this slot with /fusion-models.`,
299
306
  { code: 'model_capacity_unknown', childCreated: false },
300
307
  );
301
308
  }
@@ -322,7 +329,7 @@ function routeCapacity(
322
329
  model: model.model,
323
330
  qualified_id: model.qualifiedId,
324
331
  context_window_tokens: contextWindow,
325
- reserved_output_tokens: FUSION_RESERVED_OUTPUT_TOKENS,
332
+ reserved_output_tokens: reservedOutputTokens,
326
333
  framing_reserve_tokens: FUSION_FRAMING_RESERVE_TOKENS,
327
334
  safety_reserve_tokens: FUSION_SAFETY_RESERVE_TOKENS,
328
335
  allowed_input_tokens: allowed,
@@ -1115,7 +1122,13 @@ export class FusionBudget {
1115
1122
  const inputSegments = [knownTextSegment(systemPrompt), knownTextSegment(userPrompt)];
1116
1123
  const promptUtf8Bytes = inputSegments.reduce((sum, segment) => sum + segment.bytes, 0);
1117
1124
  const estimate = estimateRouteInput(route, inputSegments);
1118
- const billedInput = result.usage.input + result.usage.cacheRead + result.usage.cacheWrite;
1125
+ // The forecast is a one-request admission estimate. Compare it only with
1126
+ // the first provider request, never with aggregate agent-loop/cache usage.
1127
+ // Custom child runners predating this observation field remain compatible,
1128
+ // but cannot produce a calibration verdict without like-for-like evidence.
1129
+ const observedUsage = result.firstRequestUsage;
1130
+ if (observedUsage === undefined) return undefined;
1131
+ const billedInput = observedUsage.input + observedUsage.cacheRead + observedUsage.cacheWrite;
1119
1132
  if (billedInput <= estimate.tokens) return undefined;
1120
1133
  const violation: FusionCalibrationViolation = {
1121
1134
  schema_version: FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION,
@@ -1130,12 +1143,14 @@ export class FusionBudget {
1130
1143
  rate_source: estimate.rateSource,
1131
1144
  prompt_utf8_bytes: promptUtf8Bytes,
1132
1145
  prompt_sha256: sha256Hex(`${systemPrompt}\u0000${userPrompt}`),
1146
+ observation_scope: 'first_provider_request',
1147
+ provider_request_count: result.providerRequestCount ?? 1,
1133
1148
  forecast_input_tokens: estimate.tokens,
1134
1149
  billed_input_tokens: billedInput,
1135
1150
  billed_input_breakdown: {
1136
- input: result.usage.input,
1137
- cache_read: result.usage.cacheRead,
1138
- cache_write: result.usage.cacheWrite,
1151
+ input: observedUsage.input,
1152
+ cache_read: observedUsage.cacheRead,
1153
+ cache_write: observedUsage.cacheWrite,
1139
1154
  },
1140
1155
  under_forecast_tokens: billedInput - estimate.tokens,
1141
1156
  byte_class_breakdown: estimate.byte_class_breakdown,
@@ -1,9 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import type { Usage } from '@earendil-works/pi-ai';
3
+ import type { FusionClaudeCacheObservation } from './claude-cache.js';
3
4
 
4
5
  export const FUSION_CHILD_RESULT_SCHEMA_VERSION =
5
- 'pi-background-tasks.fusion-child-result.v2' as const;
6
+ 'pi-background-tasks.fusion-child-result.v3' as const;
6
7
  export const FUSION_CHILD_RESULT_PREFIX = '\u001ePI_FUSION_CHILD_RESULT ';
8
+ export const FUSION_CHILD_SETTLEMENT_SCHEMA_VERSION =
9
+ 'pi-background-tasks.fusion-child-settlement.v2' as const;
10
+ export const FUSION_CHILD_SETTLEMENT_PREFIX = '\u001ePI_FUSION_CHILD_SETTLEMENT ';
7
11
  export const FUSION_TOOL_CALL_LOG_PATH_ENV = 'PI_FUSION_TOOL_CALL_LOG_PATH';
8
12
  export const FUSION_RESEARCH_ENABLED_ENV = 'PI_FUSION_RESEARCH_ENABLED';
9
13
  export const FUSION_SOURCE_POLICY_PATH_ENV = 'PI_FUSION_SOURCE_POLICY_PATH';
@@ -11,17 +15,47 @@ export const FUSION_SOURCE_POLICY_SHA256_ENV = 'PI_FUSION_SOURCE_POLICY_SHA256';
11
15
  export const FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION =
12
16
  'pi-background-tasks.fusion-tool-call-seal.v1' as const;
13
17
  export const FUSION_TOOL_CALL_SEAL_SUFFIX = '.seal.json';
18
+ export const FUSION_RUNTIME_GUARD_SCHEMA_VERSION =
19
+ 'pi-background-tasks.fusion-runtime-guard.v1' as const;
20
+ export const FUSION_RUNTIME_GUARD_PREFIX = '\u001ePI_FUSION_RUNTIME_GUARD ';
21
+ export const FUSION_CHILD_MAX_PROVIDER_REQUESTS = 128;
22
+ export const FUSION_CHILD_MAX_TOOL_CALLS = 192;
23
+ export const FUSION_CHILD_MIN_OUTPUT_RESERVE_TOKENS = 32_768;
24
+ export const FUSION_CHILD_SAFETY_RESERVE_TOKENS = 4_096;
14
25
 
15
26
  /**
16
27
  * Aggregate ceiling on tool-result bytes a single candidate child may accumulate.
17
28
  *
18
- * v1 deliberately has no tool-call-count cap, so this byte budget is the only bound on
19
- * how much a read-only candidate can pull into its context. 8 MiB is generous for
20
- * targeted grep/read investigation while still preventing an unbounded read loop from
21
- * degrading into an opaque provider-side context failure.
29
+ * The byte ceiling complements the runtime provider-payload governor and tool/request
30
+ * count limits. It remains an independent bound on total tool material even when Pi
31
+ * compaction keeps each individual provider request within the route context window.
22
32
  */
23
33
  export const FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES = 8 * 1024 * 1024;
24
34
 
35
+ export type FusionRuntimeGuardCode =
36
+ | 'provider_request_limit'
37
+ | 'provider_request_budget'
38
+ | 'provider_payload_invalid'
39
+ | 'claude_cache_policy'
40
+ | 'tool_call_limit';
41
+
42
+ export interface FusionRuntimeGuardRecord {
43
+ schema_version: typeof FUSION_RUNTIME_GUARD_SCHEMA_VERSION;
44
+ code: FusionRuntimeGuardCode;
45
+ provider: string;
46
+ model: string;
47
+ request_ordinal: number;
48
+ tool_call_count: number;
49
+ payload_bytes: number;
50
+ payload_sha256: string;
51
+ estimated_input_tokens: number;
52
+ context_window_tokens: number;
53
+ reserved_output_tokens: number;
54
+ safety_reserve_tokens: number;
55
+ allowed_input_tokens: number;
56
+ message: string;
57
+ }
58
+
25
59
  export interface FusionChildTextBlockMetadata {
26
60
  utf8_bytes: number;
27
61
  sha256: string;
@@ -37,19 +71,109 @@ export interface FusionChildResultMetadata {
37
71
  text_blocks: FusionChildTextBlockMetadata[];
38
72
  text_sha256: string;
39
73
  usage: FusionChildResultUsageMetadata;
74
+ cache_observation: FusionClaudeCacheObservation;
75
+ }
76
+
77
+ export type FusionChildSettlementFailureReason =
78
+ | 'no_records'
79
+ | 'final_not_stop'
80
+ | 'invalid_non_final'
81
+ | 'runtime_guard'
82
+ | 'cache_observation';
83
+
84
+ export interface FusionChildSettlementRecord {
85
+ schema_version: typeof FUSION_CHILD_SETTLEMENT_SCHEMA_VERSION;
86
+ status: 'complete' | 'failed';
87
+ record_count: number;
88
+ records_sha256: string;
89
+ final_record_index: number | null;
90
+ final_text_sha256: string | null;
91
+ recovered_error_ordinals: number[];
92
+ failure_reason: FusionChildSettlementFailureReason | null;
40
93
  }
41
94
 
42
95
  function protocolSha256(value: string | Buffer): string {
43
96
  return createHash('sha256').update(value).digest('hex');
44
97
  }
45
98
 
46
- export function buildFusionChildResultMetadata(message: {
47
- provider: string;
48
- model: string;
49
- stopReason: string;
50
- content: ReadonlyArray<{ type: string; text?: string }>;
51
- usage: Usage;
52
- }): FusionChildResultMetadata {
99
+ export function serializeFusionChildResultRecords(
100
+ records: readonly FusionChildResultMetadata[],
101
+ ): Buffer {
102
+ return Buffer.from(
103
+ records.length === 0 ? '' : `${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
104
+ 'utf8',
105
+ );
106
+ }
107
+
108
+ function hasZeroUsage(record: FusionChildResultMetadata): boolean {
109
+ const usage = record.usage;
110
+ return (
111
+ usage.input === 0 &&
112
+ usage.output === 0 &&
113
+ usage.cacheRead === 0 &&
114
+ usage.cacheWrite === 0 &&
115
+ usage.totalTokens === 0 &&
116
+ usage.cost.input === 0 &&
117
+ usage.cost.output === 0 &&
118
+ usage.cost.cacheRead === 0 &&
119
+ usage.cost.cacheWrite === 0 &&
120
+ usage.cost.total === 0
121
+ );
122
+ }
123
+
124
+ export function isRecoverableFusionChildErrorRecord(record: FusionChildResultMetadata): boolean {
125
+ return (
126
+ record.stop_reason === 'error' &&
127
+ record.text_blocks.length === 0 &&
128
+ record.text_sha256 === protocolSha256(Buffer.alloc(0)) &&
129
+ hasZeroUsage(record)
130
+ );
131
+ }
132
+
133
+ export function buildFusionChildSettlement(
134
+ records: readonly FusionChildResultMetadata[],
135
+ runtimeGuardFailed = false,
136
+ cacheObservationFailed = false,
137
+ ): FusionChildSettlementRecord {
138
+ const finalRecordIndex = records.length === 0 ? null : records.length - 1;
139
+ const final = records.at(-1);
140
+ const recoveredErrorOrdinals = records.flatMap((record, ordinal) =>
141
+ ordinal < records.length - 1 && isRecoverableFusionChildErrorRecord(record) ? [ordinal] : [],
142
+ );
143
+ const invalidNonFinal = records.some(
144
+ (record, ordinal) =>
145
+ ordinal < records.length - 1 &&
146
+ record.stop_reason !== 'toolUse' &&
147
+ !isRecoverableFusionChildErrorRecord(record),
148
+ );
149
+ let failureReason: FusionChildSettlementFailureReason | null = null;
150
+ if (runtimeGuardFailed) failureReason = 'runtime_guard';
151
+ else if (cacheObservationFailed) failureReason = 'cache_observation';
152
+ else if (final === undefined) failureReason = 'no_records';
153
+ else if (final.stop_reason !== 'stop') failureReason = 'final_not_stop';
154
+ else if (invalidNonFinal) failureReason = 'invalid_non_final';
155
+ return {
156
+ schema_version: FUSION_CHILD_SETTLEMENT_SCHEMA_VERSION,
157
+ status: failureReason === null ? 'complete' : 'failed',
158
+ record_count: records.length,
159
+ records_sha256: protocolSha256(serializeFusionChildResultRecords(records)),
160
+ final_record_index: finalRecordIndex,
161
+ final_text_sha256: final?.text_sha256 ?? null,
162
+ recovered_error_ordinals: recoveredErrorOrdinals,
163
+ failure_reason: failureReason,
164
+ };
165
+ }
166
+
167
+ export function buildFusionChildResultMetadata(
168
+ message: {
169
+ provider: string;
170
+ model: string;
171
+ stopReason: string;
172
+ content: ReadonlyArray<{ type: string; text?: string }>;
173
+ usage: Usage;
174
+ },
175
+ cacheObservation: FusionClaudeCacheObservation,
176
+ ): FusionChildResultMetadata {
53
177
  const textBlocks = message.content.flatMap((part) =>
54
178
  part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
55
179
  );
@@ -78,5 +202,6 @@ export function buildFusionChildResultMetadata(message: {
78
202
  })),
79
203
  text_sha256: protocolSha256(textBlocks.join('')),
80
204
  usage,
205
+ cache_observation: cacheObservation,
81
206
  };
82
207
  }
@@ -0,0 +1,186 @@
1
+ import type { JsonObject } from '../common.js';
2
+
3
+ export const FUSION_CLAUDE_CACHE_OBSERVATION_SCHEMA_VERSION =
4
+ 'pi-background-tasks.fusion-claude-cache-observation.v1' as const;
5
+ export const FUSION_CLAUDE_CACHE_RETENTION_ENV = 'PI_CACHE_RETENTION';
6
+ export const FUSION_CLAUDE_CACHE_DEFAULT_RETENTION = 'long' as const;
7
+ export const FUSION_CLAUDE_CACHE_BREAKPOINT_LIMIT = 4;
8
+
9
+ export type FusionClaudeCacheRetention = 'none' | 'short' | 'long';
10
+ export type FusionClaudeCachePolicySource =
11
+ | 'default'
12
+ | typeof FUSION_CLAUDE_CACHE_RETENTION_ENV
13
+ | 'not_applicable';
14
+
15
+ export interface FusionClaudeCacheObservation {
16
+ schema_version: typeof FUSION_CLAUDE_CACHE_OBSERVATION_SCHEMA_VERSION;
17
+ applicability: 'anthropic' | 'not_applicable';
18
+ source: FusionClaudeCachePolicySource;
19
+ requested_retention: FusionClaudeCacheRetention | null;
20
+ effective_retention: FusionClaudeCacheRetention | null;
21
+ breakpoint_count: number;
22
+ request_ordinal: number;
23
+ }
24
+
25
+ export interface FusionClaudeCacheNormalization {
26
+ payload: JsonObject;
27
+ observation: FusionClaudeCacheObservation;
28
+ }
29
+
30
+ function isRecord(value: unknown): value is JsonObject {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
32
+ }
33
+
34
+ function unknownArray(value: unknown): unknown[] | undefined {
35
+ return Array.isArray(value) ? (value as unknown[]) : undefined;
36
+ }
37
+
38
+ function requireRequestOrdinal(value: number): number {
39
+ if (!Number.isSafeInteger(value) || value <= 0) {
40
+ throw new Error('Fusion Claude cache request ordinal must be a positive safe integer');
41
+ }
42
+ return value;
43
+ }
44
+
45
+ function parseRetention(value: string): FusionClaudeCacheRetention {
46
+ if (value === 'none' || value === 'short' || value === 'long') return value;
47
+ throw new Error(
48
+ `${FUSION_CLAUDE_CACHE_RETENTION_ENV} must be one of none, short, or long; got ${JSON.stringify(value)}`,
49
+ );
50
+ }
51
+
52
+ export function resolveFusionClaudeCachePolicy(env: Readonly<NodeJS.ProcessEnv> = process.env): {
53
+ retention: FusionClaudeCacheRetention;
54
+ source: FusionClaudeCachePolicySource;
55
+ } {
56
+ const configured = env[FUSION_CLAUDE_CACHE_RETENTION_ENV];
57
+ if (configured === undefined) {
58
+ return { retention: FUSION_CLAUDE_CACHE_DEFAULT_RETENTION, source: 'default' };
59
+ }
60
+ return {
61
+ retention: parseRetention(configured),
62
+ source: FUSION_CLAUDE_CACHE_RETENTION_ENV,
63
+ };
64
+ }
65
+
66
+ function validateCacheControl(value: unknown): JsonObject {
67
+ if (!isRecord(value)) {
68
+ throw new Error('Fusion Claude cache_control must be an object');
69
+ }
70
+ if (value['type'] !== 'ephemeral') {
71
+ throw new Error('Fusion Claude cache_control.type must be "ephemeral"');
72
+ }
73
+ const ttl = value['ttl'];
74
+ if (ttl !== undefined && ttl !== '1h' && ttl !== '5m') {
75
+ throw new Error('Fusion Claude cache_control.ttl must be "1h" or "5m" when present');
76
+ }
77
+ return value;
78
+ }
79
+
80
+ /**
81
+ * Normalize only cache breakpoints already selected by Pi's Anthropic adapter.
82
+ *
83
+ * Not creating new breakpoints is deliberate: an empty marker set may represent
84
+ * Pi's explicit cacheRetention="none" compaction request or a model compatibility
85
+ * restriction. The package may strengthen or disable native markers, but it must
86
+ * not override an upstream call-level opt-out that is no longer visible in the
87
+ * final provider payload.
88
+ */
89
+ export function normalizeFusionClaudeCachePayload(input: {
90
+ payload: unknown;
91
+ requestOrdinal: number;
92
+ env?: Readonly<NodeJS.ProcessEnv>;
93
+ supportsLongCacheRetention?: boolean | undefined;
94
+ }): FusionClaudeCacheNormalization {
95
+ if (!isRecord(input.payload)) {
96
+ throw new Error('Fusion Claude provider payload must be an object');
97
+ }
98
+ const requestOrdinal = requireRequestOrdinal(input.requestOrdinal);
99
+ const policy = resolveFusionClaudeCachePolicy(input.env ?? process.env);
100
+ const normalizedRetention: FusionClaudeCacheRetention =
101
+ policy.retention === 'long' && input.supportsLongCacheRetention === false
102
+ ? 'short'
103
+ : policy.retention;
104
+ let incomingBreakpoints = 0;
105
+ let outputBreakpoints = 0;
106
+
107
+ const normalizeBlock = (value: unknown): unknown => {
108
+ if (!isRecord(value) || !Object.hasOwn(value, 'cache_control')) return value;
109
+ const existing = value['cache_control'];
110
+ if (existing === undefined) {
111
+ const next = { ...value };
112
+ Reflect.deleteProperty(next, 'cache_control');
113
+ return next;
114
+ }
115
+ incomingBreakpoints += 1;
116
+ if (incomingBreakpoints > FUSION_CLAUDE_CACHE_BREAKPOINT_LIMIT) {
117
+ throw new Error(
118
+ `Fusion Claude payload has ${String(incomingBreakpoints)} cache_control breakpoints; Anthropic supports at most ${String(FUSION_CLAUDE_CACHE_BREAKPOINT_LIMIT)}`,
119
+ );
120
+ }
121
+ const control = validateCacheControl(existing);
122
+ const next = { ...value };
123
+ if (normalizedRetention === 'none') {
124
+ Reflect.deleteProperty(next, 'cache_control');
125
+ return next;
126
+ }
127
+ const normalizedControl = { ...control, type: 'ephemeral' };
128
+ Reflect.deleteProperty(normalizedControl, 'ttl');
129
+ if (normalizedRetention === 'long') Object.assign(normalizedControl, { ttl: '1h' });
130
+ next['cache_control'] = normalizedControl;
131
+ outputBreakpoints += 1;
132
+ return next;
133
+ };
134
+
135
+ const system = unknownArray(input.payload['system']);
136
+ const tools = unknownArray(input.payload['tools']);
137
+ const messages = unknownArray(input.payload['messages']);
138
+ const payload = {
139
+ ...input.payload,
140
+ ...(system === undefined ? {} : { system: system.map(normalizeBlock) }),
141
+ ...(tools === undefined ? {} : { tools: tools.map(normalizeBlock) }),
142
+ ...(messages === undefined
143
+ ? {}
144
+ : {
145
+ messages: messages.map((message) => {
146
+ if (!isRecord(message)) return message;
147
+ const content = unknownArray(message['content']);
148
+ return content === undefined
149
+ ? message
150
+ : { ...message, content: content.map(normalizeBlock) };
151
+ }),
152
+ }),
153
+ };
154
+ if (outputBreakpoints > FUSION_CLAUDE_CACHE_BREAKPOINT_LIMIT) {
155
+ throw new Error(
156
+ `Fusion Claude payload produced ${String(outputBreakpoints)} cache_control breakpoints; Anthropic supports at most ${String(FUSION_CLAUDE_CACHE_BREAKPOINT_LIMIT)}`,
157
+ );
158
+ }
159
+
160
+ return {
161
+ payload,
162
+ observation: {
163
+ schema_version: FUSION_CLAUDE_CACHE_OBSERVATION_SCHEMA_VERSION,
164
+ applicability: 'anthropic',
165
+ source: policy.source,
166
+ requested_retention: policy.retention,
167
+ effective_retention: outputBreakpoints === 0 ? 'none' : normalizedRetention,
168
+ breakpoint_count: outputBreakpoints,
169
+ request_ordinal: requestOrdinal,
170
+ },
171
+ };
172
+ }
173
+
174
+ export function nonAnthropicFusionCacheObservation(
175
+ requestOrdinal: number,
176
+ ): FusionClaudeCacheObservation {
177
+ return {
178
+ schema_version: FUSION_CLAUDE_CACHE_OBSERVATION_SCHEMA_VERSION,
179
+ applicability: 'not_applicable',
180
+ source: 'not_applicable',
181
+ requested_retention: null,
182
+ effective_retention: null,
183
+ breakpoint_count: 0,
184
+ request_ordinal: requireRequestOrdinal(requestOrdinal),
185
+ };
186
+ }