pi-background-tasks 1.0.4 → 1.0.6

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.
@@ -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,
@@ -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
+ }
@@ -158,6 +158,17 @@ function requireContextWindow(model: Model<Api>, label: string): number {
158
158
  return Math.floor(value);
159
159
  }
160
160
 
161
+ function requireMaxOutputTokens(model: Model<Api>, label: string): number {
162
+ const value = model.maxTokens;
163
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
164
+ throw new FusionError(`${label} has no positive maximum output token capacity`, {
165
+ code: 'model_unavailable',
166
+ childCreated: false,
167
+ });
168
+ }
169
+ return Math.floor(value);
170
+ }
171
+
161
172
  function modelIndex(models: readonly Model<Api>[]): Map<string, Model<Api>> {
162
173
  const out = new Map<string, Model<Api>>();
163
174
  for (const model of models) out.set(qualifiedModelKey(model), model);
@@ -305,6 +316,7 @@ function resolveSelection(
305
316
  qualifiedId,
306
317
  thinkingLevel,
307
318
  contextWindow: requireContextWindow(available, slotLabel),
319
+ maxOutputTokens: requireMaxOutputTokens(available, slotLabel),
308
320
  };
309
321
  }
310
322
  const model = availableByKey.get(selection);
@@ -323,6 +335,7 @@ function resolveSelection(
323
335
  qualifiedId: selection,
324
336
  thinkingLevel,
325
337
  contextWindow: requireContextWindow(model, slotLabel),
338
+ maxOutputTokens: requireMaxOutputTokens(model, slotLabel),
326
339
  };
327
340
  }
328
341