@pi-unipi/background-tasks 2.6.1
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/README.md +87 -0
- package/extensions/anthropic-attribution.ts +1 -0
- package/extensions/delegate-child.ts +1 -0
- package/extensions/fusion-child.ts +1 -0
- package/package.json +40 -0
- package/src/__tests__/anthropic-attribution.test.ts +195 -0
- package/src/__tests__/config.test.ts +137 -0
- package/src/__tests__/core.test.ts +493 -0
- package/src/__tests__/delegate-artifacts.test.ts +528 -0
- package/src/__tests__/delegate-budget.test.ts +456 -0
- package/src/__tests__/delegate-launch.test.ts +676 -0
- package/src/__tests__/delegate-result-package.test.ts +350 -0
- package/src/__tests__/delegate-seed.test.ts +392 -0
- package/src/__tests__/durable-fs.test.ts +559 -0
- package/src/__tests__/extension-api.test.ts +579 -0
- package/src/__tests__/fusion-artifacts.test.ts +1039 -0
- package/src/__tests__/fusion-budget.test.ts +1356 -0
- package/src/__tests__/fusion-claude-cache.test.ts +320 -0
- package/src/__tests__/fusion-config.test.ts +335 -0
- package/src/__tests__/fusion-context-prompts.test.ts +670 -0
- package/src/__tests__/fusion-evaluation.test.ts +315 -0
- package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
- package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
- package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
- package/src/__tests__/fusion-model-selector.test.ts +205 -0
- package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
- package/src/__tests__/fusion-rpc.test.ts +369 -0
- package/src/__tests__/fusion-sdk.test.ts +1226 -0
- package/src/__tests__/fusion-v5-core.test.ts +219 -0
- package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
- package/src/__tests__/fusion-web-fetch.test.ts +485 -0
- package/src/__tests__/fusion-workflows.test.ts +59 -0
- package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
- package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
- package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
- package/src/__tests__/helpers/fusion-canonical.ts +140 -0
- package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
- package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
- package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
- package/src/__tests__/helpers/normalize.ts +22 -0
- package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/pi-launch.test.ts +202 -0
- package/src/__tests__/registry.test.ts +1580 -0
- package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
- package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
- package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
- package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
- package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
- package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
- package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
- package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
- package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
- package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
- package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
- package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
- package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
- package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
- package/src/__tests__/task-manager.test.ts +479 -0
- package/src/__tests__/windows-taskkill.test.ts +161 -0
- package/src/anthropic-attribution-path.ts +21 -0
- package/src/anthropic-attribution.ts +1983 -0
- package/src/attested-pi-run.ts +612 -0
- package/src/child-process.ts +55 -0
- package/src/common.ts +8 -0
- package/src/config.ts +292 -0
- package/src/context-parent-snapshot.ts +142 -0
- package/src/context-token-budget.ts +903 -0
- package/src/context-visible-conversation-v2.ts +551 -0
- package/src/delegate/artifacts.ts +487 -0
- package/src/delegate/budget.ts +415 -0
- package/src/delegate/hook-contract-evidence.json +18 -0
- package/src/delegate/hook-contract.ts +154 -0
- package/src/delegate/launch.ts +497 -0
- package/src/delegate/result-package.ts +459 -0
- package/src/delegate/runner.ts +449 -0
- package/src/delegate/seed.ts +423 -0
- package/src/delegate/types.ts +323 -0
- package/src/delegate-child-extension.ts +978 -0
- package/src/delegate-extension.ts +806 -0
- package/src/durable-fs.ts +386 -0
- package/src/extension-api.ts +548 -0
- package/src/fixtures/delegate-context-incident.json +17 -0
- package/src/fixtures/fusion-golden-bytes.json +310 -0
- package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
- package/src/fusion/artifacts.ts +967 -0
- package/src/fusion/budget.ts +1162 -0
- package/src/fusion/child-protocol.ts +305 -0
- package/src/fusion/claude-cache.ts +207 -0
- package/src/fusion/clean-context.ts +91 -0
- package/src/fusion/config.ts +449 -0
- package/src/fusion/context.ts +265 -0
- package/src/fusion/evaluation.ts +800 -0
- package/src/fusion/orchestrator.ts +1288 -0
- package/src/fusion/output-contract.ts +34 -0
- package/src/fusion/pi-child.ts +2373 -0
- package/src/fusion/prompts.ts +345 -0
- package/src/fusion/result-package.ts +959 -0
- package/src/fusion/source-policy.ts +257 -0
- package/src/fusion/types.ts +1139 -0
- package/src/fusion/web-fetch.ts +1060 -0
- package/src/fusion/workflows.ts +184 -0
- package/src/fusion-child-extension.ts +1052 -0
- package/src/fusion-extension.ts +1293 -0
- package/src/index.ts +295 -0
- package/src/pi-launch.ts +225 -0
- package/src/registry.ts +2424 -0
- package/src/settings-overlay.ts +208 -0
- package/src/task-manager.ts +774 -0
- package/src/tools.ts +530 -0
- package/src/turndown.d.ts +15 -0
- package/src/types.ts +963 -0
- package/src/ui/fusion-model-selector.ts +322 -0
- package/src/windows-taskkill.ts +250 -0
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { mkdirSync, openSync, closeSync, fsyncSync, renameSync, writeSync } from 'node:fs';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
5
|
+
import { Type, type Static } from 'typebox';
|
|
6
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import type { Usage } from '@earendil-works/pi-ai';
|
|
8
|
+
import {
|
|
9
|
+
DELEGATE_RECEIPT_SCHEMA_VERSION,
|
|
10
|
+
DELEGATE_CAPABILITIES,
|
|
11
|
+
type DelegateRouteAttestation,
|
|
12
|
+
type DelegateSeedV1,
|
|
13
|
+
type DelegateSpillReceipt,
|
|
14
|
+
type DelegateUsageReport,
|
|
15
|
+
} from './delegate/types.js';
|
|
16
|
+
import { verifyDelegateSeedBytes } from './delegate/seed.js';
|
|
17
|
+
import {
|
|
18
|
+
DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS,
|
|
19
|
+
DELEGATE_FINALIZATION_TRIGGER_TOKENS,
|
|
20
|
+
evaluateDelegateRuntimeBudget,
|
|
21
|
+
} from './delegate/budget.js';
|
|
22
|
+
import { utf8ByteClassBreakdown } from './context-token-budget.js';
|
|
23
|
+
import {
|
|
24
|
+
assertWellFormedUtf8,
|
|
25
|
+
buildDelegateResultPackage,
|
|
26
|
+
serializeDelegateResultPackage,
|
|
27
|
+
} from './delegate/result-package.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Package-owned delegate child extension.
|
|
31
|
+
*
|
|
32
|
+
* This runs inside every delegate child Pi process and is the package-owned
|
|
33
|
+
* child guard. Anthropic routes load attribution first, and ambient mode may
|
|
34
|
+
* also execute discovered extensions; this guard remains
|
|
35
|
+
* responsible for every isolation guarantee that cannot be enforced from the
|
|
36
|
+
* parent:
|
|
37
|
+
*
|
|
38
|
+
* - verifying the frozen seed bytes before the first model call;
|
|
39
|
+
* - measuring the outgoing message set before every model call and requesting
|
|
40
|
+
* no-tool finalization when advisory runway becomes low;
|
|
41
|
+
* - spilling oversized or runway-pressuring tool results to hashed artifacts
|
|
42
|
+
* and replacing them with explicit receipts before transcript entry;
|
|
43
|
+
* - asserting every assistant message came from the pinned route;
|
|
44
|
+
* - enforcing turn and tool-call limits;
|
|
45
|
+
* - committing exactly one result package atomically.
|
|
46
|
+
*
|
|
47
|
+
* Measured behavior across the supported Pi lines (see
|
|
48
|
+
* `tests/scripted-provider/pi-hook-contract.test.ts` and `scripts/test-compat.ts`):
|
|
49
|
+
*
|
|
50
|
+
* - Throwing from a `context` handler does NOT stop dispatch. Pi catches the
|
|
51
|
+
* exception and continues, so a throw is never used as a barrier here.
|
|
52
|
+
* - `ctx.abort()` blocks transport in both supported modes: Pi 0.81.1-0.83.0
|
|
53
|
+
* invoke the provider with an already-aborted signal, while Pi 0.84.0 skips
|
|
54
|
+
* the provider entry point during signal-aware auth resolution.
|
|
55
|
+
* - The guard ALSO replaces the offending content in the returned message set.
|
|
56
|
+
* Even a non-conforming provider could not transmit the content because the
|
|
57
|
+
* content is no longer there.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
const SPILL_DIRNAME = 'spill';
|
|
61
|
+
|
|
62
|
+
interface GuardState {
|
|
63
|
+
seed: DelegateSeedV1;
|
|
64
|
+
artifactDirAbs: string;
|
|
65
|
+
turns: number;
|
|
66
|
+
toolCalls: number;
|
|
67
|
+
totalToolOutputBytes: number;
|
|
68
|
+
spilled: DelegateSpillReceipt[];
|
|
69
|
+
attestations: DelegateRouteAttestation[];
|
|
70
|
+
usage: Usage | undefined;
|
|
71
|
+
usageIncomplete: boolean;
|
|
72
|
+
usageUnavailableReason: string | undefined;
|
|
73
|
+
answerBlocks: string[];
|
|
74
|
+
answerBytes: number;
|
|
75
|
+
retainedGrowthTokens: number;
|
|
76
|
+
retainedGrowthBudgetTokens: number | undefined;
|
|
77
|
+
retainedToolResultBytes: number;
|
|
78
|
+
contextPressureSpillBytes: number;
|
|
79
|
+
finalizationRequested: boolean;
|
|
80
|
+
finalizationReason: string | undefined;
|
|
81
|
+
contextMeasurements: RuntimeContextMeasurement[];
|
|
82
|
+
firstRequestObservedInputTokens: number | undefined;
|
|
83
|
+
runtimeBudgetWritten: boolean;
|
|
84
|
+
terminal: TerminalLatch | undefined;
|
|
85
|
+
committed: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A terminal condition latches.
|
|
90
|
+
*
|
|
91
|
+
* Once the guard has degraded or refused anything, no later assistant message
|
|
92
|
+
* may be committed as a successful answer. Otherwise a run whose context was
|
|
93
|
+
* silently mutilated could still produce a hash-valid package, which is exactly
|
|
94
|
+
* the "hash-valid but wrong" failure this design must not have.
|
|
95
|
+
*/
|
|
96
|
+
interface TerminalLatch {
|
|
97
|
+
code: string;
|
|
98
|
+
message: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface RuntimeContextMeasurement {
|
|
102
|
+
request_ordinal: number;
|
|
103
|
+
retained_utf8_bytes: number;
|
|
104
|
+
estimated_input_tokens: number;
|
|
105
|
+
allowed_input_tokens: number;
|
|
106
|
+
signed_headroom_tokens: number;
|
|
107
|
+
dominant_byte_class: string;
|
|
108
|
+
finalization_requested: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Stop reasons that may be committed as a complete answer.
|
|
113
|
+
*
|
|
114
|
+
* `length` means the provider truncated the response at the output-token limit,
|
|
115
|
+
* `aborted` and `error` mean the run did not finish, and a pending tool call
|
|
116
|
+
* means the agent had more to do. None of those is a whole answer, so none of
|
|
117
|
+
* them may be committed as one.
|
|
118
|
+
*/
|
|
119
|
+
const ACCEPTED_STOP_REASONS: ReadonlySet<string> = new Set(['stop']);
|
|
120
|
+
|
|
121
|
+
function sha256(value: Buffer): string {
|
|
122
|
+
return createHash('sha256').update(value).digest('hex');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Replacement message set used when the guard suppresses a request.
|
|
127
|
+
*
|
|
128
|
+
* Keeps the shape valid without transmitting the content that triggered the
|
|
129
|
+
* suppression. The head message is retained only when it is a user message, so
|
|
130
|
+
* a suppressed request cannot carry assistant or tool content forward.
|
|
131
|
+
*/
|
|
132
|
+
function suppressedMessages<TMessage extends object>(
|
|
133
|
+
messages: readonly TMessage[],
|
|
134
|
+
): TMessage[] {
|
|
135
|
+
const head = messages[0];
|
|
136
|
+
if (head === undefined) return [];
|
|
137
|
+
return Reflect.get(head, 'role') === 'user' ? [head] : [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function utf8(value: string): Buffer {
|
|
141
|
+
return Buffer.from(value, 'utf8');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface DelegateToolTextPart {
|
|
145
|
+
readonly type: 'text';
|
|
146
|
+
readonly text: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface DelegateToolImagePart {
|
|
150
|
+
readonly type: 'image';
|
|
151
|
+
readonly data: string;
|
|
152
|
+
readonly mimeType: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type DelegateToolResultPart = DelegateToolTextPart | DelegateToolImagePart;
|
|
156
|
+
type SpillContentFormat = DelegateSpillReceipt['content_format'];
|
|
157
|
+
|
|
158
|
+
function encodeToolResultContent(
|
|
159
|
+
content: ReadonlyArray<DelegateToolResultPart>,
|
|
160
|
+
): { payload: Buffer; contentFormat: Exclude<SpillContentFormat, undefined> } {
|
|
161
|
+
if (content.length === 1) {
|
|
162
|
+
const only = content[0];
|
|
163
|
+
if (only?.type === 'text') {
|
|
164
|
+
return {
|
|
165
|
+
payload: assertWellFormedUtf8(only.text, 'delegate single-text tool result'),
|
|
166
|
+
contentFormat: 'single_text_utf8',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const normalized = content.map((part) => {
|
|
171
|
+
if (part.type === 'text' && typeof part.text === 'string') {
|
|
172
|
+
return { type: 'text' as const, text: part.text };
|
|
173
|
+
}
|
|
174
|
+
if (
|
|
175
|
+
part.type === 'image' &&
|
|
176
|
+
typeof part.data === 'string' &&
|
|
177
|
+
typeof part.mimeType === 'string'
|
|
178
|
+
) {
|
|
179
|
+
return {
|
|
180
|
+
type: 'image' as const,
|
|
181
|
+
data: part.data,
|
|
182
|
+
mimeType: part.mimeType,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
throw new Error('delegate tool result contains an unsupported or malformed content block');
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
payload: utf8(
|
|
189
|
+
JSON.stringify({
|
|
190
|
+
schema_version: 'pi-background-tasks.delegate-tool-result-content.v1',
|
|
191
|
+
content: normalized,
|
|
192
|
+
}),
|
|
193
|
+
),
|
|
194
|
+
contentFormat: 'tool_result_content_json_v1',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function addUsage(current: Usage | undefined, delta: Usage): Usage {
|
|
199
|
+
const prior = current ?? {
|
|
200
|
+
input: 0,
|
|
201
|
+
output: 0,
|
|
202
|
+
cacheRead: 0,
|
|
203
|
+
cacheWrite: 0,
|
|
204
|
+
totalTokens: 0,
|
|
205
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
input: prior.input + delta.input,
|
|
209
|
+
output: prior.output + delta.output,
|
|
210
|
+
cacheRead: prior.cacheRead + delta.cacheRead,
|
|
211
|
+
cacheWrite: prior.cacheWrite + delta.cacheWrite,
|
|
212
|
+
totalTokens: prior.totalTokens + delta.totalTokens,
|
|
213
|
+
cost: {
|
|
214
|
+
input: prior.cost.input + delta.cost.input,
|
|
215
|
+
output: prior.cost.output + delta.cost.output,
|
|
216
|
+
cacheRead: prior.cost.cacheRead + delta.cost.cacheRead,
|
|
217
|
+
cacheWrite: prior.cost.cacheWrite + delta.cost.cacheWrite,
|
|
218
|
+
total: prior.cost.total + delta.cost.total,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function finiteNonNegative(source: object, key: string): number | undefined {
|
|
224
|
+
const value: unknown = Reflect.get(source, key);
|
|
225
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Read a complete Pi `Usage` record, or report none.
|
|
231
|
+
*
|
|
232
|
+
* A partial usage record is treated as no usage at all. Filling missing fields
|
|
233
|
+
* with zero would understate real spend, which is a silent misreport.
|
|
234
|
+
*/
|
|
235
|
+
function readUsage(value: unknown): Usage | undefined {
|
|
236
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
237
|
+
const cost: unknown = Reflect.get(value, 'cost');
|
|
238
|
+
if (typeof cost !== 'object' || cost === null) return undefined;
|
|
239
|
+
const input = finiteNonNegative(value, 'input');
|
|
240
|
+
const output = finiteNonNegative(value, 'output');
|
|
241
|
+
const cacheRead = finiteNonNegative(value, 'cacheRead');
|
|
242
|
+
const cacheWrite = finiteNonNegative(value, 'cacheWrite');
|
|
243
|
+
const totalTokens = finiteNonNegative(value, 'totalTokens');
|
|
244
|
+
const costInput = finiteNonNegative(cost, 'input');
|
|
245
|
+
const costOutput = finiteNonNegative(cost, 'output');
|
|
246
|
+
const costCacheRead = finiteNonNegative(cost, 'cacheRead');
|
|
247
|
+
const costCacheWrite = finiteNonNegative(cost, 'cacheWrite');
|
|
248
|
+
const costTotal = finiteNonNegative(cost, 'total');
|
|
249
|
+
if (
|
|
250
|
+
input === undefined ||
|
|
251
|
+
output === undefined ||
|
|
252
|
+
cacheRead === undefined ||
|
|
253
|
+
cacheWrite === undefined ||
|
|
254
|
+
totalTokens === undefined ||
|
|
255
|
+
costInput === undefined ||
|
|
256
|
+
costOutput === undefined ||
|
|
257
|
+
costCacheRead === undefined ||
|
|
258
|
+
costCacheWrite === undefined ||
|
|
259
|
+
costTotal === undefined
|
|
260
|
+
) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
input,
|
|
265
|
+
output,
|
|
266
|
+
cacheRead,
|
|
267
|
+
cacheWrite,
|
|
268
|
+
totalTokens,
|
|
269
|
+
cost: {
|
|
270
|
+
input: costInput,
|
|
271
|
+
output: costOutput,
|
|
272
|
+
cacheRead: costCacheRead,
|
|
273
|
+
cacheWrite: costCacheWrite,
|
|
274
|
+
total: costTotal,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readEnv(key: string): string {
|
|
280
|
+
const value = process.env[key];
|
|
281
|
+
if (value === undefined || value.length === 0) {
|
|
282
|
+
throw new Error(`delegate child requires ${key}`);
|
|
283
|
+
}
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function pathInside(parent: string, child: string): boolean {
|
|
288
|
+
const rel = relative(parent, child);
|
|
289
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Synchronous durable commit: temp write, fsync, rename, directory fsync. */
|
|
293
|
+
function commitFileSync(absPath: string, data: Buffer): void {
|
|
294
|
+
const dir = dirname(absPath);
|
|
295
|
+
mkdirSync(dir, { recursive: true });
|
|
296
|
+
const temporary = `${absPath}.${String(process.pid)}.${Date.now().toString(36)}.tmp`;
|
|
297
|
+
const handle = openSync(temporary, 'wx', 0o600);
|
|
298
|
+
try {
|
|
299
|
+
let written = 0;
|
|
300
|
+
while (written < data.length) {
|
|
301
|
+
written += writeSync(handle, data, written, data.length - written, null);
|
|
302
|
+
}
|
|
303
|
+
fsyncSync(handle);
|
|
304
|
+
} finally {
|
|
305
|
+
closeSync(handle);
|
|
306
|
+
}
|
|
307
|
+
renameSync(temporary, absPath);
|
|
308
|
+
if (process.platform !== 'win32') {
|
|
309
|
+
const dirHandle = openSync(dir, 'r');
|
|
310
|
+
try {
|
|
311
|
+
fsyncSync(dirHandle);
|
|
312
|
+
} finally {
|
|
313
|
+
closeSync(dirHandle);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface ContentMeasurement {
|
|
319
|
+
bytes: number;
|
|
320
|
+
multibyteBytes: number;
|
|
321
|
+
denseBytes: 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function emptyContentMeasurement(): ContentMeasurement {
|
|
325
|
+
return { bytes: 0, multibyteBytes: 0, denseBytes: 0 };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function addContentMeasurement(target: ContentMeasurement, delta: ContentMeasurement): void {
|
|
329
|
+
target.bytes += delta.bytes;
|
|
330
|
+
target.multibyteBytes += delta.multibyteBytes;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function stringMeasurement(value: string): ContentMeasurement {
|
|
334
|
+
return utf8ByteClassBreakdown(value);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function messageContentMeasurement(content: unknown): ContentMeasurement {
|
|
338
|
+
if (typeof content === 'string') return stringMeasurement(content);
|
|
339
|
+
const total = emptyContentMeasurement();
|
|
340
|
+
if (!Array.isArray(content)) return total;
|
|
341
|
+
for (const part of content) {
|
|
342
|
+
if (typeof part !== 'object' || part === null) continue;
|
|
343
|
+
const text: unknown = Reflect.get(part, 'text');
|
|
344
|
+
if (typeof text === 'string') addContentMeasurement(total, stringMeasurement(text));
|
|
345
|
+
const thinkingText: unknown = Reflect.get(part, 'thinking');
|
|
346
|
+
if (typeof thinkingText === 'string') addContentMeasurement(total, stringMeasurement(thinkingText));
|
|
347
|
+
const args: unknown = Reflect.get(part, 'arguments');
|
|
348
|
+
if (args !== undefined) addContentMeasurement(total, stringMeasurement(JSON.stringify(args) ?? ''));
|
|
349
|
+
const data: unknown = Reflect.get(part, 'data');
|
|
350
|
+
if (typeof data === 'string') addContentMeasurement(total, stringMeasurement(data));
|
|
351
|
+
}
|
|
352
|
+
return total;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Complete retained input measured the same way the admission plan measured the seed. */
|
|
356
|
+
function retainedInputMeasurement(messages: readonly object[], systemPrompt: string): ContentMeasurement {
|
|
357
|
+
const total = stringMeasurement(systemPrompt);
|
|
358
|
+
for (const message of messages) addContentMeasurement(total, messageContentMeasurement(Reflect.get(message, 'content')));
|
|
359
|
+
return total;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const ArtifactReadParams = Type.Object(
|
|
363
|
+
{
|
|
364
|
+
artifact: Type.String({
|
|
365
|
+
description:
|
|
366
|
+
'Relative artifact path exactly as named in a spill receipt, for example spill/t0001-c0000-abc.bin',
|
|
367
|
+
}),
|
|
368
|
+
offset: Type.Number({ description: 'Byte offset to start reading from. Zero-based.' }),
|
|
369
|
+
length: Type.Number({ description: 'Exact number of bytes to read. Must be positive.' }),
|
|
370
|
+
},
|
|
371
|
+
{ additionalProperties: false },
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
type ArtifactReadParamsValue = Static<typeof ArtifactReadParams>;
|
|
375
|
+
|
|
376
|
+
interface ArtifactReadDetails {
|
|
377
|
+
artifact: string;
|
|
378
|
+
offset: number;
|
|
379
|
+
length: number;
|
|
380
|
+
total_bytes: number;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export default function delegateChildExtension(pi: ExtensionAPI): void {
|
|
384
|
+
const artifactDirAbs = readEnv('UNIPI_BG_DELEGATE_ARTIFACT_DIR');
|
|
385
|
+
const seedPath = readEnv('UNIPI_BG_DELEGATE_SEED_PATH');
|
|
386
|
+
const expectedSeedSha = readEnv('UNIPI_BG_DELEGATE_SEED_SHA256');
|
|
387
|
+
const expectedTaskId = readEnv('UNIPI_BG_DELEGATE_TASK_ID');
|
|
388
|
+
const expectedNonce = readEnv('UNIPI_BG_DELEGATE_LAUNCH_NONCE');
|
|
389
|
+
|
|
390
|
+
// Seed verification happens at load, before the first model call. A seed
|
|
391
|
+
// that does not match its declared hash and identity aborts the child rather
|
|
392
|
+
// than running with content the parent did not author.
|
|
393
|
+
const seedRaw = readFileSync(seedPath, 'utf8');
|
|
394
|
+
const seed = verifyDelegateSeedBytes(seedRaw, {
|
|
395
|
+
sha256: expectedSeedSha,
|
|
396
|
+
taskId: expectedTaskId,
|
|
397
|
+
launchNonce: expectedNonce,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (!DELEGATE_CAPABILITIES.includes(seed.capability)) {
|
|
401
|
+
throw new Error(`delegate child cannot enforce capability ${seed.capability}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const state: GuardState = {
|
|
405
|
+
seed,
|
|
406
|
+
artifactDirAbs,
|
|
407
|
+
turns: 0,
|
|
408
|
+
toolCalls: 0,
|
|
409
|
+
totalToolOutputBytes: 0,
|
|
410
|
+
spilled: [],
|
|
411
|
+
attestations: [],
|
|
412
|
+
usage: undefined,
|
|
413
|
+
usageIncomplete: false,
|
|
414
|
+
usageUnavailableReason: 'the child produced no assistant message carrying usage',
|
|
415
|
+
answerBlocks: [],
|
|
416
|
+
answerBytes: 0,
|
|
417
|
+
retainedGrowthTokens: 0,
|
|
418
|
+
retainedGrowthBudgetTokens: undefined,
|
|
419
|
+
retainedToolResultBytes: 0,
|
|
420
|
+
contextPressureSpillBytes: 0,
|
|
421
|
+
finalizationRequested: false,
|
|
422
|
+
finalizationReason: undefined,
|
|
423
|
+
contextMeasurements: [],
|
|
424
|
+
firstRequestObservedInputTokens: undefined,
|
|
425
|
+
runtimeBudgetWritten: false,
|
|
426
|
+
terminal: undefined,
|
|
427
|
+
committed: false,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
function latch(code: string, message: string): void {
|
|
431
|
+
if (state.terminal === undefined) state.terminal = { code, message };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function usageReport(): DelegateUsageReport {
|
|
435
|
+
if (!state.usageIncomplete && state.usage !== undefined) {
|
|
436
|
+
return { status: 'observed', usage: state.usage };
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
status: 'unavailable',
|
|
440
|
+
reason: state.usageUnavailableReason ?? 'usage was not reported by the provider',
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function remainingGrowthTokens(): number {
|
|
445
|
+
if (state.retainedGrowthBudgetTokens === undefined) return 0;
|
|
446
|
+
return Math.max(0, state.retainedGrowthBudgetTokens - state.retainedGrowthTokens);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function requestFinalization(reason: string): void {
|
|
450
|
+
if (!state.finalizationRequested) {
|
|
451
|
+
state.finalizationRequested = true;
|
|
452
|
+
state.finalizationReason = reason;
|
|
453
|
+
}
|
|
454
|
+
pi.setActiveTools([]);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function accountRetainedGrowth(bytes: number, source: string): void {
|
|
458
|
+
state.retainedGrowthTokens += bytes;
|
|
459
|
+
if (
|
|
460
|
+
state.retainedGrowthBudgetTokens !== undefined &&
|
|
461
|
+
remainingGrowthTokens() <= DELEGATE_FINALIZATION_TRIGGER_TOKENS
|
|
462
|
+
) {
|
|
463
|
+
requestFinalization(
|
|
464
|
+
`${source} left ${String(remainingGrowthTokens())} protected retained-growth tokens`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function writeRuntimeBudgetRecord(): void {
|
|
470
|
+
if (state.runtimeBudgetWritten) return;
|
|
471
|
+
const latest = state.contextMeasurements.at(-1);
|
|
472
|
+
const firstEstimate = state.contextMeasurements[0]?.estimated_input_tokens;
|
|
473
|
+
const calibrationViolation =
|
|
474
|
+
firstEstimate !== undefined &&
|
|
475
|
+
state.firstRequestObservedInputTokens !== undefined &&
|
|
476
|
+
state.firstRequestObservedInputTokens > firstEstimate
|
|
477
|
+
? {
|
|
478
|
+
forecast_input_tokens: firstEstimate,
|
|
479
|
+
observed_input_tokens: state.firstRequestObservedInputTokens,
|
|
480
|
+
tokens_under_forecast:
|
|
481
|
+
state.firstRequestObservedInputTokens - firstEstimate,
|
|
482
|
+
}
|
|
483
|
+
: null;
|
|
484
|
+
commitFileSync(
|
|
485
|
+
join(artifactDirAbs, 'runtime-budget.json'),
|
|
486
|
+
utf8(
|
|
487
|
+
`${JSON.stringify(
|
|
488
|
+
{
|
|
489
|
+
schema_version: 'pi-background-tasks.delegate-runtime-budget.v1',
|
|
490
|
+
task_id: seed.task_id,
|
|
491
|
+
launch_nonce: seed.launch_nonce,
|
|
492
|
+
policy: {
|
|
493
|
+
live_provider_context_owner: 'pi_and_provider',
|
|
494
|
+
retained_growth_estimator: 'provable_1_byte_per_token',
|
|
495
|
+
finalization_input_reserve_tokens: DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS,
|
|
496
|
+
finalization_trigger_tokens: DELEGATE_FINALIZATION_TRIGGER_TOKENS,
|
|
497
|
+
},
|
|
498
|
+
retained_growth_budget_tokens: state.retainedGrowthBudgetTokens ?? null,
|
|
499
|
+
retained_growth_tokens: state.retainedGrowthTokens,
|
|
500
|
+
retained_tool_result_bytes: state.retainedToolResultBytes,
|
|
501
|
+
spilled_tool_result_bytes: state.spilled.reduce(
|
|
502
|
+
(sum, receipt) => sum + receipt.byte_length,
|
|
503
|
+
0,
|
|
504
|
+
),
|
|
505
|
+
context_pressure_spill_bytes: state.contextPressureSpillBytes,
|
|
506
|
+
finalization_requested: state.finalizationRequested,
|
|
507
|
+
finalization_reason: state.finalizationReason ?? null,
|
|
508
|
+
first_request_observed_input_tokens: state.firstRequestObservedInputTokens ?? null,
|
|
509
|
+
calibration_violation: calibrationViolation,
|
|
510
|
+
latest_context_estimate: latest ?? null,
|
|
511
|
+
context_measurements: state.contextMeasurements,
|
|
512
|
+
},
|
|
513
|
+
null,
|
|
514
|
+
2,
|
|
515
|
+
)}\n`,
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
state.runtimeBudgetWritten = true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Commit exactly one result package.
|
|
523
|
+
*
|
|
524
|
+
* Refuses to commit when a terminal condition has latched, so a degraded run
|
|
525
|
+
* can never be reported as a clean success. Refuses to commit twice.
|
|
526
|
+
*/
|
|
527
|
+
function commitResult(stopReason: string): void {
|
|
528
|
+
if (state.committed) return;
|
|
529
|
+
if (state.terminal !== undefined) {
|
|
530
|
+
writeTerminalRecord(state.terminal);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// A hash proves the bytes are intact; it cannot prove they are complete.
|
|
534
|
+
// Only an approved terminal stop reason may be committed as success, so a
|
|
535
|
+
// response cut short by the output-token limit, a content filter, an
|
|
536
|
+
// aborted run, or a provider error can never be returned as a whole answer.
|
|
537
|
+
if (!ACCEPTED_STOP_REASONS.has(stopReason)) {
|
|
538
|
+
writeTerminalRecord({
|
|
539
|
+
code: stopReason === 'length' ? 'child_model_output_limit' : 'child_result_invalid',
|
|
540
|
+
message: `the delegate child stopped with reason "${stopReason}", so its answer is incomplete and is not committed as a result; the complete assistant message remains in the child transcript`,
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (state.answerBlocks.length === 0) {
|
|
545
|
+
writeTerminalRecord({
|
|
546
|
+
code: 'child_exited_without_commit',
|
|
547
|
+
message: 'the delegate child produced no final assistant answer text',
|
|
548
|
+
});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (state.answerBlocks.join('').trim().length === 0) {
|
|
552
|
+
writeTerminalRecord({
|
|
553
|
+
code: 'child_result_invalid',
|
|
554
|
+
message: 'the delegate child produced only whitespace, which is not a usable answer',
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
writeRuntimeBudgetRecord();
|
|
559
|
+
const pkg = buildDelegateResultPackage({
|
|
560
|
+
taskId: seed.task_id,
|
|
561
|
+
launchNonce: seed.launch_nonce,
|
|
562
|
+
seedSha256: expectedSeedSha,
|
|
563
|
+
directiveSha256: seed.directive.sha256,
|
|
564
|
+
route: { provider: seed.route.provider, model: seed.route.model },
|
|
565
|
+
routeAttestations: state.attestations,
|
|
566
|
+
stopReason,
|
|
567
|
+
turns: state.turns,
|
|
568
|
+
toolCalls: state.toolCalls,
|
|
569
|
+
usage: usageReport(),
|
|
570
|
+
answerBlocks: state.answerBlocks,
|
|
571
|
+
spilledArtifacts: state.spilled,
|
|
572
|
+
});
|
|
573
|
+
commitFileSync(join(artifactDirAbs, 'result.json'), utf8(serializeDelegateResultPackage(pkg)));
|
|
574
|
+
state.committed = true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function writeTerminalRecord(terminal: TerminalLatch): void {
|
|
578
|
+
writeRuntimeBudgetRecord();
|
|
579
|
+
commitFileSync(
|
|
580
|
+
join(artifactDirAbs, 'child-terminal.json'),
|
|
581
|
+
utf8(
|
|
582
|
+
`${JSON.stringify(
|
|
583
|
+
{
|
|
584
|
+
schema_version: 'pi-background-tasks.delegate-child-terminal.v1',
|
|
585
|
+
task_id: seed.task_id,
|
|
586
|
+
launch_nonce: seed.launch_nonce,
|
|
587
|
+
code: terminal.code,
|
|
588
|
+
message: terminal.message,
|
|
589
|
+
turns: state.turns,
|
|
590
|
+
tool_calls: state.toolCalls,
|
|
591
|
+
spilled_artifacts: state.spilled,
|
|
592
|
+
},
|
|
593
|
+
null,
|
|
594
|
+
2,
|
|
595
|
+
)}\n`,
|
|
596
|
+
),
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
pi.registerTool<typeof ArtifactReadParams, ArtifactReadDetails>({
|
|
601
|
+
name: 'delegate_read_artifact',
|
|
602
|
+
label: 'Delegate Artifact Read',
|
|
603
|
+
description:
|
|
604
|
+
'Read an exact byte range from a spilled tool-result artifact as lossless base64. Returns the complete requested range or fails; it never decodes arbitrary bytes as UTF-8, returns fewer bytes, or clamps the request.',
|
|
605
|
+
promptSnippet: 'Read an exact artifact byte range as lossless base64',
|
|
606
|
+
promptGuidelines: [
|
|
607
|
+
'Use delegate_read_artifact when a tool result was replaced by a spill receipt and the omitted bytes are actually needed.',
|
|
608
|
+
'The response body is base64 for the exact requested bytes. Decode it according to the receipt content_format.',
|
|
609
|
+
'Request a bounded range. A request past the end of the artifact fails loudly rather than returning a short read.',
|
|
610
|
+
],
|
|
611
|
+
parameters: ArtifactReadParams,
|
|
612
|
+
prepareArguments(args): ArtifactReadParamsValue {
|
|
613
|
+
if (typeof args !== 'object' || args === null)
|
|
614
|
+
throw new Error('delegate_read_artifact arguments must be an object');
|
|
615
|
+
const artifact: unknown = Reflect.get(args, 'artifact');
|
|
616
|
+
const offset: unknown = Reflect.get(args, 'offset');
|
|
617
|
+
const length: unknown = Reflect.get(args, 'length');
|
|
618
|
+
if (typeof artifact !== 'string')
|
|
619
|
+
throw new Error('delegate_read_artifact requires artifact string');
|
|
620
|
+
if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0)
|
|
621
|
+
throw new Error('delegate_read_artifact requires a non-negative integer offset');
|
|
622
|
+
if (typeof length !== 'number' || !Number.isSafeInteger(length) || length <= 0)
|
|
623
|
+
throw new Error('delegate_read_artifact requires a positive integer length');
|
|
624
|
+
return { artifact, offset, length };
|
|
625
|
+
},
|
|
626
|
+
execute(_toolCallId, params) {
|
|
627
|
+
const absPath = join(artifactDirAbs, params.artifact);
|
|
628
|
+
if (!pathInside(artifactDirAbs, absPath)) {
|
|
629
|
+
throw new Error(
|
|
630
|
+
`delegate_read_artifact path ${params.artifact} escapes the delegate artifact directory`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
const bytes = readFileSync(absPath);
|
|
634
|
+
const end = params.offset + params.length;
|
|
635
|
+
if (end > bytes.length) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
`delegate_read_artifact requested bytes ${String(params.offset)}..${String(end)} but ${params.artifact} is ${String(bytes.length)} bytes; the read is refused rather than silently shortened`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
const slice = bytes.subarray(params.offset, end);
|
|
641
|
+
if (slice.length !== params.length) {
|
|
642
|
+
throw new Error(
|
|
643
|
+
`delegate_read_artifact returned ${String(slice.length)} of ${String(params.length)} requested bytes`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
const encoded = slice.toString('base64');
|
|
647
|
+
const responseText = [
|
|
648
|
+
`[delegate artifact range] artifact=${params.artifact} offset=${String(params.offset)} length=${String(params.length)} encoding=base64`,
|
|
649
|
+
encoded,
|
|
650
|
+
].join('\n');
|
|
651
|
+
const responseBytes = utf8(responseText).length;
|
|
652
|
+
const availableInlineBytes = Math.max(
|
|
653
|
+
0,
|
|
654
|
+
Math.min(
|
|
655
|
+
seed.limits.max_tool_result_bytes,
|
|
656
|
+
remainingGrowthTokens() - DELEGATE_FINALIZATION_TRIGGER_TOKENS,
|
|
657
|
+
),
|
|
658
|
+
);
|
|
659
|
+
if (responseBytes > availableInlineBytes) {
|
|
660
|
+
if (availableInlineBytes === 0) {
|
|
661
|
+
requestFinalization('no retained-growth runway remains for an artifact range read');
|
|
662
|
+
throw new Error(
|
|
663
|
+
`delegate_read_artifact cannot retain another artifact range because protected final-answer runway is active; stop reading and answer from gathered evidence`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
throw new Error(
|
|
667
|
+
`delegate_read_artifact requested ${String(params.length)} raw bytes whose lossless base64 response is ${String(responseBytes)} bytes, but current protected runway permits at most ${String(availableInlineBytes)} inline bytes; request a smaller exact range`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return Promise.resolve({
|
|
671
|
+
content: [{ type: 'text' as const, text: responseText }],
|
|
672
|
+
details: {
|
|
673
|
+
artifact: params.artifact,
|
|
674
|
+
offset: params.offset,
|
|
675
|
+
length: params.length,
|
|
676
|
+
total_bytes: bytes.length,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
pi.on('context', (event, ctx) => {
|
|
683
|
+
// Measurement failures remain fail-closed. A successful estimate is
|
|
684
|
+
// advisory: Fusion BUG-185 proved that subtracting hypothetical output from
|
|
685
|
+
// a live provider payload can falsely refuse valid work. Package-owned
|
|
686
|
+
// growth is bounded earlier by the tool-result spill governor instead.
|
|
687
|
+
try {
|
|
688
|
+
if (state.terminal !== undefined) {
|
|
689
|
+
ctx.abort();
|
|
690
|
+
return { messages: suppressedMessages(event.messages) };
|
|
691
|
+
}
|
|
692
|
+
const measurement = retainedInputMeasurement(event.messages, ctx.getSystemPrompt());
|
|
693
|
+
const verdict = evaluateDelegateRuntimeBudget(
|
|
694
|
+
{
|
|
695
|
+
retainedInputBytes: measurement.bytes,
|
|
696
|
+
retainedInputMultibyteBytes: measurement.multibyteBytes,
|
|
697
|
+
retainedInputDenseBytes: measurement.denseBytes,
|
|
698
|
+
},
|
|
699
|
+
seed.limits.allowed_input_tokens,
|
|
700
|
+
seed.route,
|
|
701
|
+
);
|
|
702
|
+
if (state.retainedGrowthBudgetTokens === undefined) {
|
|
703
|
+
state.retainedGrowthBudgetTokens = Math.max(
|
|
704
|
+
0,
|
|
705
|
+
verdict.allowedTokens -
|
|
706
|
+
verdict.measuredTokens -
|
|
707
|
+
DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
state.contextMeasurements.push({
|
|
711
|
+
request_ordinal: state.contextMeasurements.length + 1,
|
|
712
|
+
retained_utf8_bytes: measurement.bytes,
|
|
713
|
+
estimated_input_tokens: verdict.measuredTokens,
|
|
714
|
+
allowed_input_tokens: verdict.allowedTokens,
|
|
715
|
+
signed_headroom_tokens: verdict.allowedTokens - verdict.measuredTokens,
|
|
716
|
+
dominant_byte_class: verdict.dominantByteClass,
|
|
717
|
+
finalization_requested: state.finalizationRequested,
|
|
718
|
+
});
|
|
719
|
+
if (
|
|
720
|
+
verdict.measuredTokens >=
|
|
721
|
+
verdict.allowedTokens - DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS
|
|
722
|
+
) {
|
|
723
|
+
requestFinalization(
|
|
724
|
+
`advisory retained-input estimate reached ${String(verdict.measuredTokens)} of ${String(verdict.allowedTokens)} allowed tokens`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
if (!state.finalizationRequested) return undefined;
|
|
728
|
+
const finalizationMessage = {
|
|
729
|
+
role: 'user' as const,
|
|
730
|
+
content: `[delegate finalization runway] Stop investigating and do not call tools. Produce the final self-contained answer now from evidence already gathered. Reason: ${state.finalizationReason ?? 'protected final-answer runway is active'}.`,
|
|
731
|
+
timestamp: Date.now(),
|
|
732
|
+
};
|
|
733
|
+
return { messages: [...event.messages, finalizationMessage] };
|
|
734
|
+
} catch (error) {
|
|
735
|
+
latch(
|
|
736
|
+
'child_result_invalid',
|
|
737
|
+
`delegate context measurement failed and the run was stopped rather than dispatched unguarded: ${error instanceof Error ? error.message : String(error)}`,
|
|
738
|
+
);
|
|
739
|
+
try {
|
|
740
|
+
ctx.abort();
|
|
741
|
+
} catch {
|
|
742
|
+
// The terminal latch prevents success even if abort itself fails.
|
|
743
|
+
}
|
|
744
|
+
return { messages: suppressedMessages(event.messages) };
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
pi.on('tool_call', (_event, ctx) => {
|
|
749
|
+
if (state.finalizationRequested) {
|
|
750
|
+
return {
|
|
751
|
+
block: true,
|
|
752
|
+
reason: 'delegate protected final-answer runway is active; answer now without tools',
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
state.toolCalls += 1;
|
|
756
|
+
if (state.toolCalls > seed.limits.max_tool_calls) {
|
|
757
|
+
latch(
|
|
758
|
+
'child_tool_call_limit',
|
|
759
|
+
`delegate child exceeded its ${String(seed.limits.max_tool_calls)} tool-call limit`,
|
|
760
|
+
);
|
|
761
|
+
ctx.abort();
|
|
762
|
+
return {
|
|
763
|
+
block: true,
|
|
764
|
+
reason: `delegate child exceeded its ${String(seed.limits.max_tool_calls)} tool-call limit`,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
return undefined;
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
pi.on('tool_result', (event) => {
|
|
771
|
+
// Fail closed for the same reason as the context guard: a throw here would
|
|
772
|
+
// let the ORIGINAL oversized payload flow into the transcript.
|
|
773
|
+
try {
|
|
774
|
+
return guardToolResult(event);
|
|
775
|
+
} catch (error) {
|
|
776
|
+
latch(
|
|
777
|
+
'artifact_spill_failed',
|
|
778
|
+
`delegate tool-result guard failed and the payload was withheld: ${error instanceof Error ? error.message : String(error)}`,
|
|
779
|
+
);
|
|
780
|
+
return {
|
|
781
|
+
content: [
|
|
782
|
+
{
|
|
783
|
+
type: 'text' as const,
|
|
784
|
+
text: '[delegate: tool result withheld because the result guard failed; the run is terminating]',
|
|
785
|
+
},
|
|
786
|
+
],
|
|
787
|
+
isError: true,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
function guardToolResult(event: {
|
|
793
|
+
toolName: string;
|
|
794
|
+
toolCallId: string;
|
|
795
|
+
content: ReadonlyArray<DelegateToolResultPart>;
|
|
796
|
+
}): { content: Array<{ type: 'text'; text: string }>; isError?: boolean } | undefined {
|
|
797
|
+
// A single text block keeps its exact UTF-8 payload for convenient range
|
|
798
|
+
// reads. Multi-block and image-bearing results use a closed JSON envelope
|
|
799
|
+
// so block boundaries, MIME types, and complete base64 image data survive
|
|
800
|
+
// a spill. Unknown blocks fail closed rather than disappearing from hashes.
|
|
801
|
+
const encodedContent = encodeToolResultContent(event.content);
|
|
802
|
+
const payload = encodedContent.payload;
|
|
803
|
+
state.totalToolOutputBytes += payload.length;
|
|
804
|
+
if (state.totalToolOutputBytes > seed.limits.max_total_tool_output_bytes) {
|
|
805
|
+
latch(
|
|
806
|
+
'aggregate_tool_output_cap',
|
|
807
|
+
`delegate child accumulated ${String(state.totalToolOutputBytes)} bytes of tool output, exceeding its ${String(seed.limits.max_total_tool_output_bytes)}-byte cap`,
|
|
808
|
+
);
|
|
809
|
+
requestFinalization('the aggregate raw tool-output cap was reached');
|
|
810
|
+
const withheld = '[delegate: tool result withheld because the aggregate raw-output cap was reached; answer from evidence already gathered]';
|
|
811
|
+
accountRetainedGrowth(utf8(withheld).length, 'aggregate-cap receipt');
|
|
812
|
+
return { content: [{ type: 'text', text: withheld }], isError: true };
|
|
813
|
+
}
|
|
814
|
+
const contextPressure =
|
|
815
|
+
state.retainedGrowthBudgetTokens === undefined || payload.length > remainingGrowthTokens();
|
|
816
|
+
if (!contextPressure && payload.length <= seed.limits.max_tool_result_bytes) {
|
|
817
|
+
state.retainedToolResultBytes += payload.length;
|
|
818
|
+
accountRetainedGrowth(payload.length, `${event.toolName} inline result`);
|
|
819
|
+
return undefined;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Per-result oversized or route-pressure output is spilled in full and
|
|
823
|
+
// replaced by a receipt. Conservative false positives cause an explicit
|
|
824
|
+
// spill, never task failure or silent byte loss.
|
|
825
|
+
const turnSequence = state.turns;
|
|
826
|
+
const sourceCallIndex = state.spilled.length;
|
|
827
|
+
const safeCallId = event.toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 64);
|
|
828
|
+
const name = `t${String(turnSequence).padStart(4, '0')}-c${String(sourceCallIndex).padStart(4, '0')}-${safeCallId}.bin`;
|
|
829
|
+
const relPath = join(SPILL_DIRNAME, name);
|
|
830
|
+
const absPath = join(artifactDirAbs, relPath);
|
|
831
|
+
if (!pathInside(artifactDirAbs, absPath)) {
|
|
832
|
+
latch('artifact_spill_failed', `delegate spill path escapes the artifact directory: ${name}`);
|
|
833
|
+
return {
|
|
834
|
+
content: [
|
|
835
|
+
{
|
|
836
|
+
type: 'text' as const,
|
|
837
|
+
text: '[delegate: tool result withheld because its spill path was rejected]',
|
|
838
|
+
},
|
|
839
|
+
],
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
try {
|
|
843
|
+
commitFileSync(absPath, payload);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
// A spill that cannot be committed is terminal. The original payload is
|
|
846
|
+
// never returned as a fallback, and no receipt claims an uncommitted file.
|
|
847
|
+
latch(
|
|
848
|
+
'artifact_spill_failed',
|
|
849
|
+
`delegate could not spill a ${String(payload.length)}-byte tool result: ${error instanceof Error ? error.message : String(error)}`,
|
|
850
|
+
);
|
|
851
|
+
return {
|
|
852
|
+
content: [
|
|
853
|
+
{
|
|
854
|
+
type: 'text' as const,
|
|
855
|
+
text: '[delegate: tool result withheld because it could not be durably spilled; the run is terminating]',
|
|
856
|
+
},
|
|
857
|
+
],
|
|
858
|
+
isError: true,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
const receipt: DelegateSpillReceipt = {
|
|
862
|
+
schema_version: DELEGATE_RECEIPT_SCHEMA_VERSION,
|
|
863
|
+
artifact: relPath,
|
|
864
|
+
tool_name: event.toolName,
|
|
865
|
+
tool_call_id: event.toolCallId,
|
|
866
|
+
turn_sequence: turnSequence,
|
|
867
|
+
source_call_index: sourceCallIndex,
|
|
868
|
+
byte_length: payload.length,
|
|
869
|
+
sha256: sha256(payload),
|
|
870
|
+
content_format: encodedContent.contentFormat,
|
|
871
|
+
};
|
|
872
|
+
state.spilled.push(receipt);
|
|
873
|
+
if (contextPressure) state.contextPressureSpillBytes += payload.length;
|
|
874
|
+
const spillReason = contextPressure
|
|
875
|
+
? `retaining it would consume protected final-answer runway (${String(remainingGrowthTokens())} tokens remain)`
|
|
876
|
+
: `it exceeded the ${String(seed.limits.max_tool_result_bytes)}-byte per-result transcript cap`;
|
|
877
|
+
const receiptText = [
|
|
878
|
+
`[delegate spill receipt] The ${event.toolName} result was ${String(payload.length)} bytes; ${spillReason}.`,
|
|
879
|
+
`It was written in full to ${relPath} (sha256 ${receipt.sha256}, content_format ${encodedContent.contentFormat}).`,
|
|
880
|
+
'Nothing was truncated: the complete encoded content is on disk.',
|
|
881
|
+
`Read an exact range as base64 with delegate_read_artifact({artifact:"${relPath}", offset, length}).`,
|
|
882
|
+
].join('\n');
|
|
883
|
+
accountRetainedGrowth(utf8(receiptText).length, `${event.toolName} spill receipt`);
|
|
884
|
+
return {
|
|
885
|
+
content: [{ type: 'text' as const, text: receiptText }],
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
pi.on('turn_start', () => {
|
|
890
|
+
state.turns += 1;
|
|
891
|
+
if (state.turns > seed.limits.max_turns) {
|
|
892
|
+
latch(
|
|
893
|
+
'child_turn_limit',
|
|
894
|
+
`delegate child exceeded its ${String(seed.limits.max_turns)} turn limit`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
pi.on('message_end', (event) => {
|
|
900
|
+
if (event.message.role !== 'assistant') return;
|
|
901
|
+
const provider: unknown = Reflect.get(event.message, 'provider');
|
|
902
|
+
const model: unknown = Reflect.get(event.message, 'model');
|
|
903
|
+
const stopReason: unknown = Reflect.get(event.message, 'stopReason');
|
|
904
|
+
const attestation: DelegateRouteAttestation = {
|
|
905
|
+
provider: typeof provider === 'string' ? provider : '',
|
|
906
|
+
model: typeof model === 'string' ? model : '',
|
|
907
|
+
stop_reason: typeof stopReason === 'string' ? stopReason : '',
|
|
908
|
+
};
|
|
909
|
+
state.attestations.push(attestation);
|
|
910
|
+
if (
|
|
911
|
+
attestation.provider !== seed.route.provider ||
|
|
912
|
+
attestation.model !== seed.route.model
|
|
913
|
+
) {
|
|
914
|
+
latch(
|
|
915
|
+
'route_mismatch',
|
|
916
|
+
`delegate child produced an assistant message on ${attestation.provider}/${attestation.model}, but the pinned route is ${seed.route.qualified_id}`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
const observedUsage = readUsage(Reflect.get(event.message, 'usage'));
|
|
920
|
+
if (observedUsage === undefined) {
|
|
921
|
+
// Never synthesize zero usage. Once one turn is incomplete, later usage
|
|
922
|
+
// cannot conceal it by replacing the missing record.
|
|
923
|
+
state.usageIncomplete = true;
|
|
924
|
+
state.usageUnavailableReason =
|
|
925
|
+
'at least one provider turn did not report a complete token/cost usage record';
|
|
926
|
+
} else {
|
|
927
|
+
if (state.firstRequestObservedInputTokens === undefined) {
|
|
928
|
+
state.firstRequestObservedInputTokens =
|
|
929
|
+
observedUsage.input + observedUsage.cacheRead + observedUsage.cacheWrite;
|
|
930
|
+
}
|
|
931
|
+
state.usage = addUsage(state.usage, observedUsage);
|
|
932
|
+
if (!state.usageIncomplete) state.usageUnavailableReason = undefined;
|
|
933
|
+
}
|
|
934
|
+
const content: unknown = Reflect.get(event.message, 'content');
|
|
935
|
+
if (!Array.isArray(content)) return;
|
|
936
|
+
if (attestation.stop_reason !== 'stop') {
|
|
937
|
+
const retained = messageContentMeasurement(content);
|
|
938
|
+
accountRetainedGrowth(retained.bytes, 'assistant intermediate message');
|
|
939
|
+
// Tool-use narration is retained in the transcript for reasoning but is
|
|
940
|
+
// not part of the delegate's committed answer. Only the final clean-stop
|
|
941
|
+
// assistant message owns the answer data plane.
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
state.answerBlocks = [];
|
|
945
|
+
state.answerBytes = 0;
|
|
946
|
+
for (const part of content) {
|
|
947
|
+
if (typeof part !== 'object' || part === null) continue;
|
|
948
|
+
if (Reflect.get(part, 'type') !== 'text') continue;
|
|
949
|
+
const text: unknown = Reflect.get(part, 'text');
|
|
950
|
+
if (typeof text !== 'string' || text.length === 0) continue;
|
|
951
|
+
const bytes = utf8(text).length;
|
|
952
|
+
state.answerBytes += bytes;
|
|
953
|
+
if (state.answerBytes > seed.limits.max_answer_bytes) {
|
|
954
|
+
latch(
|
|
955
|
+
'child_capture_limit',
|
|
956
|
+
`delegate child answer text reached ${String(state.answerBytes)} bytes, exceeding its ${String(seed.limits.max_answer_bytes)}-byte capture contract; the transcript is preserved and no prefix is committed`,
|
|
957
|
+
);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
state.answerBlocks.push(text);
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
pi.on('agent_end', () => {
|
|
965
|
+
const finalStop = state.attestations.at(-1)?.stop_reason ?? 'unknown';
|
|
966
|
+
commitResult(finalStop);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
pi.on('session_shutdown', () => {
|
|
970
|
+
// A shutdown before agent_end means no answer was produced. Record it so the
|
|
971
|
+
// parent sees a typed reason instead of an empty directory.
|
|
972
|
+
if (state.committed) return;
|
|
973
|
+
if (state.terminal === undefined) {
|
|
974
|
+
latch('child_exited_without_commit', 'the delegate child shut down before committing a result');
|
|
975
|
+
}
|
|
976
|
+
if (state.terminal !== undefined) writeTerminalRecord(state.terminal);
|
|
977
|
+
});
|
|
978
|
+
}
|