@pi-unipi/fusion 2.18.1 → 2.19.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/README.md +5 -2
- package/package.json +3 -3
- package/src/sidekick-runtime.ts +62 -19
- package/src/tools.ts +11 -3
package/README.md
CHANGED
|
@@ -92,8 +92,11 @@ persist across handoffs. The sidekick's context compacts independently of the
|
|
|
92
92
|
lead's (it is its own pi session). `sidekick({message, block:true})` waits by
|
|
93
93
|
default; `block:false` returns immediately and delivers a
|
|
94
94
|
`<subagent_completion_notification>`. Calling it while busy steers the same
|
|
95
|
-
handoff.
|
|
96
|
-
|
|
95
|
+
handoff. Background tasks keep that handoff open through their completion
|
|
96
|
+
notification and any follow-up turn, so the report is not released at an
|
|
97
|
+
intermediate checkpoint. If a prompt arrives while the child is processing,
|
|
98
|
+
Fusion retries it once with pi's `followUp` streaming behavior.
|
|
99
|
+
`read_subagent({agent_id?, block?, timeout?})` reads or waits for a handoff.
|
|
97
100
|
|
|
98
101
|
The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
|
|
99
102
|
Fusion extension guard prevents child processes from registering Fusion tools,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/fusion",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "2.
|
|
36
|
-
"@pi-unipi/subagents": "2.
|
|
35
|
+
"@pi-unipi/core": "2.19.0",
|
|
36
|
+
"@pi-unipi/subagents": "2.19.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-ai": "^0.84.0",
|
package/src/sidekick-runtime.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface SidekickSpawnConfig {
|
|
|
15
15
|
spawn?: typeof defaultSpawn;
|
|
16
16
|
command?: { command: string; args: string[] };
|
|
17
17
|
onProgress?: () => void;
|
|
18
|
+
settleGraceMs?: number;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface SidekickUsage {
|
|
@@ -54,11 +55,15 @@ export interface HandoffReport {
|
|
|
54
55
|
|
|
55
56
|
interface PendingHandoff {
|
|
56
57
|
id: string;
|
|
58
|
+
message: string;
|
|
57
59
|
startedAt: number;
|
|
58
60
|
usage: SidekickUsage;
|
|
59
61
|
progress: HandoffProgress;
|
|
62
|
+
retriedPrompt: boolean;
|
|
63
|
+
openBgTasks: number;
|
|
64
|
+
settled: boolean;
|
|
65
|
+
settleTimer?: NodeJS.Timeout;
|
|
60
66
|
resolve: (report: HandoffReport) => void;
|
|
61
|
-
reject: (error: Error) => void;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
const emptyUsage = (): SidekickUsage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
@@ -193,16 +198,35 @@ export class SidekickRuntime {
|
|
|
193
198
|
}
|
|
194
199
|
}
|
|
195
200
|
|
|
201
|
+
private requestLastAssistantText(): void {
|
|
202
|
+
const current = this.pending;
|
|
203
|
+
if (current === undefined) return;
|
|
204
|
+
this.responseText = (text) => this.finish(this.abortRequested ? "aborted" : this.pendingError === undefined ? "completed" : "error", text, this.pendingError);
|
|
205
|
+
this.responseError = (error) => this.finish("error", undefined, error.message);
|
|
206
|
+
try {
|
|
207
|
+
this.send({ type: "get_last_assistant_text" });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
this.finish("error", undefined, error instanceof Error ? error.message : String(error));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
196
213
|
private handleMessage(message: Record<string, unknown>): void {
|
|
197
214
|
if (message.type === "response") {
|
|
198
215
|
const command = message.command;
|
|
199
216
|
if (command === "prompt" && message.success === false) {
|
|
200
|
-
const
|
|
217
|
+
const errorText = String(message.error ?? "Sidekick prompt rejected");
|
|
201
218
|
const current = this.pending;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
if (current === undefined) return;
|
|
220
|
+
if (/already processing/i.test(errorText) && !current.retriedPrompt) {
|
|
221
|
+
current.retriedPrompt = true;
|
|
222
|
+
try {
|
|
223
|
+
this.send({ id: current.id, type: "prompt", message: current.message, streamingBehavior: "followUp" });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.finish("error", undefined, error instanceof Error ? error.message : String(error));
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
this.finish("error", undefined, errorText);
|
|
229
|
+
}
|
|
206
230
|
} else if (command === "get_last_assistant_text") {
|
|
207
231
|
const data = message.data as Record<string, unknown> | undefined;
|
|
208
232
|
const text = typeof data?.text === "string" ? data.text : this.pending?.progress.textTail ?? "";
|
|
@@ -224,6 +248,7 @@ export class SidekickRuntime {
|
|
|
224
248
|
this.closeOpenText();
|
|
225
249
|
this.pending.progress.toolCalls += 1;
|
|
226
250
|
const args = message.args !== undefined && typeof message.args === "object" && message.args !== null ? message.args as Record<string, unknown> : undefined;
|
|
251
|
+
if (message.toolName === "bg_run" && args?.notifyOnCompletion !== false && args?.triggerOnCompletion !== false) this.pending.openBgTasks += 1;
|
|
227
252
|
const argsText = args === undefined ? "" : JSON.stringify(args).replace(/\s+/gu, " ");
|
|
228
253
|
const summary = `${String(message.toolName ?? "tool")}(${argsText})`.slice(0, 40);
|
|
229
254
|
this.pending.progress.recentTools = [...this.pending.progress.recentTools, summary].slice(-6);
|
|
@@ -237,6 +262,7 @@ export class SidekickRuntime {
|
|
|
237
262
|
event.endedAt = Date.now();
|
|
238
263
|
event.isError = message.isError === true;
|
|
239
264
|
event.output = this.toolOutput(message.result);
|
|
265
|
+
if (event.name === "bg_run" && event.isError) this.pending.openBgTasks = Math.max(0, this.pending.openBgTasks - 1);
|
|
240
266
|
}
|
|
241
267
|
this.notifyProgress();
|
|
242
268
|
} else if (message.type === "message_update") {
|
|
@@ -251,7 +277,19 @@ export class SidekickRuntime {
|
|
|
251
277
|
}
|
|
252
278
|
} else if (message.type === "message_end") {
|
|
253
279
|
const msg = message.message as Record<string, unknown> | undefined;
|
|
254
|
-
if (msg?.role === "
|
|
280
|
+
if (msg?.role === "custom" && msg.customType === "background-task-notification") {
|
|
281
|
+
this.pending.openBgTasks = Math.max(0, this.pending.openBgTasks - 1);
|
|
282
|
+
if (this.pending.openBgTasks === 0 && this.pending.settled) {
|
|
283
|
+
clearTimeout(this.pending.settleTimer);
|
|
284
|
+
this.pending.settleTimer = setTimeout(() => {
|
|
285
|
+
const current = this.pending;
|
|
286
|
+
if (current === undefined || !current.settled || current.openBgTasks > 0) return;
|
|
287
|
+
this.requestLastAssistantText();
|
|
288
|
+
}, this.cfg.settleGraceMs ?? 3000);
|
|
289
|
+
this.pending.settleTimer.unref();
|
|
290
|
+
}
|
|
291
|
+
this.notifyProgress();
|
|
292
|
+
} else if (msg?.role === "assistant") {
|
|
255
293
|
this.closeOpenText();
|
|
256
294
|
this.notifyProgress();
|
|
257
295
|
if (msg.stopReason === "error" && typeof msg.errorMessage === "string") this.pendingError = msg.errorMessage;
|
|
@@ -267,16 +305,18 @@ export class SidekickRuntime {
|
|
|
267
305
|
});
|
|
268
306
|
}
|
|
269
307
|
}
|
|
308
|
+
} else if (message.type === "agent_start") {
|
|
309
|
+
clearTimeout(this.pending.settleTimer);
|
|
310
|
+
this.pending.settleTimer = undefined;
|
|
311
|
+
this.pending.settled = false;
|
|
312
|
+
this.notifyProgress();
|
|
270
313
|
} else if (message.type === "agent_settled") {
|
|
271
|
-
this.
|
|
272
|
-
this.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
try {
|
|
276
|
-
this.send({ type: "get_last_assistant_text" });
|
|
277
|
-
} catch (error) {
|
|
278
|
-
this.finish("error", undefined, error instanceof Error ? error.message : String(error));
|
|
314
|
+
this.pending.settled = true;
|
|
315
|
+
if (this.pending.openBgTasks > 0) {
|
|
316
|
+
this.notifyProgress();
|
|
317
|
+
return;
|
|
279
318
|
}
|
|
319
|
+
this.requestLastAssistantText();
|
|
280
320
|
}
|
|
281
321
|
}
|
|
282
322
|
|
|
@@ -293,6 +333,8 @@ export class SidekickRuntime {
|
|
|
293
333
|
private finish(status: HandoffReport["status"], text?: string, error?: string): void {
|
|
294
334
|
const current = this.pending;
|
|
295
335
|
if (current === undefined) return;
|
|
336
|
+
clearTimeout(current.settleTimer);
|
|
337
|
+
current.settleTimer = undefined;
|
|
296
338
|
this.pending = undefined;
|
|
297
339
|
this.responseText = undefined;
|
|
298
340
|
this.responseError = undefined;
|
|
@@ -323,19 +365,20 @@ export class SidekickRuntime {
|
|
|
323
365
|
const id = randomUUID();
|
|
324
366
|
const startedAt = Date.now();
|
|
325
367
|
let resolve!: (report: HandoffReport) => void;
|
|
326
|
-
|
|
327
|
-
const done = new Promise<HandoffReport>((res, rej) => {
|
|
368
|
+
const done = new Promise<HandoffReport>((res) => {
|
|
328
369
|
resolve = res;
|
|
329
|
-
reject = rej;
|
|
330
370
|
});
|
|
331
371
|
this.pendingError = undefined;
|
|
332
372
|
this.pending = {
|
|
333
373
|
id,
|
|
374
|
+
message,
|
|
334
375
|
startedAt,
|
|
335
376
|
usage: emptyUsage(),
|
|
336
377
|
progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt, events: [], droppedEvents: 0 },
|
|
378
|
+
retriedPrompt: false,
|
|
379
|
+
openBgTasks: 0,
|
|
380
|
+
settled: false,
|
|
337
381
|
resolve,
|
|
338
|
-
reject,
|
|
339
382
|
};
|
|
340
383
|
this.latestHandoff = { id, done };
|
|
341
384
|
try {
|
package/src/tools.ts
CHANGED
|
@@ -60,7 +60,7 @@ async function waitForReport(
|
|
|
60
60
|
ctx: ExtensionContext,
|
|
61
61
|
onUpdate?: (update: unknown) => void,
|
|
62
62
|
timeoutMs = 2700000,
|
|
63
|
-
): Promise<{ report?: HandoffReport; interrupted?: string; aborted?: boolean }> {
|
|
63
|
+
): Promise<{ report?: HandoffReport; interrupted?: string; aborted?: boolean; error?: string }> {
|
|
64
64
|
const started = Date.now();
|
|
65
65
|
let lastProgressKey = "";
|
|
66
66
|
while (true) {
|
|
@@ -72,8 +72,14 @@ async function waitForReport(
|
|
|
72
72
|
const remaining = timeoutMs - (Date.now() - started);
|
|
73
73
|
if (remaining <= 0) return {};
|
|
74
74
|
const timer = new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), Math.min(500, remaining)));
|
|
75
|
-
const
|
|
76
|
-
|
|
75
|
+
const outcome = await Promise.race([
|
|
76
|
+
done.then((report) => ({ report }), (error) => ({ error: error instanceof Error ? error.message : String(error) })),
|
|
77
|
+
timer,
|
|
78
|
+
]);
|
|
79
|
+
if (outcome !== undefined) {
|
|
80
|
+
if ("error" in outcome) return { error: outcome.error };
|
|
81
|
+
return { report: outcome.report };
|
|
82
|
+
}
|
|
77
83
|
const progress = progressText(runtime, id);
|
|
78
84
|
const key = progressKey(runtime, id);
|
|
79
85
|
if (key !== lastProgressKey) {
|
|
@@ -175,6 +181,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
175
181
|
deps.onReport?.(ctx, waited.report);
|
|
176
182
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
177
183
|
}
|
|
184
|
+
if (waited.error) return result(`Handoff ${handoff.id} failed: ${waited.error}`, undefined, true);
|
|
178
185
|
if (waited.aborted) return result(`${progressText(runtime, handoff.id)}\nHandoff ${handoff.id} aborted.`, undefined, true);
|
|
179
186
|
if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${handoff.id}) was working. The handoff continues in the background. Act on the user's message first, then call read_subagent({agent_id:"${handoff.id}", block:true}) to collect the report or sidekick({message}) to redirect it.\n${waited.interrupted}`);
|
|
180
187
|
return result(`Handoff ${handoff.id} is still running.\n${progressText(runtime, handoff.id)}`);
|
|
@@ -205,6 +212,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
205
212
|
deps.onReport?.(ctx, waited.report);
|
|
206
213
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
207
214
|
}
|
|
215
|
+
if (waited.error) return result(`Handoff ${id} failed: ${waited.error}`, undefined, true);
|
|
208
216
|
if (waited.aborted) return result(`Handoff ${id} aborted.`, undefined, true);
|
|
209
217
|
if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${id}) was working.\n${waited.interrupted}`);
|
|
210
218
|
return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`);
|