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.
- package/BACKGROUND-TASKS-INSTRUCTIONS.md +2 -1
- package/README.md +2 -1
- package/TESTING.md +2 -2
- package/TEST_PLAN.md +10 -9
- package/docs/manifest.json +8 -4
- package/docs/operations/configuration.md +10 -0
- package/docs/operations/testing.md +2 -1
- package/docs/operations/troubleshooting.md +3 -1
- package/docs/read-before-edit.md +1 -0
- package/docs/reference/runtime-contracts.md +66 -61
- package/docs/subsystems/docs-freshness-gate.md +5 -5
- package/docs/subsystems/fusion.md +19 -14
- package/package.json +2 -1
- package/src/core/fusion/artifacts.ts +41 -0
- package/src/core/fusion/budget.ts +23 -8
- package/src/core/fusion/child-protocol.ts +137 -12
- package/src/core/fusion/claude-cache.ts +186 -0
- package/src/core/fusion/config.ts +13 -0
- package/src/core/fusion/evaluation.ts +61 -0
- package/src/core/fusion/orchestrator.ts +126 -15
- package/src/core/fusion/pi-child.ts +564 -14
- package/src/core/fusion/prompts.ts +1 -0
- package/src/core/fusion/types.ts +39 -8
- package/src/fusion-child-extension.ts +413 -4
|
@@ -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
|
|
64
|
-
|
|
65
|
-
| reason
|
|
66
|
-
| investigate |
|
|
67
|
-
| research
|
|
68
|
-
| validate
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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:
|
|
1137
|
-
cache_read:
|
|
1138
|
-
cache_write:
|
|
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.
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|