pi-background-tasks 0.7.6 → 0.7.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/PUBLISHING.md +7 -7
- package/README.md +177 -13
- package/TESTING.md +94 -0
- package/TEST_PLAN.md +21 -4
- package/extensions/delegate-child.ts +1 -0
- package/package.json +4 -3
- package/src/core/common.ts +41 -0
- package/src/core/context/parent-snapshot.ts +142 -0
- package/src/core/context/token-budget.ts +890 -0
- package/src/core/context/visible-conversation-v2.ts +551 -0
- package/src/core/delegate/artifacts.ts +479 -0
- package/src/core/delegate/budget.ts +370 -0
- package/src/core/delegate/hook-contract-evidence.json +18 -0
- package/src/core/delegate/hook-contract.ts +153 -0
- package/src/core/delegate/launch.ts +459 -0
- package/src/core/delegate/result-package.ts +443 -0
- package/src/core/delegate/runner.ts +406 -0
- package/src/core/delegate/seed.ts +411 -0
- package/src/core/delegate/types.ts +304 -0
- package/src/core/fusion/artifacts.ts +15 -0
- package/src/core/fusion/budget.ts +444 -54
- package/src/core/fusion/context.ts +108 -509
- package/src/core/fusion/orchestrator.ts +116 -5
- package/src/core/fusion/prompts.ts +5 -1
- package/src/core/fusion/types.ts +154 -36
- package/src/core/registry.ts +174 -0
- package/src/delegate-child-extension.ts +673 -0
- package/src/delegate-extension.ts +587 -0
- package/src/extension.ts +10 -0
- package/src/fusion-extension.ts +2 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TOKEN_BUDGET_CALIBRATION_VERSION,
|
|
3
|
+
TOKEN_BUDGET_FAMILY_CALIBRATIONS,
|
|
4
|
+
TOKEN_BUDGET_RATE_SCALE,
|
|
5
|
+
estimateInputTokens,
|
|
6
|
+
knownTextSegment,
|
|
7
|
+
maxKnownTextBytesForTokens,
|
|
8
|
+
resolveTokenBudgetFamily,
|
|
9
|
+
utf8ByteClassBreakdown,
|
|
10
|
+
allowedInputTokens,
|
|
11
|
+
isUsableContextWindow,
|
|
12
|
+
type EstimateInputTokensResult,
|
|
13
|
+
type TokenBudgetByteClassBreakdown,
|
|
14
|
+
type TokenBudgetFamily,
|
|
15
|
+
type TokenBudgetFamilyCalibration,
|
|
16
|
+
type TokenBudgetRateSource,
|
|
17
|
+
} from '../context/token-budget.js';
|
|
18
|
+
import {
|
|
19
|
+
DELEGATE_BUDGET_PLAN_SCHEMA_VERSION,
|
|
20
|
+
DelegateError,
|
|
21
|
+
type DelegateLimits,
|
|
22
|
+
type DelegatePinnedRoute,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Delegate budgeting.
|
|
27
|
+
*
|
|
28
|
+
* A delegate child is a multi-turn, tool-using agent, so its budget has two
|
|
29
|
+
* distinct phases rather than Fusion's single-shot stage forecast:
|
|
30
|
+
*
|
|
31
|
+
* 1. Launch admission checks the frozen seed, framing, and child system prompt.
|
|
32
|
+
* 2. The runtime governor checks the complete retained input before each call.
|
|
33
|
+
*
|
|
34
|
+
* Nothing here clamps, downgrades, or silently reduces. An input that does not
|
|
35
|
+
* fit is a typed refusal.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Output tokens reserved so the child can always finish an answer. */
|
|
39
|
+
export const DELEGATE_RESERVED_OUTPUT_TOKENS = 16_384;
|
|
40
|
+
/** Provider/tool-schema framing the package does not directly control. */
|
|
41
|
+
export const DELEGATE_FRAMING_RESERVE_TOKENS = 8_192;
|
|
42
|
+
export const DELEGATE_SAFETY_RESERVE_TOKENS = 4_096;
|
|
43
|
+
/** Below this, a route cannot hold a useful seed plus real investigation. */
|
|
44
|
+
export const DELEGATE_MIN_USABLE_INPUT_TOKENS = 8_192;
|
|
45
|
+
export const DELEGATE_MIN_CONTEXT_WINDOW_TOKENS =
|
|
46
|
+
DELEGATE_MIN_USABLE_INPUT_TOKENS +
|
|
47
|
+
DELEGATE_RESERVED_OUTPUT_TOKENS +
|
|
48
|
+
DELEGATE_FRAMING_RESERVE_TOKENS +
|
|
49
|
+
DELEGATE_SAFETY_RESERVE_TOKENS;
|
|
50
|
+
|
|
51
|
+
export const DELEGATE_DEFAULT_MAX_TURNS = 24;
|
|
52
|
+
export const DELEGATE_DEFAULT_MAX_TOOL_CALLS = 120;
|
|
53
|
+
export const DELEGATE_DEFAULT_TIMEOUT_SECONDS = 900;
|
|
54
|
+
export const DELEGATE_MAX_TOOL_RESULT_BYTES = 64 * 1024;
|
|
55
|
+
export const DELEGATE_MAX_TOTAL_TOOL_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
56
|
+
export const DELEGATE_MAX_ANSWER_BYTES = 4 * 1024 * 1024;
|
|
57
|
+
/** Answers at or under this serialize inline; larger ones degrade explicitly. */
|
|
58
|
+
export const DELEGATE_INLINE_ANSWER_BYTES = 48 * 1024;
|
|
59
|
+
|
|
60
|
+
export const DELEGATE_BUDGET_POLICY_ID = 'delegate-budget-policy-v2';
|
|
61
|
+
|
|
62
|
+
export interface DelegateBudgetPolicyDescriptor {
|
|
63
|
+
id: typeof DELEGATE_BUDGET_POLICY_ID;
|
|
64
|
+
calibration_version: string;
|
|
65
|
+
calibration_table: Readonly<Record<TokenBudgetFamily, TokenBudgetFamilyCalibration>>;
|
|
66
|
+
reserved_output_tokens: number;
|
|
67
|
+
framing_reserve_tokens: number;
|
|
68
|
+
safety_reserve_tokens: number;
|
|
69
|
+
min_usable_input_tokens: number;
|
|
70
|
+
inline_answer_bytes: number;
|
|
71
|
+
estimator_scope: 'delegate';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const DELEGATE_BUDGET_POLICY: DelegateBudgetPolicyDescriptor = {
|
|
75
|
+
id: DELEGATE_BUDGET_POLICY_ID,
|
|
76
|
+
calibration_version: TOKEN_BUDGET_CALIBRATION_VERSION,
|
|
77
|
+
calibration_table: TOKEN_BUDGET_FAMILY_CALIBRATIONS,
|
|
78
|
+
reserved_output_tokens: DELEGATE_RESERVED_OUTPUT_TOKENS,
|
|
79
|
+
framing_reserve_tokens: DELEGATE_FRAMING_RESERVE_TOKENS,
|
|
80
|
+
safety_reserve_tokens: DELEGATE_SAFETY_RESERVE_TOKENS,
|
|
81
|
+
min_usable_input_tokens: DELEGATE_MIN_USABLE_INPUT_TOKENS,
|
|
82
|
+
inline_answer_bytes: DELEGATE_INLINE_ANSWER_BYTES,
|
|
83
|
+
estimator_scope: 'delegate',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export interface DelegateAdmissionPlanV1 {
|
|
87
|
+
schema_version: typeof DELEGATE_BUDGET_PLAN_SCHEMA_VERSION;
|
|
88
|
+
policy: DelegateBudgetPolicyDescriptor;
|
|
89
|
+
route: {
|
|
90
|
+
provider: string;
|
|
91
|
+
model: string;
|
|
92
|
+
qualified_id: string;
|
|
93
|
+
context_window_tokens: number;
|
|
94
|
+
allowed_input_tokens: number;
|
|
95
|
+
family: TokenBudgetFamily;
|
|
96
|
+
backed: boolean;
|
|
97
|
+
rate_source: TokenBudgetRateSource;
|
|
98
|
+
byte_capacity_utf8_bytes: number;
|
|
99
|
+
};
|
|
100
|
+
seed_utf8_bytes: number;
|
|
101
|
+
seed_multibyte_utf8_bytes: number;
|
|
102
|
+
system_prompt_utf8_bytes: number;
|
|
103
|
+
system_prompt_multibyte_utf8_bytes: number;
|
|
104
|
+
launch_utf8_bytes: number;
|
|
105
|
+
launch_input_tokens_upper_bound: number;
|
|
106
|
+
signed_headroom_tokens: number;
|
|
107
|
+
utilization_basis_points: number;
|
|
108
|
+
byte_class_breakdown: TokenBudgetByteClassBreakdown;
|
|
109
|
+
dominant_byte_class: EstimateInputTokensResult['rateSource']['dominant_byte_class'];
|
|
110
|
+
estimate: EstimateInputTokensResult;
|
|
111
|
+
fits: boolean;
|
|
112
|
+
limits: DelegateLimits;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function utilizationBasisPoints(tokens: number, allowed: number): number {
|
|
116
|
+
if (!Number.isSafeInteger(tokens) || tokens < 0) {
|
|
117
|
+
throw new TypeError('tokens must be a non-negative safe integer');
|
|
118
|
+
}
|
|
119
|
+
if (!Number.isSafeInteger(allowed) || allowed <= 0) {
|
|
120
|
+
throw new TypeError('allowed must be a positive safe integer');
|
|
121
|
+
}
|
|
122
|
+
return Math.floor(((tokens * 10_000) + allowed - 1) / allowed);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function routeFamily(route: DelegatePinnedRoute): ReturnType<typeof resolveTokenBudgetFamily> {
|
|
126
|
+
return resolveTokenBudgetFamily({ provider: route.provider, model: route.model });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Usable input tokens for a pinned route.
|
|
131
|
+
*
|
|
132
|
+
* A route with an unknown, non-integral, or non-positive context window is a
|
|
133
|
+
* loud `route_capacity_unknown` refusal. The delegate never assumes a default
|
|
134
|
+
* window, because assuming one is how oversized prompts reach a provider.
|
|
135
|
+
*/
|
|
136
|
+
export function delegateAllowedInputTokens(route: DelegatePinnedRoute): number {
|
|
137
|
+
if (!isUsableContextWindow(route.context_window_tokens)) {
|
|
138
|
+
throw new DelegateError(
|
|
139
|
+
`bg_delegate route ${route.qualified_id} reports no usable context-window capacity`,
|
|
140
|
+
{
|
|
141
|
+
code: 'route_capacity_unknown',
|
|
142
|
+
childCreated: false,
|
|
143
|
+
remediation: [
|
|
144
|
+
'Pin an explicit route whose model catalogue entry declares a context window.',
|
|
145
|
+
'No child was created and no capacity was assumed.',
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const allowed = allowedInputTokens(route.context_window_tokens, {
|
|
151
|
+
reservedOutputTokens: DELEGATE_RESERVED_OUTPUT_TOKENS,
|
|
152
|
+
framingReserveTokens: DELEGATE_FRAMING_RESERVE_TOKENS,
|
|
153
|
+
safetyReserveTokens: DELEGATE_SAFETY_RESERVE_TOKENS,
|
|
154
|
+
});
|
|
155
|
+
if (allowed < DELEGATE_MIN_USABLE_INPUT_TOKENS) {
|
|
156
|
+
throw new DelegateError(
|
|
157
|
+
`bg_delegate route ${route.qualified_id} has a ${String(route.context_window_tokens)}-token context window, but a delegate child requires at least ${String(DELEGATE_MIN_CONTEXT_WINDOW_TOKENS)} tokens: ${String(DELEGATE_RESERVED_OUTPUT_TOKENS)} output + ${String(DELEGATE_FRAMING_RESERVE_TOKENS)} framing + ${String(DELEGATE_SAFETY_RESERVE_TOKENS)} safety + ${String(DELEGATE_MIN_USABLE_INPUT_TOKENS)} usable input`,
|
|
158
|
+
{
|
|
159
|
+
code: 'route_capacity_unknown',
|
|
160
|
+
childCreated: false,
|
|
161
|
+
remediation: ['Pin a larger-context route for this delegate.'],
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return allowed;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface DelegateAdmissionInput {
|
|
169
|
+
route: DelegatePinnedRoute;
|
|
170
|
+
seedSerialized: string;
|
|
171
|
+
childSystemPrompt: string;
|
|
172
|
+
limits: DelegateLimits;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Deterministic launch-admission forecast. Pure; creates nothing. */
|
|
176
|
+
export function planDelegateAdmission(input: DelegateAdmissionInput): DelegateAdmissionPlanV1 {
|
|
177
|
+
const allowed = delegateAllowedInputTokens(input.route);
|
|
178
|
+
const family = routeFamily(input.route);
|
|
179
|
+
const seed = utf8ByteClassBreakdown(input.seedSerialized);
|
|
180
|
+
const system = utf8ByteClassBreakdown(input.childSystemPrompt);
|
|
181
|
+
const estimate = estimateInputTokens({
|
|
182
|
+
family: family.family,
|
|
183
|
+
calibrationBacked: family.backed,
|
|
184
|
+
familyResolution: family.resolution,
|
|
185
|
+
allowedInputTokens: allowed,
|
|
186
|
+
scope: 'delegate',
|
|
187
|
+
segments: [knownTextSegment(input.seedSerialized), knownTextSegment(input.childSystemPrompt)],
|
|
188
|
+
});
|
|
189
|
+
const byteCapacity = Math.floor(
|
|
190
|
+
(allowed * estimate.rateSource.effective_rate_bytes_per_token_x100) / TOKEN_BUDGET_RATE_SCALE,
|
|
191
|
+
);
|
|
192
|
+
const launchBytes = seed.bytes + system.bytes;
|
|
193
|
+
return {
|
|
194
|
+
schema_version: DELEGATE_BUDGET_PLAN_SCHEMA_VERSION,
|
|
195
|
+
policy: DELEGATE_BUDGET_POLICY,
|
|
196
|
+
route: {
|
|
197
|
+
provider: input.route.provider,
|
|
198
|
+
model: input.route.model,
|
|
199
|
+
qualified_id: input.route.qualified_id,
|
|
200
|
+
context_window_tokens: input.route.context_window_tokens,
|
|
201
|
+
allowed_input_tokens: allowed,
|
|
202
|
+
family: family.family,
|
|
203
|
+
backed: estimate.rateSource.backed,
|
|
204
|
+
rate_source: estimate.rateSource,
|
|
205
|
+
byte_capacity_utf8_bytes: byteCapacity,
|
|
206
|
+
},
|
|
207
|
+
seed_utf8_bytes: seed.bytes,
|
|
208
|
+
seed_multibyte_utf8_bytes: seed.multibyteBytes,
|
|
209
|
+
system_prompt_utf8_bytes: system.bytes,
|
|
210
|
+
system_prompt_multibyte_utf8_bytes: system.multibyteBytes,
|
|
211
|
+
launch_utf8_bytes: launchBytes,
|
|
212
|
+
launch_input_tokens_upper_bound: estimate.tokens,
|
|
213
|
+
signed_headroom_tokens: allowed - estimate.tokens,
|
|
214
|
+
utilization_basis_points: utilizationBasisPoints(estimate.tokens, allowed),
|
|
215
|
+
byte_class_breakdown: estimate.byte_class_breakdown,
|
|
216
|
+
dominant_byte_class: estimate.rateSource.dominant_byte_class,
|
|
217
|
+
estimate,
|
|
218
|
+
fits: estimate.tokens <= allowed,
|
|
219
|
+
limits: input.limits,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function rateWarningText(rateSource: TokenBudgetRateSource, qualifiedId: string): string {
|
|
224
|
+
if (rateSource.warning === null) return '';
|
|
225
|
+
return ` Estimator warning for ${qualifiedId}: ${rateSource.warning}.`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function requiredByteReduction(plan: DelegateAdmissionPlanV1): number {
|
|
229
|
+
return Math.max(
|
|
230
|
+
0,
|
|
231
|
+
plan.launch_utf8_bytes -
|
|
232
|
+
maxKnownTextBytesForTokens({
|
|
233
|
+
family: plan.route.family,
|
|
234
|
+
calibrationBacked: plan.route.rate_source.backed,
|
|
235
|
+
familyResolution: plan.route.rate_source.model_resolution,
|
|
236
|
+
allowedInputTokens: plan.route.allowed_input_tokens,
|
|
237
|
+
scope: 'delegate',
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Enforce the admission plan.
|
|
244
|
+
*
|
|
245
|
+
* Called before the child process, the child session, and the artifact
|
|
246
|
+
* directory exist, so a refusal leaves zero children and zero artifacts.
|
|
247
|
+
*/
|
|
248
|
+
export function assertDelegateAdmission(plan: DelegateAdmissionPlanV1): void {
|
|
249
|
+
if (plan.fits) return;
|
|
250
|
+
const overage = plan.launch_input_tokens_upper_bound - plan.route.allowed_input_tokens;
|
|
251
|
+
throw new DelegateError(
|
|
252
|
+
`bg_delegate seed does not fit the pinned route before launch. Route ${plan.route.qualified_id} allows ${String(plan.route.allowed_input_tokens)} input tokens; the frozen seed plus the child system prompt measure ${String(plan.launch_utf8_bytes)} UTF-8 bytes (<= ${String(plan.launch_input_tokens_upper_bound)} input tokens), over by ${String(overage)} tokens. Estimator family ${plan.route.family}, source ${plan.route.rate_source.source}, backed=${String(plan.route.rate_source.backed)}, dominant_byte_class=${plan.dominant_byte_class}, rate ${String(plan.route.rate_source.effective_rate_bytes_per_token_x100)}/100 B/tok + ${String(plan.route.rate_source.affine_f_tokens)} tokens.${rateWarningText(plan.route.rate_source, plan.route.qualified_id)} Required reduction is at least ${String(requiredByteReduction(plan))} UTF-8 bytes. No child process, child session, or artifact was created. Nothing was clipped, dropped, or substituted.`,
|
|
253
|
+
{
|
|
254
|
+
code: 'seed_budget_exceeded',
|
|
255
|
+
childCreated: false,
|
|
256
|
+
budget: {
|
|
257
|
+
measurement_kind: 'launch_admission',
|
|
258
|
+
measured_utf8_bytes: plan.launch_utf8_bytes,
|
|
259
|
+
measured_input_tokens_upper_bound: plan.launch_input_tokens_upper_bound,
|
|
260
|
+
allowed_input_tokens: plan.route.allowed_input_tokens,
|
|
261
|
+
rate_source: plan.route.rate_source,
|
|
262
|
+
backed: plan.route.rate_source.backed,
|
|
263
|
+
dominant_byte_class: plan.dominant_byte_class,
|
|
264
|
+
byte_class_breakdown: plan.byte_class_breakdown,
|
|
265
|
+
},
|
|
266
|
+
remediation: [
|
|
267
|
+
'Pin a larger-context route with the route argument.',
|
|
268
|
+
'Delegate earlier in the session, or start a fresh conversation, so less history is projected.',
|
|
269
|
+
'Restate only the required findings as visible conversation text; omitted tool payloads are not what is large here.',
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface DelegateRuntimeMeasurement {
|
|
276
|
+
/** Complete retained input for the next model call, in UTF-8 bytes. */
|
|
277
|
+
retainedInputBytes: number;
|
|
278
|
+
retainedInputMultibyteBytes: number;
|
|
279
|
+
retainedInputDenseBytes: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface DelegateGovernorVerdict {
|
|
283
|
+
withinBudget: boolean;
|
|
284
|
+
measuredTokens: number;
|
|
285
|
+
allowedTokens: number;
|
|
286
|
+
overageTokens: number;
|
|
287
|
+
byteClassBreakdown: TokenBudgetByteClassBreakdown;
|
|
288
|
+
dominantByteClass: EstimateInputTokensResult['rateSource']['dominant_byte_class'];
|
|
289
|
+
backed: boolean;
|
|
290
|
+
rateSource: TokenBudgetRateSource;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Runtime governor decision for one prospective model call.
|
|
295
|
+
*
|
|
296
|
+
* Pure and total, so the child guard can call it from inside a hook with no
|
|
297
|
+
* possibility of throwing where a throw would be swallowed.
|
|
298
|
+
*/
|
|
299
|
+
export function evaluateDelegateRuntimeBudget(
|
|
300
|
+
measurement: DelegateRuntimeMeasurement,
|
|
301
|
+
allowedTokens: number,
|
|
302
|
+
route: { provider: string; model: string },
|
|
303
|
+
): DelegateGovernorVerdict {
|
|
304
|
+
const family = resolveTokenBudgetFamily(route);
|
|
305
|
+
const estimate = estimateInputTokens({
|
|
306
|
+
family: family.family,
|
|
307
|
+
calibrationBacked: family.backed,
|
|
308
|
+
familyResolution: family.resolution,
|
|
309
|
+
allowedInputTokens: allowedTokens,
|
|
310
|
+
scope: 'delegate',
|
|
311
|
+
segments: [
|
|
312
|
+
{
|
|
313
|
+
kind: 'known_text',
|
|
314
|
+
bytes: measurement.retainedInputBytes,
|
|
315
|
+
multibyteBytes: measurement.retainedInputMultibyteBytes,
|
|
316
|
+
denseBytes: measurement.retainedInputDenseBytes,
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
return {
|
|
321
|
+
withinBudget: estimate.tokens <= allowedTokens,
|
|
322
|
+
measuredTokens: estimate.tokens,
|
|
323
|
+
allowedTokens,
|
|
324
|
+
overageTokens: Math.max(0, estimate.tokens - allowedTokens),
|
|
325
|
+
byteClassBreakdown: estimate.byte_class_breakdown,
|
|
326
|
+
dominantByteClass: estimate.rateSource.dominant_byte_class,
|
|
327
|
+
backed: estimate.rateSource.backed,
|
|
328
|
+
rateSource: estimate.rateSource,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface DelegateLimitOverrides {
|
|
333
|
+
maxTurns?: number | undefined;
|
|
334
|
+
maxToolCalls?: number | undefined;
|
|
335
|
+
timeoutSeconds?: number | undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function positiveInteger(value: number | undefined, fallback: number, label: string): number {
|
|
339
|
+
if (value === undefined) return fallback;
|
|
340
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
341
|
+
throw new DelegateError(`bg_delegate ${label} must be a positive integer`, {
|
|
342
|
+
code: 'invalid_arguments',
|
|
343
|
+
childCreated: false,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return value;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function resolveDelegateLimits(
|
|
350
|
+
route: DelegatePinnedRoute,
|
|
351
|
+
overrides: DelegateLimitOverrides = {},
|
|
352
|
+
): DelegateLimits {
|
|
353
|
+
return {
|
|
354
|
+
max_turns: positiveInteger(overrides.maxTurns, DELEGATE_DEFAULT_MAX_TURNS, 'maxTurns'),
|
|
355
|
+
max_tool_calls: positiveInteger(
|
|
356
|
+
overrides.maxToolCalls,
|
|
357
|
+
DELEGATE_DEFAULT_MAX_TOOL_CALLS,
|
|
358
|
+
'maxToolCalls',
|
|
359
|
+
),
|
|
360
|
+
timeout_seconds: positiveInteger(
|
|
361
|
+
overrides.timeoutSeconds,
|
|
362
|
+
DELEGATE_DEFAULT_TIMEOUT_SECONDS,
|
|
363
|
+
'timeoutSeconds',
|
|
364
|
+
),
|
|
365
|
+
max_tool_result_bytes: DELEGATE_MAX_TOOL_RESULT_BYTES,
|
|
366
|
+
max_total_tool_output_bytes: DELEGATE_MAX_TOTAL_TOOL_OUTPUT_BYTES,
|
|
367
|
+
max_answer_bytes: DELEGATE_MAX_ANSWER_BYTES,
|
|
368
|
+
allowed_input_tokens: delegateAllowedInputTokens(route),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "pi-background-tasks.delegate-hook-contract.v1",
|
|
3
|
+
"contract_id": "context-measure-abort-v1+tool-result-spill-v1",
|
|
4
|
+
"guarantees": {
|
|
5
|
+
"context_fires_before_every_model_call": true,
|
|
6
|
+
"context_result_messages_reach_provider": true,
|
|
7
|
+
"context_abort_blocks_provider_call": true,
|
|
8
|
+
"context_abort_skips_stream_invocation": false,
|
|
9
|
+
"context_abort_terminates_run": true,
|
|
10
|
+
"context_throw_blocks_provider_call": false,
|
|
11
|
+
"context_throw_isolated_to_throwing_handler": true,
|
|
12
|
+
"tool_result_fires_before_transcript_entry": true,
|
|
13
|
+
"tool_result_replacement_reaches_provider": true,
|
|
14
|
+
"tool_result_replacement_preserves_identity": true,
|
|
15
|
+
"tool_result_chains_in_load_order": true,
|
|
16
|
+
"handlers_run_in_extension_load_order": true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi extension-hook contract required by the `bg_delegate` child-side guard.
|
|
3
|
+
*
|
|
4
|
+
* The guard installed inside a delegate child depends on runtime behaviour of
|
|
5
|
+
* Pi's `context` and `tool_result` hooks. That behaviour is proven by the
|
|
6
|
+
* `tests/scripted-provider/pi-hook-contract.test.ts` characterisation gate,
|
|
7
|
+
* which drives a real Pi agent loop and writes the observed guarantees to
|
|
8
|
+
* `tests/scripted-provider/pi-hook-contract-evidence.json`.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here infers behaviour from type declarations. A guarantee is either
|
|
11
|
+
* observed by that gate or the delegate launch refuses to spawn a child.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DELEGATE_HOOK_CONTRACT_SCHEMA_VERSION =
|
|
15
|
+
'pi-background-tasks.delegate-hook-contract.v1' as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Identifier for the exact guard mechanism the child installs.
|
|
19
|
+
*
|
|
20
|
+
* `context-measure-abort-v1`: measure the outgoing message set inside the
|
|
21
|
+
* `context` hook and, when it would exceed the pinned route window, call
|
|
22
|
+
* `ctx.abort()` so the request is never issued, then report a typed failure over
|
|
23
|
+
* the child result channel.
|
|
24
|
+
*
|
|
25
|
+
* Empirically established by the characterisation gate on Pi 0.83:
|
|
26
|
+
* `ctx.abort()` does NOT skip the `streamSimple` call site. Pi still invokes the
|
|
27
|
+
* provider entry point, but hands it an already-aborted `AbortSignal`, so no
|
|
28
|
+
* network request is issued and the run terminates with stop reason `aborted`.
|
|
29
|
+
* Throwing from a `context` handler is NOT a barrier at all: Pi catches the
|
|
30
|
+
* exception, reports it as an extension error, and dispatches the call anyway.
|
|
31
|
+
* The guard therefore uses abort, never a throw, and additionally suppresses the
|
|
32
|
+
* oversized content itself so a non-conforming provider cannot transmit it.
|
|
33
|
+
*
|
|
34
|
+
* `tool-result-spill-v1`: replace an oversized `tool_result` payload with an
|
|
35
|
+
* explicit hash-accounted receipt before it enters the transcript.
|
|
36
|
+
*/
|
|
37
|
+
export const DELEGATE_HOOK_CONTRACT_ID = 'context-measure-abort-v1+tool-result-spill-v1' as const;
|
|
38
|
+
|
|
39
|
+
export const DELEGATE_HOOK_GUARANTEE_NAMES = [
|
|
40
|
+
'context_fires_before_every_model_call',
|
|
41
|
+
'context_result_messages_reach_provider',
|
|
42
|
+
'context_abort_blocks_provider_call',
|
|
43
|
+
'context_abort_skips_stream_invocation',
|
|
44
|
+
'context_abort_terminates_run',
|
|
45
|
+
'context_throw_blocks_provider_call',
|
|
46
|
+
'context_throw_isolated_to_throwing_handler',
|
|
47
|
+
'tool_result_fires_before_transcript_entry',
|
|
48
|
+
'tool_result_replacement_reaches_provider',
|
|
49
|
+
'tool_result_replacement_preserves_identity',
|
|
50
|
+
'tool_result_chains_in_load_order',
|
|
51
|
+
'handlers_run_in_extension_load_order',
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
export type DelegateHookGuaranteeName = (typeof DELEGATE_HOOK_GUARANTEE_NAMES)[number];
|
|
55
|
+
|
|
56
|
+
export type DelegateHookGuarantees = Readonly<Record<DelegateHookGuaranteeName, boolean>>;
|
|
57
|
+
|
|
58
|
+
export interface DelegateHookContractEvidence {
|
|
59
|
+
schema_version: typeof DELEGATE_HOOK_CONTRACT_SCHEMA_VERSION;
|
|
60
|
+
contract_id: typeof DELEGATE_HOOK_CONTRACT_ID;
|
|
61
|
+
guarantees: DelegateHookGuarantees;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Guarantees the child guard actually depends on.
|
|
66
|
+
*
|
|
67
|
+
* `context_throw_blocks_provider_call` and
|
|
68
|
+
* `context_abort_skips_stream_invocation` are deliberately absent, because
|
|
69
|
+
* neither holds on Pi 0.83. The gate records both as observed evidence, and the
|
|
70
|
+
* guard is built so that it does not need either: it aborts the run AND removes
|
|
71
|
+
* the oversized content from the outgoing message set, so the request cannot be
|
|
72
|
+
* issued and could not carry the content even if it were.
|
|
73
|
+
*/
|
|
74
|
+
export const DELEGATE_REQUIRED_HOOK_GUARANTEES: readonly DelegateHookGuaranteeName[] = [
|
|
75
|
+
'context_fires_before_every_model_call',
|
|
76
|
+
'context_result_messages_reach_provider',
|
|
77
|
+
'context_abort_blocks_provider_call',
|
|
78
|
+
'context_abort_terminates_run',
|
|
79
|
+
'context_throw_isolated_to_throwing_handler',
|
|
80
|
+
'tool_result_fires_before_transcript_entry',
|
|
81
|
+
'tool_result_replacement_reaches_provider',
|
|
82
|
+
'tool_result_replacement_preserves_identity',
|
|
83
|
+
'tool_result_chains_in_load_order',
|
|
84
|
+
'handlers_run_in_extension_load_order',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export interface DelegateHookContractVerdict {
|
|
88
|
+
supported: boolean;
|
|
89
|
+
missing: readonly DelegateHookGuaranteeName[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function evaluateDelegateHookContract(
|
|
93
|
+
evidence: DelegateHookContractEvidence,
|
|
94
|
+
): DelegateHookContractVerdict {
|
|
95
|
+
const missing = DELEGATE_REQUIRED_HOOK_GUARANTEES.filter(
|
|
96
|
+
(guarantee) => evidence.guarantees[guarantee] !== true,
|
|
97
|
+
);
|
|
98
|
+
return { supported: missing.length === 0, missing };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
|
102
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Strict parse. A malformed or partial evidence file is a loud failure, never a default-allow. */
|
|
106
|
+
export function parseDelegateHookContractEvidence(value: unknown): DelegateHookContractEvidence {
|
|
107
|
+
if (!isRecord(value)) throw new Error('delegate hook-contract evidence must be an object');
|
|
108
|
+
if (value['schema_version'] !== DELEGATE_HOOK_CONTRACT_SCHEMA_VERSION) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`delegate hook-contract evidence schema_version must be ${DELEGATE_HOOK_CONTRACT_SCHEMA_VERSION}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (value['contract_id'] !== DELEGATE_HOOK_CONTRACT_ID) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`delegate hook-contract evidence contract_id must be ${DELEGATE_HOOK_CONTRACT_ID}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const raw = value['guarantees'];
|
|
119
|
+
if (!isRecord(raw)) throw new Error('delegate hook-contract evidence guarantees must be an object');
|
|
120
|
+
const keys = Object.keys(raw).sort();
|
|
121
|
+
const expected = [...DELEGATE_HOOK_GUARANTEE_NAMES].sort();
|
|
122
|
+
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`delegate hook-contract evidence guarantees keys mismatch: expected ${expected.join(', ')}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const flag = (name: DelegateHookGuaranteeName): boolean => {
|
|
128
|
+
const observed = raw[name];
|
|
129
|
+
if (typeof observed !== 'boolean') {
|
|
130
|
+
throw new Error(`delegate hook-contract evidence guarantee ${name} must be a boolean`);
|
|
131
|
+
}
|
|
132
|
+
return observed;
|
|
133
|
+
};
|
|
134
|
+
const guarantees: DelegateHookGuarantees = {
|
|
135
|
+
context_fires_before_every_model_call: flag('context_fires_before_every_model_call'),
|
|
136
|
+
context_result_messages_reach_provider: flag('context_result_messages_reach_provider'),
|
|
137
|
+
context_abort_blocks_provider_call: flag('context_abort_blocks_provider_call'),
|
|
138
|
+
context_abort_skips_stream_invocation: flag('context_abort_skips_stream_invocation'),
|
|
139
|
+
context_abort_terminates_run: flag('context_abort_terminates_run'),
|
|
140
|
+
context_throw_blocks_provider_call: flag('context_throw_blocks_provider_call'),
|
|
141
|
+
context_throw_isolated_to_throwing_handler: flag('context_throw_isolated_to_throwing_handler'),
|
|
142
|
+
tool_result_fires_before_transcript_entry: flag('tool_result_fires_before_transcript_entry'),
|
|
143
|
+
tool_result_replacement_reaches_provider: flag('tool_result_replacement_reaches_provider'),
|
|
144
|
+
tool_result_replacement_preserves_identity: flag('tool_result_replacement_preserves_identity'),
|
|
145
|
+
tool_result_chains_in_load_order: flag('tool_result_chains_in_load_order'),
|
|
146
|
+
handlers_run_in_extension_load_order: flag('handlers_run_in_extension_load_order'),
|
|
147
|
+
};
|
|
148
|
+
return {
|
|
149
|
+
schema_version: DELEGATE_HOOK_CONTRACT_SCHEMA_VERSION,
|
|
150
|
+
contract_id: DELEGATE_HOOK_CONTRACT_ID,
|
|
151
|
+
guarantees,
|
|
152
|
+
};
|
|
153
|
+
}
|