pi-background-tasks 0.7.6 → 0.9.0
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 +238 -17
- package/TESTING.md +94 -0
- package/TEST_PLAN.md +28 -4
- package/extensions/delegate-child.ts +1 -0
- package/package.json +10 -4
- 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 +460 -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 +64 -4
- package/src/core/fusion/budget.ts +464 -65
- package/src/core/fusion/context.ts +115 -511
- package/src/core/fusion/orchestrator.ts +184 -18
- package/src/core/fusion/pi-child.ts +473 -8
- package/src/core/fusion/prompts.ts +156 -4
- package/src/core/fusion/types.ts +237 -37
- package/src/core/fusion/web-fetch.ts +904 -0
- package/src/core/fusion/workflows.ts +130 -0
- 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-child-extension.ts +279 -2
- package/src/fusion-extension.ts +183 -26
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
Theme,
|
|
7
|
+
ToolRenderResultOptions,
|
|
8
|
+
} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
10
|
+
import { Type, type Static } from 'typebox';
|
|
11
|
+
import type { BgTask, BgTaskSnapshot, StartDelegateTaskOptions } from './core/common.js';
|
|
12
|
+
import { truncateChars } from './core/common.js';
|
|
13
|
+
import {
|
|
14
|
+
DELEGATE_AUTO_DELIVER_MODES,
|
|
15
|
+
DELEGATE_CAPABILITIES,
|
|
16
|
+
DELEGATE_RESULT_TOOL_NAME,
|
|
17
|
+
DELEGATE_TOOL_NAME,
|
|
18
|
+
DelegateError,
|
|
19
|
+
type DelegateAutoDeliverMode,
|
|
20
|
+
type DelegateCapability,
|
|
21
|
+
type DelegateDeliveryMode,
|
|
22
|
+
type DelegateBudgetRouteSource,
|
|
23
|
+
type DelegateRoute,
|
|
24
|
+
} from './core/delegate/types.js';
|
|
25
|
+
import {
|
|
26
|
+
DELEGATE_INLINE_ANSWER_BYTES,
|
|
27
|
+
DELEGATE_DEFAULT_MAX_TOOL_CALLS,
|
|
28
|
+
DELEGATE_DEFAULT_MAX_TURNS,
|
|
29
|
+
DELEGATE_DEFAULT_TIMEOUT_SECONDS,
|
|
30
|
+
} from './core/delegate/budget.js';
|
|
31
|
+
import { resolveDelegateRoute } from './core/delegate/launch.js';
|
|
32
|
+
import {
|
|
33
|
+
decideDelegateDelivery,
|
|
34
|
+
evaluateDelegateTerminal,
|
|
35
|
+
inlineTooLarge,
|
|
36
|
+
prepareDelegateLaunch,
|
|
37
|
+
} from './core/delegate/runner.js';
|
|
38
|
+
import { loadDelegateHookContractEvidence } from './core/delegate/launch.js';
|
|
39
|
+
import type { DelegateHookContractEvidence } from './core/delegate/hook-contract.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `bg_delegate` and `bg_result` registration.
|
|
43
|
+
*
|
|
44
|
+
* `bg_delegate` launches one background child Pi agent seeded with a frozen
|
|
45
|
+
* projection of the current conversation. `bg_result` retrieves that child's
|
|
46
|
+
* hash-verified answer. They ship together: a delegate without a safe retrieval
|
|
47
|
+
* path would have no way to return its work.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Shipped copy of the observed Pi hook-contract evidence.
|
|
52
|
+
*
|
|
53
|
+
* It is produced by executing a real Pi agent loop in
|
|
54
|
+
* `tests/scripted-provider/pi-hook-contract.test.ts`, and a package test asserts
|
|
55
|
+
* the shipped copy is byte-identical to the recorded one, so the runtime gate
|
|
56
|
+
* and the gate that proved it can never drift apart.
|
|
57
|
+
*/
|
|
58
|
+
const HOOK_EVIDENCE_PATH = fileURLToPath(
|
|
59
|
+
new URL('./core/delegate/hook-contract-evidence.json', import.meta.url),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const DelegateParams = Type.Object(
|
|
63
|
+
{
|
|
64
|
+
name: Type.String({
|
|
65
|
+
description: 'Short human-readable task name shown in the bg footer dock. Use 2-6 words.',
|
|
66
|
+
}),
|
|
67
|
+
prompt: Type.String({
|
|
68
|
+
description:
|
|
69
|
+
'Authoritative instruction for the delegate. The projected conversation is supporting background only.',
|
|
70
|
+
}),
|
|
71
|
+
route: Type.Optional(
|
|
72
|
+
Type.Object(
|
|
73
|
+
{
|
|
74
|
+
provider: Type.String({ description: 'Exact provider name to pin.' }),
|
|
75
|
+
model: Type.String({ description: 'Exact provider-local model id to pin.' }),
|
|
76
|
+
},
|
|
77
|
+
{ additionalProperties: false, description: 'Explicit route. Defaults to the current model.' },
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
capability: Type.Optional(
|
|
81
|
+
Type.String({
|
|
82
|
+
description: `Capability profile. Only "inspect" (read/search/list, no shell, no writes, no network, no recursion) is supported.`,
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
maxTurns: Type.Optional(
|
|
86
|
+
Type.Number({ description: `Maximum agent turns. Default ${String(DELEGATE_DEFAULT_MAX_TURNS)}.` }),
|
|
87
|
+
),
|
|
88
|
+
maxToolCalls: Type.Optional(
|
|
89
|
+
Type.Number({
|
|
90
|
+
description: `Maximum tool calls. Default ${String(DELEGATE_DEFAULT_MAX_TOOL_CALLS)}.`,
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
timeoutSeconds: Type.Optional(
|
|
94
|
+
Type.Number({
|
|
95
|
+
description: `Wall-clock timeout. Default ${String(DELEGATE_DEFAULT_TIMEOUT_SECONDS)}.`,
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
autoDeliver: Type.Optional(
|
|
99
|
+
Type.String({
|
|
100
|
+
description:
|
|
101
|
+
'Whether the completion notification carries the answer: never | when_small | always. Default never; retrieve with bg_result.',
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
notifyOnCompletion: Type.Optional(
|
|
105
|
+
Type.Boolean({ description: 'Deliver the durable terminal notification. Default true.' }),
|
|
106
|
+
),
|
|
107
|
+
triggerOnCompletion: Type.Optional(
|
|
108
|
+
Type.Boolean({ description: 'Let that notification start a follow-up turn. Default true.' }),
|
|
109
|
+
),
|
|
110
|
+
},
|
|
111
|
+
{ additionalProperties: false },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const ResultParams = Type.Object(
|
|
115
|
+
{
|
|
116
|
+
taskId: Type.String({ description: 'Delegate task id returned by bg_delegate.' }),
|
|
117
|
+
delivery: Type.Optional(
|
|
118
|
+
Type.String({
|
|
119
|
+
description:
|
|
120
|
+
'inline returns the verified answer text; artifact returns metadata plus the artifact reference. Oversized answers are never truncated.',
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
123
|
+
},
|
|
124
|
+
{ additionalProperties: false },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
type DelegateParamsValue = Static<typeof DelegateParams>;
|
|
128
|
+
type ResultParamsValue = Static<typeof ResultParams>;
|
|
129
|
+
|
|
130
|
+
export interface DelegateLaunchDetails {
|
|
131
|
+
schema_version: 'pi-background-tasks.delegate-launch.v1';
|
|
132
|
+
task: BgTaskSnapshot;
|
|
133
|
+
route: { provider: string; model: string; qualified_id: string; origin: string };
|
|
134
|
+
child_session_id: string;
|
|
135
|
+
artifact_dir: string;
|
|
136
|
+
seed_sha256: string;
|
|
137
|
+
seed_utf8_bytes: number;
|
|
138
|
+
budget: DelegateBudgetRouteSource;
|
|
139
|
+
auto_deliver: DelegateAutoDeliverMode;
|
|
140
|
+
notify_on_completion: boolean;
|
|
141
|
+
trigger_on_completion: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface DelegateResultDetails {
|
|
145
|
+
schema_version: 'pi-background-tasks.delegate-result-view.v1';
|
|
146
|
+
task_id: string;
|
|
147
|
+
state: 'running' | 'committed' | 'failed' | 'cancelled';
|
|
148
|
+
delivery: DelegateDeliveryMode | 'none';
|
|
149
|
+
route?: { provider: string; model: string } | undefined;
|
|
150
|
+
budget?: DelegateBudgetRouteSource | undefined;
|
|
151
|
+
answer_bytes?: number | undefined;
|
|
152
|
+
answer_sha256?: string | undefined;
|
|
153
|
+
turns?: number | undefined;
|
|
154
|
+
tool_calls?: number | undefined;
|
|
155
|
+
usage?: { status: string } | undefined;
|
|
156
|
+
artifact_dir?: string | undefined;
|
|
157
|
+
error_code?: string | undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function textContent(text: string) {
|
|
161
|
+
return [{ type: 'text' as const, text }];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
|
165
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function requireCapability(value: unknown): DelegateCapability {
|
|
169
|
+
if (value === undefined) return 'inspect';
|
|
170
|
+
if (value === 'inspect') return 'inspect';
|
|
171
|
+
throw new DelegateError(
|
|
172
|
+
`bg_delegate capability must be one of ${DELEGATE_CAPABILITIES.join(', ')}. Writable profiles are deliberately out of scope in this version.`,
|
|
173
|
+
{ code: 'invalid_arguments', childCreated: false },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function requireAutoDeliver(value: unknown): DelegateAutoDeliverMode {
|
|
178
|
+
if (value === undefined) return 'never';
|
|
179
|
+
if (value === 'never' || value === 'when_small' || value === 'always') return value;
|
|
180
|
+
throw new DelegateError(
|
|
181
|
+
`bg_delegate autoDeliver must be one of ${DELEGATE_AUTO_DELIVER_MODES.join(', ')}`,
|
|
182
|
+
{ code: 'invalid_arguments', childCreated: false },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function requireDelivery(value: unknown): DelegateDeliveryMode | undefined {
|
|
187
|
+
if (value === undefined) return undefined;
|
|
188
|
+
if (value === 'inline' || value === 'artifact') return value;
|
|
189
|
+
throw new DelegateError('bg_result delivery must be inline or artifact', {
|
|
190
|
+
code: 'invalid_arguments',
|
|
191
|
+
childCreated: false,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function requireRoute(value: unknown): DelegateRoute | undefined {
|
|
196
|
+
if (value === undefined) return undefined;
|
|
197
|
+
if (!isRecord(value)) {
|
|
198
|
+
throw new DelegateError('bg_delegate route must be an object', {
|
|
199
|
+
code: 'invalid_arguments',
|
|
200
|
+
childCreated: false,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const provider = value['provider'];
|
|
204
|
+
const model = value['model'];
|
|
205
|
+
if (typeof provider !== 'string' || provider.length === 0)
|
|
206
|
+
throw new DelegateError('bg_delegate route.provider must be a non-empty string', {
|
|
207
|
+
code: 'invalid_arguments',
|
|
208
|
+
childCreated: false,
|
|
209
|
+
});
|
|
210
|
+
if (typeof model !== 'string' || model.length === 0)
|
|
211
|
+
throw new DelegateError('bg_delegate route.model must be a non-empty string', {
|
|
212
|
+
code: 'invalid_arguments',
|
|
213
|
+
childCreated: false,
|
|
214
|
+
});
|
|
215
|
+
return { provider, model };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function optionalPositiveInteger(value: unknown, label: string): number | undefined {
|
|
219
|
+
if (value === undefined) return undefined;
|
|
220
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0)
|
|
221
|
+
throw new DelegateError(`bg_delegate ${label} must be a positive integer`, {
|
|
222
|
+
code: 'invalid_arguments',
|
|
223
|
+
childCreated: false,
|
|
224
|
+
});
|
|
225
|
+
return value;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface DelegateExtensionDependencies {
|
|
229
|
+
startDelegateTask: (
|
|
230
|
+
ctx: ExtensionContext,
|
|
231
|
+
options: StartDelegateTaskOptions,
|
|
232
|
+
) => Promise<BgTask>;
|
|
233
|
+
snapshot: (task: BgTask) => BgTaskSnapshot;
|
|
234
|
+
resolveTask: (idOrPrefix: string) => BgTask;
|
|
235
|
+
/** Overridable so tests can supply observed evidence without touching disk. */
|
|
236
|
+
loadHookEvidence?: (() => Promise<DelegateHookContractEvidence>) | undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function defaultHookEvidence(): Promise<DelegateHookContractEvidence> {
|
|
240
|
+
let raw: string;
|
|
241
|
+
try {
|
|
242
|
+
raw = await readFile(HOOK_EVIDENCE_PATH, 'utf8');
|
|
243
|
+
} catch (error) {
|
|
244
|
+
throw new DelegateError(
|
|
245
|
+
`bg_delegate cannot verify the Pi hook contract: the recorded evidence at ${HOOK_EVIDENCE_PATH} is unreadable (${error instanceof Error ? error.message : String(error)}). No child was created.`,
|
|
246
|
+
{
|
|
247
|
+
code: 'delegate_hook_contract_unsupported',
|
|
248
|
+
childCreated: false,
|
|
249
|
+
remediation: [
|
|
250
|
+
'Run the Pi hook characterisation gate to regenerate the evidence for this Pi build.',
|
|
251
|
+
'The guard is never bypassed when its evidence is missing.',
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return loadDelegateHookContractEvidence(raw);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function registerDelegateExtension(
|
|
260
|
+
pi: ExtensionAPI,
|
|
261
|
+
deps: DelegateExtensionDependencies,
|
|
262
|
+
): void {
|
|
263
|
+
const loadEvidence = deps.loadHookEvidence ?? defaultHookEvidence;
|
|
264
|
+
|
|
265
|
+
pi.registerTool<typeof DelegateParams, DelegateLaunchDetails>({
|
|
266
|
+
name: DELEGATE_TOOL_NAME,
|
|
267
|
+
label: 'Background Delegate',
|
|
268
|
+
description:
|
|
269
|
+
'Launch one background Pi agent seeded with a frozen projection of the current conversation, then return a launch receipt immediately. The child has its own session, a route pinned at launch that is never substituted, and read-only tools. Retrieve its verified answer with bg_result.',
|
|
270
|
+
promptSnippet:
|
|
271
|
+
'Delegate an investigation to a background agent that already has this conversation as context',
|
|
272
|
+
promptGuidelines: [
|
|
273
|
+
'Use bg_delegate when work should continue in the background and the worker needs what you already know: it is seeded with a projection of this conversation.',
|
|
274
|
+
'The prompt is authoritative. State exactly what you want investigated and what the answer should contain.',
|
|
275
|
+
'The delegate is inspect-only: it can read, search, and list files, but cannot run shell commands, edit or write files, use the network, or delegate further.',
|
|
276
|
+
'Facts that exist only inside omitted tool output are not available to the delegate. Restate such findings in the prompt.',
|
|
277
|
+
'bg_delegate returns immediately. Do not poll; retrieve the answer with bg_result after the terminal notification arrives.',
|
|
278
|
+
],
|
|
279
|
+
parameters: DelegateParams,
|
|
280
|
+
prepareArguments(args): DelegateParamsValue {
|
|
281
|
+
if (!isRecord(args))
|
|
282
|
+
throw new DelegateError('bg_delegate arguments must be an object', {
|
|
283
|
+
code: 'invalid_arguments',
|
|
284
|
+
childCreated: false,
|
|
285
|
+
});
|
|
286
|
+
const name = args['name'];
|
|
287
|
+
const prompt = args['prompt'];
|
|
288
|
+
if (typeof name !== 'string' || name.trim().length === 0)
|
|
289
|
+
throw new DelegateError('bg_delegate requires a non-empty name', {
|
|
290
|
+
code: 'invalid_arguments',
|
|
291
|
+
childCreated: false,
|
|
292
|
+
});
|
|
293
|
+
if (typeof prompt !== 'string' || prompt.trim().length === 0)
|
|
294
|
+
throw new DelegateError('bg_delegate requires a non-blank prompt', {
|
|
295
|
+
code: 'invalid_arguments',
|
|
296
|
+
childCreated: false,
|
|
297
|
+
});
|
|
298
|
+
const prepared: DelegateParamsValue = { name, prompt };
|
|
299
|
+
const route = requireRoute(args['route']);
|
|
300
|
+
if (route !== undefined) prepared.route = route;
|
|
301
|
+
prepared.capability = requireCapability(args['capability']);
|
|
302
|
+
prepared.autoDeliver = requireAutoDeliver(args['autoDeliver']);
|
|
303
|
+
const maxTurns = optionalPositiveInteger(args['maxTurns'], 'maxTurns');
|
|
304
|
+
if (maxTurns !== undefined) prepared.maxTurns = maxTurns;
|
|
305
|
+
const maxToolCalls = optionalPositiveInteger(args['maxToolCalls'], 'maxToolCalls');
|
|
306
|
+
if (maxToolCalls !== undefined) prepared.maxToolCalls = maxToolCalls;
|
|
307
|
+
const timeoutSeconds = optionalPositiveInteger(args['timeoutSeconds'], 'timeoutSeconds');
|
|
308
|
+
if (timeoutSeconds !== undefined) prepared.timeoutSeconds = timeoutSeconds;
|
|
309
|
+
const notify = args['notifyOnCompletion'];
|
|
310
|
+
if (typeof notify === 'boolean') prepared.notifyOnCompletion = notify;
|
|
311
|
+
const trigger = args['triggerOnCompletion'];
|
|
312
|
+
if (typeof trigger === 'boolean') prepared.triggerOnCompletion = trigger;
|
|
313
|
+
return prepared;
|
|
314
|
+
},
|
|
315
|
+
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
316
|
+
const capability = requireCapability(params.capability);
|
|
317
|
+
const autoDeliver = requireAutoDeliver(params.autoDeliver);
|
|
318
|
+
const hookEvidence = await loadEvidence();
|
|
319
|
+
const route = resolveDelegateRoute({
|
|
320
|
+
requested: params.route,
|
|
321
|
+
currentModel:
|
|
322
|
+
ctx.model === undefined
|
|
323
|
+
? undefined
|
|
324
|
+
: {
|
|
325
|
+
provider: ctx.model.provider,
|
|
326
|
+
id: ctx.model.id,
|
|
327
|
+
contextWindow: ctx.model.contextWindow,
|
|
328
|
+
},
|
|
329
|
+
availableModels: ctx.modelRegistry
|
|
330
|
+
.getAll()
|
|
331
|
+
.map((model) => ({
|
|
332
|
+
provider: model.provider,
|
|
333
|
+
id: model.id,
|
|
334
|
+
contextWindow: model.contextWindow,
|
|
335
|
+
})),
|
|
336
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const prepared = await prepareDelegateLaunch({
|
|
340
|
+
ctx: {
|
|
341
|
+
cwd: ctx.cwd,
|
|
342
|
+
sessionManager: ctx.sessionManager,
|
|
343
|
+
getSystemPrompt: () => ctx.getSystemPrompt(),
|
|
344
|
+
},
|
|
345
|
+
toolCallId,
|
|
346
|
+
prompt: params.prompt,
|
|
347
|
+
capability,
|
|
348
|
+
route,
|
|
349
|
+
limitOverrides: {
|
|
350
|
+
maxTurns: params.maxTurns,
|
|
351
|
+
maxToolCalls: params.maxToolCalls,
|
|
352
|
+
timeoutSeconds: params.timeoutSeconds,
|
|
353
|
+
},
|
|
354
|
+
hookEvidence,
|
|
355
|
+
cwd: ctx.cwd,
|
|
356
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
357
|
+
autoDeliver,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const launchOptions: StartDelegateTaskOptions = {
|
|
361
|
+
name: params.name,
|
|
362
|
+
argv: prepared.argv,
|
|
363
|
+
stdinBytes: prepared.stdinBytes,
|
|
364
|
+
env: prepared.env,
|
|
365
|
+
facts: prepared.facts,
|
|
366
|
+
notifyOnCompletion: params.notifyOnCompletion ?? true,
|
|
367
|
+
triggerOnCompletion: params.triggerOnCompletion ?? true,
|
|
368
|
+
timeoutSeconds: prepared.preflight.limits.timeout_seconds,
|
|
369
|
+
};
|
|
370
|
+
const task = await deps.startDelegateTask(ctx, launchOptions);
|
|
371
|
+
|
|
372
|
+
const details: DelegateLaunchDetails = {
|
|
373
|
+
schema_version: 'pi-background-tasks.delegate-launch.v1',
|
|
374
|
+
task: deps.snapshot(task),
|
|
375
|
+
route: {
|
|
376
|
+
provider: route.provider,
|
|
377
|
+
model: route.model,
|
|
378
|
+
qualified_id: route.qualified_id,
|
|
379
|
+
origin: route.origin,
|
|
380
|
+
},
|
|
381
|
+
child_session_id: prepared.preflight.childSessionId,
|
|
382
|
+
artifact_dir: prepared.facts.artifactDir,
|
|
383
|
+
seed_sha256: prepared.facts.seedSha256,
|
|
384
|
+
seed_utf8_bytes: prepared.preflight.plan.seed_utf8_bytes,
|
|
385
|
+
budget: prepared.facts.budget,
|
|
386
|
+
auto_deliver: autoDeliver,
|
|
387
|
+
notify_on_completion: launchOptions.notifyOnCompletion,
|
|
388
|
+
trigger_on_completion: launchOptions.triggerOnCompletion,
|
|
389
|
+
};
|
|
390
|
+
return {
|
|
391
|
+
content: textContent(
|
|
392
|
+
[
|
|
393
|
+
`Started delegate ${params.name} (${task.id})`,
|
|
394
|
+
`Route pinned: ${route.qualified_id} (${route.origin}); it is never substituted.`,
|
|
395
|
+
`Child session: ${prepared.preflight.childSessionId} (separate from this session)`,
|
|
396
|
+
`Artifacts: ${prepared.facts.artifactDir}`,
|
|
397
|
+
`Seed: ${String(prepared.preflight.plan.seed_utf8_bytes)} bytes, sha256 ${prepared.facts.seedSha256}`,
|
|
398
|
+
`Estimator: family ${prepared.facts.budget.family}, source ${prepared.facts.budget.rate_source.source}, rate ${String(prepared.facts.budget.rate_source.effective_rate_bytes_per_token_x100)}/100 B/tok + ${String(prepared.facts.budget.rate_source.affine_f_tokens)} tokens${prepared.facts.budget.rate_source.warning === null ? '' : `; warning: ${prepared.facts.budget.rate_source.warning}`}`,
|
|
399
|
+
`Capability: ${capability} (read/search/list only)`,
|
|
400
|
+
`Limits: ${String(prepared.preflight.limits.max_turns)} turns, ${String(prepared.preflight.limits.max_tool_calls)} tool calls, ${String(prepared.preflight.limits.timeout_seconds)}s`,
|
|
401
|
+
`Auto-deliver: ${autoDeliver}`,
|
|
402
|
+
launchOptions.notifyOnCompletion
|
|
403
|
+
? `Terminal notification: enabled.${launchOptions.triggerOnCompletion ? ' It will start a follow-up turn.' : ' It will not start a turn.'}`
|
|
404
|
+
: 'Terminal notification: disabled.',
|
|
405
|
+
`Retrieve the verified answer with ${DELEGATE_RESULT_TOOL_NAME}({taskId:"${task.id}"}). Do not poll.`,
|
|
406
|
+
].join('\n'),
|
|
407
|
+
),
|
|
408
|
+
details,
|
|
409
|
+
};
|
|
410
|
+
},
|
|
411
|
+
renderCall(args, theme) {
|
|
412
|
+
return new Text(
|
|
413
|
+
`${theme.fg('toolTitle', theme.bold('bg_delegate '))}${theme.fg('muted', truncateChars(args.name, 60))}`,
|
|
414
|
+
0,
|
|
415
|
+
0,
|
|
416
|
+
);
|
|
417
|
+
},
|
|
418
|
+
renderResult(result, _options, theme) {
|
|
419
|
+
const details = result.details;
|
|
420
|
+
return new Text(
|
|
421
|
+
`${theme.fg('success', '✓ delegated')} ${theme.fg('accent', details.task.id)}\n${theme.fg('dim', `route ${details.route.qualified_id} · seed ${String(details.seed_utf8_bytes)}B · ${details.artifact_dir}`)}`,
|
|
422
|
+
0,
|
|
423
|
+
0,
|
|
424
|
+
);
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
pi.registerTool<typeof ResultParams, DelegateResultDetails>({
|
|
429
|
+
name: DELEGATE_RESULT_TOOL_NAME,
|
|
430
|
+
label: 'Delegate Result',
|
|
431
|
+
description:
|
|
432
|
+
'Retrieve the hash-verified answer from a bg_delegate task. Never blocks: a running task returns a typed not-ready result. A completed answer is verified against its recorded SHA-256 before it is returned, and an oversized answer is never truncated.',
|
|
433
|
+
promptSnippet: 'Retrieve the verified answer from a completed bg_delegate task',
|
|
434
|
+
promptGuidelines: [
|
|
435
|
+
'Call bg_result once the delegate terminal notification has arrived. It never blocks and must not be polled.',
|
|
436
|
+
'A not-ready result means the delegate is still running; end the turn and wait for the notification.',
|
|
437
|
+
],
|
|
438
|
+
parameters: ResultParams,
|
|
439
|
+
prepareArguments(args): ResultParamsValue {
|
|
440
|
+
if (!isRecord(args))
|
|
441
|
+
throw new DelegateError('bg_result arguments must be an object', {
|
|
442
|
+
code: 'invalid_arguments',
|
|
443
|
+
childCreated: false,
|
|
444
|
+
});
|
|
445
|
+
const taskId = args['taskId'];
|
|
446
|
+
if (typeof taskId !== 'string' || taskId.trim().length === 0)
|
|
447
|
+
throw new DelegateError('bg_result requires taskId', {
|
|
448
|
+
code: 'invalid_arguments',
|
|
449
|
+
childCreated: false,
|
|
450
|
+
});
|
|
451
|
+
const prepared: ResultParamsValue = { taskId };
|
|
452
|
+
const delivery = requireDelivery(args['delivery']);
|
|
453
|
+
if (delivery !== undefined) prepared.delivery = delivery;
|
|
454
|
+
return prepared;
|
|
455
|
+
},
|
|
456
|
+
async execute(_toolCallId, params) {
|
|
457
|
+
let task: BgTask;
|
|
458
|
+
try {
|
|
459
|
+
task = deps.resolveTask(params.taskId);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
throw new DelegateError(
|
|
462
|
+
`bg_result does not know task ${params.taskId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
463
|
+
{ code: 'task_unknown', childCreated: false },
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const facts = task.delegate;
|
|
467
|
+
if (facts === undefined) {
|
|
468
|
+
throw new DelegateError(
|
|
469
|
+
`bg_result task ${task.id} is not a bg_delegate task; use bg_logs for ordinary background tasks`,
|
|
470
|
+
{ code: 'task_unknown', childCreated: false },
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
if (task.status === 'running') {
|
|
474
|
+
const details: DelegateResultDetails = {
|
|
475
|
+
schema_version: 'pi-background-tasks.delegate-result-view.v1',
|
|
476
|
+
task_id: task.id,
|
|
477
|
+
state: 'running',
|
|
478
|
+
delivery: 'none',
|
|
479
|
+
artifact_dir: facts.artifactDir,
|
|
480
|
+
budget: facts.budget,
|
|
481
|
+
};
|
|
482
|
+
return {
|
|
483
|
+
content: textContent(
|
|
484
|
+
`Delegate ${task.id} is still running. This is not an error and bg_result never blocks. End this turn; the terminal notification will wake you, then call bg_result again.`,
|
|
485
|
+
),
|
|
486
|
+
details,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const terminal = await evaluateDelegateTerminal({
|
|
491
|
+
artifactDirAbs: facts.artifactDirAbs,
|
|
492
|
+
taskId: facts.taskId,
|
|
493
|
+
launchNonce: facts.launchNonce,
|
|
494
|
+
seedSha256: facts.seedSha256,
|
|
495
|
+
route: { provider: facts.route.provider, model: facts.route.model },
|
|
496
|
+
taskStatus: task.status === 'completed' ? 'completed' : task.status,
|
|
497
|
+
taskError: task.error,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
if (terminal.error !== undefined || terminal.result === undefined) {
|
|
501
|
+
const failure =
|
|
502
|
+
terminal.error ??
|
|
503
|
+
new DelegateError(`delegate ${task.id} produced no verified answer`, {
|
|
504
|
+
code: 'result_unavailable',
|
|
505
|
+
childCreated: true,
|
|
506
|
+
taskId: task.id,
|
|
507
|
+
artifactDir: facts.artifactDir,
|
|
508
|
+
});
|
|
509
|
+
throw new Error(failure.describe(), { cause: failure });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const verified = terminal.result;
|
|
513
|
+
const requestedDelivery = requireDelivery(params.delivery);
|
|
514
|
+
const decision = decideDelegateDelivery(
|
|
515
|
+
verified.package.answer.byte_length,
|
|
516
|
+
requestedDelivery,
|
|
517
|
+
);
|
|
518
|
+
if (requestedDelivery === 'inline' && decision.mode === 'artifact') {
|
|
519
|
+
const failure = inlineTooLarge(
|
|
520
|
+
task.id,
|
|
521
|
+
facts.artifactDir,
|
|
522
|
+
verified.package.answer.byte_length,
|
|
523
|
+
);
|
|
524
|
+
throw new Error(failure.describe(), { cause: failure });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const details: DelegateResultDetails = {
|
|
528
|
+
schema_version: 'pi-background-tasks.delegate-result-view.v1',
|
|
529
|
+
task_id: task.id,
|
|
530
|
+
state: 'committed',
|
|
531
|
+
delivery: decision.mode,
|
|
532
|
+
route: verified.package.route,
|
|
533
|
+
budget: facts.budget,
|
|
534
|
+
answer_bytes: verified.package.answer.byte_length,
|
|
535
|
+
answer_sha256: verified.package.answer.sha256,
|
|
536
|
+
turns: verified.package.turns,
|
|
537
|
+
tool_calls: verified.package.tool_calls,
|
|
538
|
+
usage: { status: verified.package.usage.status },
|
|
539
|
+
artifact_dir: facts.artifactDir,
|
|
540
|
+
};
|
|
541
|
+
const header = [
|
|
542
|
+
`Delegate ${task.id} completed on ${verified.package.route.provider}/${verified.package.route.model}.`,
|
|
543
|
+
`Answer: ${String(verified.package.answer.byte_length)} bytes, sha256 ${verified.package.answer.sha256} (verified).`,
|
|
544
|
+
`Turns: ${String(verified.package.turns)} · tool calls: ${String(verified.package.tool_calls)} · usage: ${verified.package.usage.status}`,
|
|
545
|
+
`Artifacts: ${facts.artifactDir}`,
|
|
546
|
+
`Estimator: family ${facts.budget.family}, source ${facts.budget.rate_source.source}, rate ${String(facts.budget.rate_source.effective_rate_bytes_per_token_x100)}/100 B/tok + ${String(facts.budget.rate_source.affine_f_tokens)} tokens${facts.budget.rate_source.warning === null ? '' : `; warning: ${facts.budget.rate_source.warning}`}`,
|
|
547
|
+
].join('\n');
|
|
548
|
+
if (decision.mode === 'artifact') {
|
|
549
|
+
return {
|
|
550
|
+
content: textContent(
|
|
551
|
+
`${header}\nDelivery: artifact (${decision.reason}). The complete verified answer is in ${facts.artifactDir}/result.json. It was not truncated.`,
|
|
552
|
+
),
|
|
553
|
+
details,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return { content: textContent(`${header}\n\n${verified.answer}`), details };
|
|
557
|
+
},
|
|
558
|
+
renderCall(args, theme) {
|
|
559
|
+
return new Text(
|
|
560
|
+
`${theme.fg('toolTitle', theme.bold('bg_result '))}${theme.fg('accent', args.taskId)}`,
|
|
561
|
+
0,
|
|
562
|
+
0,
|
|
563
|
+
);
|
|
564
|
+
},
|
|
565
|
+
renderResult(result, options: ToolRenderResultOptions, theme: Theme) {
|
|
566
|
+
void options;
|
|
567
|
+
const details = result.details;
|
|
568
|
+
if (details.state === 'running')
|
|
569
|
+
return new Text(theme.fg('warning', `delegate ${details.task_id} still running`), 0, 0);
|
|
570
|
+
return new Text(
|
|
571
|
+
`${theme.fg('success', '✓ delegate answer')} ${theme.fg('dim', `${String(details.answer_bytes ?? 0)}B · ${details.delivery}`)}`,
|
|
572
|
+
0,
|
|
573
|
+
0,
|
|
574
|
+
);
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
pi.on('session_start', () => {
|
|
579
|
+
const active = pi.getActiveTools();
|
|
580
|
+
const missing = [DELEGATE_TOOL_NAME, DELEGATE_RESULT_TOOL_NAME].filter(
|
|
581
|
+
(name) => !active.includes(name),
|
|
582
|
+
);
|
|
583
|
+
if (missing.length > 0) pi.setActiveTools([...active, ...missing]);
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export { DELEGATE_INLINE_ANSWER_BYTES };
|
package/src/extension.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
type TaskManagerResult,
|
|
48
48
|
} from './ui/background-tasks-manager.js';
|
|
49
49
|
import { registerFusionExtension } from './fusion-extension.js';
|
|
50
|
+
import { registerDelegateExtension } from './delegate-extension.js';
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* Project-local Pi background task manager.
|
|
@@ -232,6 +233,15 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
232
233
|
isShuttingDown: () => registry.isShuttingDown(),
|
|
233
234
|
});
|
|
234
235
|
|
|
236
|
+
registerDelegateExtension(pi, {
|
|
237
|
+
startDelegateTask: async (ctx, options) => {
|
|
238
|
+
currentCtx = ctx;
|
|
239
|
+
return registry.startDelegateTask(ctx, options);
|
|
240
|
+
},
|
|
241
|
+
snapshot: (task) => registry.snapshot(task),
|
|
242
|
+
resolveTask: (idOrPrefix) => registry.resolveTask(idOrPrefix),
|
|
243
|
+
});
|
|
244
|
+
|
|
235
245
|
function unseenFinishedTasks(): BgTask[] {
|
|
236
246
|
return registry
|
|
237
247
|
.allTasks()
|