pi-background-tasks 0.7.0 → 0.7.2
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 +11 -7
- package/TESTING.md +4 -4
- package/TEST_PLAN.md +7 -7
- package/extensions/fusion-child.ts +1 -0
- package/package.json +1 -1
- package/src/core/common.ts +60 -0
- package/src/core/fusion/artifacts.ts +13 -3
- package/src/core/fusion/orchestrator.ts +4 -2
- package/src/core/fusion/pi-child.ts +285 -201
- package/src/core/fusion/types.ts +2 -1
- package/src/core/registry.ts +1 -0
- package/src/extension.ts +31 -14
- package/src/fusion-child-extension.ts +100 -0
- package/src/fusion-extension.ts +29 -13
package/src/extension.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { Type, type Static } from 'typebox';
|
|
|
12
12
|
import {
|
|
13
13
|
DEFAULT_LOG_BYTES,
|
|
14
14
|
MAX_LOG_BYTES,
|
|
15
|
+
deriveCompletionDeliveryGuidance,
|
|
15
16
|
deriveTaskNameFromCommand,
|
|
16
17
|
formatSnapshotList,
|
|
17
18
|
formatUpdateSegment,
|
|
@@ -125,12 +126,15 @@ const BgRunParams = Type.Object({
|
|
|
125
126
|
Type.Number({ description: 'Optional timeout; task is failed and killed when exceeded' }),
|
|
126
127
|
),
|
|
127
128
|
notifyOnCompletion: Type.Optional(
|
|
128
|
-
Type.Boolean({
|
|
129
|
+
Type.Boolean({
|
|
130
|
+
description:
|
|
131
|
+
'Whether to deliver the durable terminal notification. Default: true; disable only when deliberately taking over completion monitoring.',
|
|
132
|
+
}),
|
|
129
133
|
),
|
|
130
134
|
triggerOnCompletion: Type.Optional(
|
|
131
135
|
Type.Boolean({
|
|
132
136
|
description:
|
|
133
|
-
'Whether
|
|
137
|
+
'Whether that notification should automatically trigger a follow-up agent turn. Default: true for bg_run; requires notifyOnCompletion.',
|
|
134
138
|
}),
|
|
135
139
|
),
|
|
136
140
|
});
|
|
@@ -657,15 +661,18 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
657
661
|
pi.registerTool<typeof BgRunParams, BgRunDetails>({
|
|
658
662
|
name: 'bg_run',
|
|
659
663
|
label: 'Background Run',
|
|
660
|
-
description: `Start a named long-running shell command in the background and return immediately with a task ID and output path. Output is written to .pi/tasks and model-visible logs are bounded to ${formatSize(MAX_LOG_BYTES)}.`,
|
|
664
|
+
description: `Start a named long-running shell command in the background and return immediately with a task ID and output path. By default, completed, failed, or killed terminal state is delivered automatically as <background-task-notification> and starts a follow-up agent turn; do not sleep or poll merely to wait. Output is written to .pi/tasks and model-visible logs are bounded to ${formatSize(MAX_LOG_BYTES)}.`,
|
|
661
665
|
promptSnippet:
|
|
662
|
-
'Start named long-running shell
|
|
666
|
+
'Start a named long-running shell command; default terminal notification wakes a follow-up turn, so yield instead of polling',
|
|
663
667
|
promptGuidelines: [
|
|
664
|
-
'Use bg_run instead of bash for commands expected to run for a long time, such as test suites, dev servers, watchers,
|
|
668
|
+
'Use bg_run instead of bash for commands expected to run for a long time, such as test suites, dev servers, watchers, or builds.',
|
|
665
669
|
'Always set isAgent: true only when the background task launches an LLM/agent process; set isAgent: false for scripts, tests, dev servers, sleeps, and ordinary shell commands.',
|
|
666
670
|
'When using bg_run, always set name to a concise 2-6 word human-readable label for the footer task dock; do not use the raw command as the name unless it is already short and meaningful.',
|
|
667
|
-
'
|
|
668
|
-
'
|
|
671
|
+
'bg_run returns immediately. With notifyOnCompletion:true and triggerOnCompletion:true (both defaults), completed, failed, or killed terminal state is delivered as <background-task-notification> and automatically starts a follow-up agent turn.',
|
|
672
|
+
'After a default bg_run launch, continue only independent useful work that does not merely wait for the task; otherwise briefly acknowledge it if useful, then end the current turn. Do not call sleep, bg_status, or bg_logs merely to wait; the terminal notification will wake you.',
|
|
673
|
+
'Treat <background-task-notification> as durable terminal truth. Do not call bg_status to reconfirm it; call bg_logs only when the task output is needed.',
|
|
674
|
+
'Use bg_status/bg_logs only when the user explicitly requests an update, automatic notification or wake-up was deliberately disabled, there is concrete evidence the task is hung, or a terminal notification arrived and output details are needed.',
|
|
675
|
+
'Do not set notifyOnCompletion:false or triggerOnCompletion:false unless intentionally opting out of automatic completion handling.',
|
|
669
676
|
],
|
|
670
677
|
parameters: BgRunParams,
|
|
671
678
|
prepareArguments(args): BgRunParamsValue {
|
|
@@ -708,9 +715,13 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
708
715
|
if (params.description !== undefined) taskOptions.description = params.description;
|
|
709
716
|
if (params.timeoutSeconds !== undefined) taskOptions.timeoutSeconds = params.timeoutSeconds;
|
|
710
717
|
const task = await startTask(ctx, params.command, taskOptions);
|
|
718
|
+
const completionDelivery = deriveCompletionDeliveryGuidance(
|
|
719
|
+
task.notifyOnCompletion,
|
|
720
|
+
task.triggerOnCompletion,
|
|
721
|
+
);
|
|
711
722
|
return {
|
|
712
723
|
content: textContent(
|
|
713
|
-
`Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${String(task.pid ?? 'unknown')}\nOutput: ${task.outputPath}`,
|
|
724
|
+
`Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${String(task.pid ?? 'unknown')}\nOutput: ${task.outputPath}\n${completionDelivery.text}`,
|
|
714
725
|
),
|
|
715
726
|
details: { task: registry.snapshot(task) },
|
|
716
727
|
};
|
|
@@ -800,10 +811,14 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
800
811
|
pi.registerTool<typeof BgStatusParams, BgStatusDetails>({
|
|
801
812
|
name: 'bg_status',
|
|
802
813
|
label: 'Background Status',
|
|
803
|
-
description:
|
|
804
|
-
|
|
814
|
+
description:
|
|
815
|
+
'Inspect one background task or list all running/recent background tasks. This is a point-in-time inspection tool, not a waiting primitive.',
|
|
816
|
+
promptSnippet:
|
|
817
|
+
'Inspect point-in-time status for one or all background tasks; never poll it as a wait loop',
|
|
805
818
|
promptGuidelines: [
|
|
806
|
-
'Use bg_status
|
|
819
|
+
'Use bg_status for deliberate point-in-time inspection, not as a waiting primitive.',
|
|
820
|
+
'A running result is not an instruction to poll again. Do not repeatedly call bg_status while an automatic terminal notification is pending.',
|
|
821
|
+
'Use bg_status when the user explicitly requests an update, automatic completion handling was disabled, or concrete evidence suggests a task is hung; terminal notifications do not need reconfirmation.',
|
|
807
822
|
],
|
|
808
823
|
parameters: BgStatusParams,
|
|
809
824
|
execute(_toolCallId, params) {
|
|
@@ -827,10 +842,12 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
827
842
|
pi.registerTool<typeof BgLogsParams, BgLogsDetails>({
|
|
828
843
|
name: 'bg_logs',
|
|
829
844
|
label: 'Background Logs',
|
|
830
|
-
description: `Read bounded output from a background task. Output is capped at ${formatSize(MAX_LOG_BYTES)} for model safety and points to the full output file when truncated.`,
|
|
831
|
-
promptSnippet: 'Read bounded output
|
|
845
|
+
description: `Read bounded output from a background task for deliberate inspection; this is not a waiting primitive. Output is capped at ${formatSize(MAX_LOG_BYTES)} for model safety and points to the full output file when truncated.`,
|
|
846
|
+
promptSnippet: 'Read bounded task output when needed; never tail it repeatedly as a wait loop',
|
|
832
847
|
promptGuidelines: [
|
|
833
|
-
'Use bg_logs with a modest maxBytes value
|
|
848
|
+
'Use bg_logs with a modest maxBytes value only when task output is needed, without flooding context.',
|
|
849
|
+
'Do not repeatedly call bg_logs to wait for completion while an automatic terminal notification is pending.',
|
|
850
|
+
'Use bg_status first only when a deliberate inspection requires the current task state; do not reconfirm a terminal notification.',
|
|
834
851
|
],
|
|
835
852
|
parameters: BgLogsParams,
|
|
836
853
|
async execute(_toolCallId, params) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
|
|
4
|
+
export const FUSION_CHILD_RESULT_SCHEMA_VERSION =
|
|
5
|
+
'pi-background-tasks.fusion-child-result.v1' as const;
|
|
6
|
+
export const FUSION_CHILD_RESULT_PREFIX = '\u001ePI_FUSION_CHILD_RESULT ';
|
|
7
|
+
|
|
8
|
+
export interface FusionChildTextBlockMetadata {
|
|
9
|
+
utf8_bytes: number;
|
|
10
|
+
sha256: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FusionChildResultUsageMetadata {
|
|
14
|
+
input: number;
|
|
15
|
+
output: number;
|
|
16
|
+
cacheRead: number;
|
|
17
|
+
cacheWrite: number;
|
|
18
|
+
totalTokens: number;
|
|
19
|
+
costTotal?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FusionChildResultMetadata {
|
|
23
|
+
schema_version: typeof FUSION_CHILD_RESULT_SCHEMA_VERSION;
|
|
24
|
+
provider: string;
|
|
25
|
+
model: string;
|
|
26
|
+
stop_reason: string;
|
|
27
|
+
text_blocks: FusionChildTextBlockMetadata[];
|
|
28
|
+
text_sha256: string;
|
|
29
|
+
usage: FusionChildResultUsageMetadata;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sha256(value: string): string {
|
|
33
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildFusionChildResultMetadata(message: {
|
|
37
|
+
provider: string;
|
|
38
|
+
model: string;
|
|
39
|
+
stopReason: string;
|
|
40
|
+
content: ReadonlyArray<{ type: string; text?: string }>;
|
|
41
|
+
usage: {
|
|
42
|
+
input: number;
|
|
43
|
+
output: number;
|
|
44
|
+
cacheRead: number;
|
|
45
|
+
cacheWrite: number;
|
|
46
|
+
totalTokens: number;
|
|
47
|
+
cost: { total: number };
|
|
48
|
+
};
|
|
49
|
+
}): FusionChildResultMetadata {
|
|
50
|
+
const textBlocks = message.content.flatMap((part) =>
|
|
51
|
+
part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
|
|
52
|
+
);
|
|
53
|
+
const usage: FusionChildResultUsageMetadata = {
|
|
54
|
+
input: message.usage.input,
|
|
55
|
+
output: message.usage.output,
|
|
56
|
+
cacheRead: message.usage.cacheRead,
|
|
57
|
+
cacheWrite: message.usage.cacheWrite,
|
|
58
|
+
totalTokens: message.usage.totalTokens,
|
|
59
|
+
};
|
|
60
|
+
if (Number.isFinite(message.usage.cost.total) && message.usage.cost.total >= 0) {
|
|
61
|
+
usage.costTotal = message.usage.cost.total;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
|
|
65
|
+
provider: message.provider,
|
|
66
|
+
model: message.model,
|
|
67
|
+
stop_reason: message.stopReason,
|
|
68
|
+
text_blocks: textBlocks.map((text) => ({
|
|
69
|
+
utf8_bytes: Buffer.byteLength(text, 'utf8'),
|
|
70
|
+
sha256: sha256(text),
|
|
71
|
+
})),
|
|
72
|
+
text_sha256: sha256(textBlocks.join('')),
|
|
73
|
+
usage,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function writeMetadata(record: FusionChildResultMetadata): Promise<void> {
|
|
78
|
+
const line = `${FUSION_CHILD_RESULT_PREFIX}${JSON.stringify(record)}\n`;
|
|
79
|
+
await new Promise<void>((resolve, reject) => {
|
|
80
|
+
process.stderr.write(line, (error) => {
|
|
81
|
+
if (error) reject(error);
|
|
82
|
+
else resolve();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Private Fusion child extension.
|
|
89
|
+
*
|
|
90
|
+
* Pi print mode writes only the final full text to stdout. This extension adds
|
|
91
|
+
* one compact, reasoning-free metadata record to stderr for each finalized
|
|
92
|
+
* assistant message so the parent can validate model identity, stop reason,
|
|
93
|
+
* exact text bytes, and usage without consuming cumulative JSON stream events.
|
|
94
|
+
*/
|
|
95
|
+
export default function fusionChildExtension(pi: ExtensionAPI): void {
|
|
96
|
+
pi.on('message_end', async (event) => {
|
|
97
|
+
if (event.message.role !== 'assistant') return;
|
|
98
|
+
await writeMetadata(buildFusionChildResultMetadata(event.message));
|
|
99
|
+
});
|
|
100
|
+
}
|
package/src/fusion-extension.ts
CHANGED
|
@@ -124,6 +124,17 @@ function errorArtifactSuffix(error: unknown): string {
|
|
|
124
124
|
: '';
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
function toolFailureMessage(error: unknown): string {
|
|
128
|
+
const coordinates: string[] = [];
|
|
129
|
+
if (error instanceof FusionError) {
|
|
130
|
+
if (error.stage !== undefined) coordinates.push(`stage=${error.stage}`);
|
|
131
|
+
if (error.slot !== undefined) coordinates.push(`slot=${String(error.slot)}`);
|
|
132
|
+
if (error.attempt !== undefined) coordinates.push(`attempt=${String(error.attempt)}`);
|
|
133
|
+
}
|
|
134
|
+
const location = coordinates.length === 0 ? '' : ` (${coordinates.join(', ')})`;
|
|
135
|
+
return `Fusion failed${location}: ${errorMessage(error)}${errorArtifactSuffix(error)}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
127
138
|
function progressText(event: FusionProgressEvent): string {
|
|
128
139
|
if (event.type === 'state') return `fusion: ${event.state.replace(/_/g, ' ')}`;
|
|
129
140
|
if (event.type === 'candidate_started') return `fusion: candidate ${String(event.slot)} starting`;
|
|
@@ -546,19 +557,24 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
|
|
|
546
557
|
prepareArguments: prepareFusionArguments,
|
|
547
558
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
548
559
|
const prompt = normalizeToolPrompt(params.prompt);
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
560
|
+
let result: FusionRunResult;
|
|
561
|
+
try {
|
|
562
|
+
result = await runFusion({
|
|
563
|
+
source: 'tool',
|
|
564
|
+
ctx,
|
|
565
|
+
request: prompt,
|
|
566
|
+
signal,
|
|
567
|
+
toolCallId,
|
|
568
|
+
onProgress: (event) => {
|
|
569
|
+
onUpdate?.({
|
|
570
|
+
content: textContent(progressText(event)),
|
|
571
|
+
details: makeProgressDetails(event),
|
|
572
|
+
});
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
} catch (error) {
|
|
576
|
+
throw new Error(toolFailureMessage(error), { cause: error });
|
|
577
|
+
}
|
|
562
578
|
const toolResult: FusionToolResultWithUsage = {
|
|
563
579
|
content: textContent(result.mergedText),
|
|
564
580
|
details: result.details,
|