@fradser/pi-agent-teams 0.5.1 → 0.5.3
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/.memory/follow-up-queue.md +17 -0
- package/.memory/node-terminal-followups.md +17 -0
- package/README.md +3 -1
- package/index.ts +1 -0
- package/package.json +4 -3
- package/src/console-viewport.ts +2 -1
- package/src/follow-up-queue.ts +170 -0
- package/src/guidance.ts +12 -9
- package/src/index.ts +33 -16
- package/src/run-machine.ts +99 -73
- package/src/spawner.ts +142 -97
- package/src/state.ts +40 -42
- package/src/tools.ts +63 -4
- package/src/types.ts +48 -10
- package/src/ui.ts +56 -73
- package/src/worker.ts +4 -1
package/src/spawner.ts
CHANGED
|
@@ -8,13 +8,17 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
11
12
|
import * as fs from "node:fs";
|
|
12
13
|
import * as os from "node:os";
|
|
13
14
|
import * as path from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { extractTextContent } from "@fradser/pi-kit";
|
|
16
|
+
import { extractTextContent, resolvePiCli } from "@fradser/pi-kit";
|
|
16
17
|
import type { WorkerUsage } from "./types";
|
|
17
18
|
|
|
19
|
+
/** High default safety cap; explicit task budgets can still be lower. */
|
|
20
|
+
export const DEFAULT_TURN_BUDGET = 100;
|
|
21
|
+
|
|
18
22
|
/**
|
|
19
23
|
* Build the one-task execution prompt for a spawned worker.
|
|
20
24
|
*
|
|
@@ -26,9 +30,12 @@ export function buildAutonomousPrompt(opts: {
|
|
|
26
30
|
role: string;
|
|
27
31
|
prompt: string;
|
|
28
32
|
taskId?: string;
|
|
29
|
-
|
|
33
|
+
turnBudget?: number;
|
|
30
34
|
}): string {
|
|
31
35
|
const taskLine = `\nAssigned task: [${opts.taskId}].\nWhen finished, you MUST call teammate_message with status="completed" to submit your FULL final deliverable.`;
|
|
36
|
+
const budgetLine = opts.turnBudget
|
|
37
|
+
? `4. You have a maximum of ${opts.turnBudget} assistant turn(s) — manage your turn budget, avoid unnecessary exploration, and deliver your final message before the budget is exhausted.`
|
|
38
|
+
: `4. There is no turn budget — work until the task is complete. Do not rush or truncate your work.`;
|
|
32
39
|
|
|
33
40
|
return `You are a FULLY AUTONOMOUS teammate named "${opts.name}" (agent: ${opts.role}) in a pi multi-agent team run.
|
|
34
41
|
|
|
@@ -40,7 +47,7 @@ YOUR ROLE IN THIS RUN:
|
|
|
40
47
|
1. Work directly on your assigned scope and declared paths. DAG upstream results are ALREADY injected into this prompt below; do not poll for them.
|
|
41
48
|
2. Report plans, progress, and blockers with teammate_message. When finished, call teammate_message with status="completed" and put your FULL deliverable in the body. If blocked or failed, use status="failed" and explain the error.
|
|
42
49
|
3. There is no peer or leader-to-worker message channel. Make decisions within the assigned task and report blockers instead of waiting for a reply.
|
|
43
|
-
|
|
50
|
+
${budgetLine}
|
|
44
51
|
|
|
45
52
|
BOUND CAPABILITIES:
|
|
46
53
|
- teammate_message reports progress or a final deliverable to the team leader.`;
|
|
@@ -59,10 +66,10 @@ export interface SpawnPiWorkerOptions {
|
|
|
59
66
|
env?: Record<string, string | undefined>;
|
|
60
67
|
/** Working directory for the child (defaults to the parent's cwd). */
|
|
61
68
|
cwd?: string;
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
/** Output parsing mode.
|
|
65
|
-
mode?: "json" | "text";
|
|
69
|
+
/** Maximum assistant turns for this worker (defaults to DEFAULT_TURN_BUDGET). */
|
|
70
|
+
turnBudget?: number;
|
|
71
|
+
/** Output parsing mode. RPC keeps stdin available for runtime steering. */
|
|
72
|
+
mode?: "json" | "rpc" | "text";
|
|
66
73
|
/** Called whenever JSON-mode worker output reveals live model or tool activity. */
|
|
67
74
|
onUpdate?: (update: WorkerProgressUpdate) => void;
|
|
68
75
|
/** Called once with the captured output when the child exits. */
|
|
@@ -78,7 +85,7 @@ export interface WorkerProcessResult {
|
|
|
78
85
|
stdout: string;
|
|
79
86
|
stderr: string;
|
|
80
87
|
usage?: WorkerUsage;
|
|
81
|
-
|
|
88
|
+
turnBudgetExceeded?: boolean;
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
/** Live state extracted from a spawned worker's JSON-mode output. */
|
|
@@ -91,13 +98,13 @@ export interface WorkerProgressUpdate {
|
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
/** A worker only succeeds after a normal zero exit; signals are failures. */
|
|
94
|
-
export function isSuccessfulWorkerExit(result: Pick<WorkerProcessResult, "exitCode" | "signal"
|
|
95
|
-
return result.exitCode === 0 && result.signal === null
|
|
101
|
+
export function isSuccessfulWorkerExit(result: Pick<WorkerProcessResult, "exitCode" | "signal">): boolean {
|
|
102
|
+
return result.exitCode === 0 && result.signal === null;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
/** A reported completion remains successful when the harness closes it or teardown observes a signal
|
|
105
|
+
/** A reported completion remains successful when the harness closes it or teardown observes a signal. */
|
|
99
106
|
export function isCompletedWorkerExit(
|
|
100
|
-
result: Pick<WorkerProcessResult, "exitCode" | "signal"
|
|
107
|
+
result: Pick<WorkerProcessResult, "exitCode" | "signal">,
|
|
101
108
|
reportedCompleted = false,
|
|
102
109
|
): boolean {
|
|
103
110
|
return isSuccessfulWorkerExit(result) || reportedCompleted;
|
|
@@ -152,6 +159,14 @@ export class CancellationIntents {
|
|
|
152
159
|
/** Live workers by teammate name — lets the panel interrupt/stop a running worker. */
|
|
153
160
|
const workers = new Map<string, ReturnType<typeof spawn>>();
|
|
154
161
|
|
|
162
|
+
/** Send a steering message to an RPC worker without adding a peer mailbox. */
|
|
163
|
+
export function sendWorkerSteer(name: string, message: string): boolean {
|
|
164
|
+
const child = workers.get(name);
|
|
165
|
+
if (!child?.stdin || child.stdin.destroyed || !child.stdin.writable) return false;
|
|
166
|
+
child.stdin.write(`${JSON.stringify({ type: "steer", message })}\n`);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
155
170
|
function isChildRunning(child: ReturnType<typeof spawn>): boolean {
|
|
156
171
|
return child.exitCode === null && child.signalCode === null;
|
|
157
172
|
}
|
|
@@ -230,9 +245,20 @@ export async function terminateAllWorkers(graceMs = CANCEL_GRACE_MS): Promise<vo
|
|
|
230
245
|
const TASK_ARG_LIMIT = 8000;
|
|
231
246
|
const OUTPUT_CAP = 16_000;
|
|
232
247
|
|
|
248
|
+
function appendCapped(chunks: string[], chunk: string, cap: number): void {
|
|
249
|
+
chunks.push(chunk);
|
|
250
|
+
let total = chunks.reduce((sum, value) => sum + value.length, 0);
|
|
251
|
+
while (total > cap && chunks.length > 1) {
|
|
252
|
+
total -= chunks.shift()?.length ?? 0;
|
|
253
|
+
}
|
|
254
|
+
if (total > cap && chunks.length === 1) chunks[0] = chunks[0].slice(-cap);
|
|
255
|
+
}
|
|
256
|
+
|
|
233
257
|
type JsonEvent = {
|
|
234
258
|
type?: string;
|
|
259
|
+
toolCallId?: string;
|
|
235
260
|
toolName?: string;
|
|
261
|
+
args?: unknown;
|
|
236
262
|
assistantMessageEvent?: {
|
|
237
263
|
type?: string;
|
|
238
264
|
delta?: string;
|
|
@@ -262,18 +288,53 @@ export function parseWorkerOutput(stdout: string): { text: string; usage?: Worke
|
|
|
262
288
|
return { text: state.text.trim(), usage: state.usage };
|
|
263
289
|
}
|
|
264
290
|
|
|
291
|
+
/** Parse the latest live progress state from a worker JSON stream. */
|
|
292
|
+
export function parseWorkerProgress(stdout: string): WorkerProgressUpdate {
|
|
293
|
+
const state = createWorkerStreamState();
|
|
294
|
+
for (const line of stdout.split("\n")) applyWorkerJsonLine(state, line);
|
|
295
|
+
return {
|
|
296
|
+
text: truncate(state.text, OUTPUT_CAP),
|
|
297
|
+
activeTool: state.activeTool,
|
|
298
|
+
liveThinking: truncate(state.thinking, OUTPUT_CAP),
|
|
299
|
+
turns: state.turns,
|
|
300
|
+
finalResponse: state.finalResponse,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
265
304
|
interface WorkerStreamState {
|
|
266
305
|
text: string;
|
|
267
306
|
thinking: string;
|
|
268
307
|
toolcallArgs: string;
|
|
269
308
|
activeTool?: string;
|
|
309
|
+
activeToolCallId?: string;
|
|
310
|
+
activeTools: Map<string, string>;
|
|
270
311
|
turns: number;
|
|
271
312
|
finalResponse?: boolean;
|
|
272
313
|
usage?: WorkerUsage;
|
|
273
314
|
}
|
|
274
315
|
|
|
275
316
|
function createWorkerStreamState(): WorkerStreamState {
|
|
276
|
-
return { text: "", thinking: "", toolcallArgs: "", turns: 0 };
|
|
317
|
+
return { text: "", thinking: "", toolcallArgs: "", activeTools: new Map(), turns: 0 };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function setActiveTool(state: WorkerStreamState, toolCallId: string | undefined, label: string): void {
|
|
321
|
+
const key = toolCallId ?? `tool-${state.activeTools.size}`;
|
|
322
|
+
state.activeTools.set(key, label);
|
|
323
|
+
state.activeToolCallId = key;
|
|
324
|
+
state.activeTool = label;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function clearActiveTool(state: WorkerStreamState, toolCallId: string | undefined): void {
|
|
328
|
+
if (toolCallId) state.activeTools.delete(toolCallId);
|
|
329
|
+
else state.activeTools.clear();
|
|
330
|
+
const next = [...state.activeTools.entries()].at(-1);
|
|
331
|
+
state.activeToolCallId = next?.[0];
|
|
332
|
+
state.activeTool = next?.[1];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function toolExecutionLabel(toolName: string | undefined, args: unknown): string {
|
|
336
|
+
const serialized = typeof args === "string" ? args : JSON.stringify(args ?? {});
|
|
337
|
+
return toolcallLabel(serialized) ?? toolName ?? "tool";
|
|
277
338
|
}
|
|
278
339
|
|
|
279
340
|
/** Human-readable label from a partially streamed tool-call argument JSON. */
|
|
@@ -304,9 +365,31 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
304
365
|
} catch {
|
|
305
366
|
return false;
|
|
306
367
|
}
|
|
368
|
+
if (event.type === "message_start" && event.message?.role === "assistant") {
|
|
369
|
+
state.thinking = "";
|
|
370
|
+
state.activeTool = undefined;
|
|
371
|
+
state.activeToolCallId = undefined;
|
|
372
|
+
state.activeTools.clear();
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
if (event.type === "tool_execution_start") {
|
|
376
|
+
setActiveTool(state, event.toolCallId, toolExecutionLabel(event.toolName, event.args));
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
if (event.type === "tool_execution_end") {
|
|
380
|
+
if (!event.toolCallId || state.activeTools.has(event.toolCallId)) {
|
|
381
|
+
clearActiveTool(state, event.toolCallId);
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
307
386
|
if (event.type !== "message_update") {
|
|
308
387
|
if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
|
|
309
388
|
state.turns++;
|
|
389
|
+
state.activeTool = undefined;
|
|
390
|
+
state.activeToolCallId = undefined;
|
|
391
|
+
state.activeTools.clear();
|
|
392
|
+
state.thinking = "";
|
|
310
393
|
if (event.message.stopReason === "stop") state.finalResponse = true;
|
|
311
394
|
const parts = extractTextContent(event.message.content, "");
|
|
312
395
|
if (parts.trim()) state.text = parts;
|
|
@@ -327,14 +410,18 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
327
410
|
if (!sub) return false;
|
|
328
411
|
switch (sub.type) {
|
|
329
412
|
case "text_delta":
|
|
413
|
+
state.activeTool = undefined;
|
|
330
414
|
state.text += sub.delta ?? "";
|
|
331
415
|
return true;
|
|
332
416
|
case "thinking_delta":
|
|
417
|
+
state.activeTool = undefined;
|
|
333
418
|
state.thinking += sub.delta ?? "";
|
|
334
419
|
return true;
|
|
335
420
|
case "toolcall_start":
|
|
336
421
|
state.toolcallArgs = "";
|
|
337
422
|
state.activeTool = undefined;
|
|
423
|
+
state.activeToolCallId = undefined;
|
|
424
|
+
state.activeTools.clear();
|
|
338
425
|
return true;
|
|
339
426
|
case "toolcall_delta": {
|
|
340
427
|
state.toolcallArgs += sub.delta ?? "";
|
|
@@ -342,14 +429,14 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
342
429
|
if (label) state.activeTool = label;
|
|
343
430
|
return true;
|
|
344
431
|
}
|
|
345
|
-
case "toolcall_end":
|
|
346
|
-
|
|
347
|
-
//
|
|
348
|
-
// fall back to the bare tool name only when no label could be parsed.
|
|
349
|
-
if (tc?.name && !state.activeTool) state.activeTool = tc.name;
|
|
432
|
+
case "toolcall_end":
|
|
433
|
+
// The tool call has been emitted; execution events now own the current
|
|
434
|
+
// activity label until the tool result arrives.
|
|
350
435
|
state.toolcallArgs = "";
|
|
436
|
+
state.activeTool = undefined;
|
|
437
|
+
state.activeToolCallId = undefined;
|
|
438
|
+
state.activeTools.clear();
|
|
351
439
|
return true;
|
|
352
|
-
}
|
|
353
440
|
default:
|
|
354
441
|
return false;
|
|
355
442
|
}
|
|
@@ -366,61 +453,11 @@ function truncate(text: string, cap: number): string {
|
|
|
366
453
|
return `${text.slice(0, cap)}\n... [truncated ${text.length - cap} chars]`;
|
|
367
454
|
}
|
|
368
455
|
|
|
369
|
-
export interface PiCliResolution {
|
|
370
|
-
/** Command to execute — either a runtime (node/bun) or a `pi` binary. */
|
|
371
|
-
command: string;
|
|
372
|
-
/** Leading args: the CLI script path when running via a runtime, else empty. */
|
|
373
|
-
args: string[];
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** Whether a file is the pi coding-agent CLI script (verified against its package manifest). */
|
|
377
|
-
function isPiPackageScript(filePath: string): boolean {
|
|
378
|
-
try {
|
|
379
|
-
const resolved = fs.realpathSync(filePath);
|
|
380
|
-
if (!/\.(mjs|cjs|js)$/.test(resolved)) return false;
|
|
381
|
-
let dir = path.dirname(resolved);
|
|
382
|
-
while (dir !== path.dirname(dir)) {
|
|
383
|
-
const pkgPath = path.join(dir, "package.json");
|
|
384
|
-
if (fs.existsSync(pkgPath)) {
|
|
385
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
|
|
386
|
-
return pkg.name === "@earendil-works/pi-coding-agent";
|
|
387
|
-
}
|
|
388
|
-
dir = path.dirname(dir);
|
|
389
|
-
}
|
|
390
|
-
} catch {
|
|
391
|
-
// Unreadable paths are simply not candidates.
|
|
392
|
-
}
|
|
393
|
-
return false;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
456
|
/**
|
|
397
|
-
* Resolve how to launch a
|
|
398
|
-
*
|
|
399
|
-
* Resolution order:
|
|
400
|
-
* 1. `process.argv[1]` — the current Pi process entry, verified against the
|
|
401
|
-
* package manifest (avoids mistaking unrelated scripts for the CLI).
|
|
402
|
-
* 2. The installed `@earendil-works/pi-coding-agent` package's `dist/cli.js`.
|
|
403
|
-
* 3. A `pi` binary on PATH (best effort).
|
|
457
|
+
* Resolve how to launch a Pi CLI process.
|
|
458
|
+
* Re-exported from pi-kit for backward compatibility.
|
|
404
459
|
*/
|
|
405
|
-
export
|
|
406
|
-
const argv1 = process.argv[1];
|
|
407
|
-
if (argv1 && isPiPackageScript(argv1)) {
|
|
408
|
-
return { command: process.execPath, args: [path.resolve(argv1)] };
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
try {
|
|
412
|
-
const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
413
|
-
const packageRoot = path.dirname(path.dirname(entry));
|
|
414
|
-
const cliPath = path.join(packageRoot, "dist", "cli.js");
|
|
415
|
-
if (fs.existsSync(cliPath)) {
|
|
416
|
-
return { command: process.execPath, args: [cliPath] };
|
|
417
|
-
}
|
|
418
|
-
} catch {
|
|
419
|
-
// Package resolution is best-effort; fall through to PATH.
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
return { command: "pi", args: [] };
|
|
423
|
-
}
|
|
460
|
+
export { resolvePiCli } from "@fradser/pi-kit";
|
|
424
461
|
|
|
425
462
|
/**
|
|
426
463
|
* Spawn a child Pi process that executes the task description in
|
|
@@ -431,7 +468,16 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
431
468
|
// resolvePiCli always resolves (worst case a "pi" binary on PATH); a missing
|
|
432
469
|
// binary surfaces later as a child "error" event through onError.
|
|
433
470
|
const cli = resolvePiCli();
|
|
434
|
-
const
|
|
471
|
+
const workerExtension = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
472
|
+
const mode = options.mode ?? "json";
|
|
473
|
+
const args: string[] = [
|
|
474
|
+
...cli.args,
|
|
475
|
+
...(mode === "rpc" ? ["--mode", "rpc"] : ["--print", "--mode", mode]),
|
|
476
|
+
"--no-session",
|
|
477
|
+
"--no-extensions",
|
|
478
|
+
"--extension",
|
|
479
|
+
workerExtension,
|
|
480
|
+
];
|
|
435
481
|
if (options.model) args.push("--model", options.model);
|
|
436
482
|
// Capability tools cannot be removed; execution tools are selected by the
|
|
437
483
|
// leader from the teammate's explicit configuration or role default.
|
|
@@ -455,14 +501,14 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
455
501
|
error: error instanceof Error ? error.message : String(error),
|
|
456
502
|
});
|
|
457
503
|
|
|
504
|
+
let taskText = `Task: ${options.description}`;
|
|
458
505
|
try {
|
|
459
|
-
|
|
460
|
-
if (taskText.length > TASK_ARG_LIMIT) {
|
|
506
|
+
if (mode !== "rpc" && taskText.length > TASK_ARG_LIMIT) {
|
|
461
507
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "teammate-"));
|
|
462
508
|
const taskFile = path.join(tempDir, "task.md");
|
|
463
509
|
fs.writeFileSync(taskFile, taskText, { mode: 0o600 });
|
|
464
510
|
args.push(`@${taskFile}`);
|
|
465
|
-
} else {
|
|
511
|
+
} else if (mode !== "rpc") {
|
|
466
512
|
args.push(taskText);
|
|
467
513
|
}
|
|
468
514
|
} catch (error) {
|
|
@@ -475,7 +521,7 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
475
521
|
child = spawn(cli.command, args, {
|
|
476
522
|
cwd: options.cwd,
|
|
477
523
|
env: { ...process.env, ...options.env },
|
|
478
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
524
|
+
stdio: [mode === "rpc" ? "pipe" : "ignore", "pipe", "pipe"],
|
|
479
525
|
});
|
|
480
526
|
} catch (error) {
|
|
481
527
|
cleanupTempDir();
|
|
@@ -483,12 +529,16 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
483
529
|
}
|
|
484
530
|
if (options.workerName) workers.set(options.workerName, child);
|
|
485
531
|
|
|
532
|
+
if (mode === "rpc") {
|
|
533
|
+
child.stdin?.write(`${JSON.stringify({ type: "prompt", id: randomUUID(), message: taskText })}\n`);
|
|
534
|
+
}
|
|
535
|
+
|
|
486
536
|
const stdoutChunks: string[] = [];
|
|
487
537
|
const stderrChunks: string[] = [];
|
|
488
538
|
const streamState = createWorkerStreamState();
|
|
489
539
|
let stdoutBuffer = "";
|
|
490
|
-
let timedOut = false;
|
|
491
540
|
let settled = false;
|
|
541
|
+
let turnBudgetExceeded = false;
|
|
492
542
|
const emitProgress = () => options.onUpdate?.({
|
|
493
543
|
text: truncate(streamState.text, OUTPUT_CAP),
|
|
494
544
|
activeTool: streamState.activeTool,
|
|
@@ -496,29 +546,25 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
496
546
|
turns: streamState.turns,
|
|
497
547
|
finalResponse: streamState.finalResponse,
|
|
498
548
|
});
|
|
499
|
-
child.stdout
|
|
549
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
500
550
|
const text = chunk.toString();
|
|
501
|
-
stdoutChunks
|
|
551
|
+
appendCapped(stdoutChunks, text, OUTPUT_CAP * 2);
|
|
502
552
|
stdoutBuffer += text;
|
|
503
553
|
const lines = stdoutBuffer.split("\n");
|
|
504
554
|
stdoutBuffer = lines.pop() ?? "";
|
|
505
555
|
let changed = false;
|
|
506
|
-
for (const line of lines)
|
|
556
|
+
for (const line of lines) {
|
|
557
|
+
changed = applyWorkerJsonLine(streamState, line) || changed;
|
|
558
|
+
if (options.turnBudget && streamState.turns >= options.turnBudget && !streamState.finalResponse && !turnBudgetExceeded) {
|
|
559
|
+
turnBudgetExceeded = true;
|
|
560
|
+
void terminateChildProcess(child);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
507
563
|
if (changed) emitProgress();
|
|
508
564
|
});
|
|
509
|
-
child.stderr
|
|
510
|
-
|
|
511
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
512
|
-
if (options.timeoutMs) {
|
|
513
|
-
timer = setTimeout(() => {
|
|
514
|
-
timedOut = true;
|
|
515
|
-
child.kill("SIGKILL");
|
|
516
|
-
}, options.timeoutMs);
|
|
517
|
-
timer.unref?.();
|
|
518
|
-
}
|
|
565
|
+
child.stderr?.on("data", (chunk: Buffer) => appendCapped(stderrChunks, chunk.toString(), OUTPUT_CAP * 2));
|
|
519
566
|
|
|
520
567
|
child.on("error", (error) => {
|
|
521
|
-
if (timer) clearTimeout(timer);
|
|
522
568
|
cleanupTempDir();
|
|
523
569
|
settled = true;
|
|
524
570
|
if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
|
|
@@ -526,7 +572,6 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
526
572
|
});
|
|
527
573
|
|
|
528
574
|
child.on("close", (code, signal) => {
|
|
529
|
-
if (timer) clearTimeout(timer);
|
|
530
575
|
cleanupTempDir();
|
|
531
576
|
// A spawn failure already reported via onError (Node fires error then
|
|
532
577
|
// close) — do not double-report through onExit, which would let a failed
|
|
@@ -534,12 +579,12 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
534
579
|
if (settled) return;
|
|
535
580
|
if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
|
|
536
581
|
const rawStdout = stdoutChunks.join("");
|
|
537
|
-
const parsed =
|
|
538
|
-
?
|
|
539
|
-
:
|
|
582
|
+
const parsed = mode === "text"
|
|
583
|
+
? { text: rawStdout.trim(), usage: undefined }
|
|
584
|
+
: parseWorkerOutput(rawStdout);
|
|
540
585
|
const stdout = truncate(parsed.text, OUTPUT_CAP);
|
|
541
586
|
const stderr = truncate(stderrChunks.join("").trim(), OUTPUT_CAP);
|
|
542
|
-
options.onExit({ pid: child.pid ?? 0, exitCode: code, signal, stdout, stderr, usage: parsed.usage,
|
|
587
|
+
options.onExit({ pid: child.pid ?? 0, exitCode: code, signal, stdout, stderr, usage: parsed.usage, turnBudgetExceeded });
|
|
543
588
|
});
|
|
544
589
|
|
|
545
590
|
return { pid: child.pid ?? 0 };
|
package/src/state.ts
CHANGED
|
@@ -84,6 +84,28 @@ export function normalizeNodePaths(paths: string[]): { ok: true; paths: string[]
|
|
|
84
84
|
|
|
85
85
|
// ── Run creation ──────────────────────────────────────────────────
|
|
86
86
|
|
|
87
|
+
export const MAX_STRUCTURED_OUTPUT_BYTES = 64 * 1024;
|
|
88
|
+
export const MAX_STRUCTURED_OUTPUT_DEPTH = 8;
|
|
89
|
+
export const MAX_FANOUT_ITEMS = 32;
|
|
90
|
+
|
|
91
|
+
export function structuredOutputSize(value: unknown): number {
|
|
92
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function structuredOutputDepth(value: unknown): number {
|
|
96
|
+
if (!value || typeof value !== "object") return 0;
|
|
97
|
+
if (Array.isArray(value)) return 1 + Math.max(0, ...value.map(structuredOutputDepth));
|
|
98
|
+
return 1 + Math.max(0, ...Object.values(value as Record<string, unknown>).map(structuredOutputDepth));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function validateStructuredOutput(value: unknown): string | undefined {
|
|
102
|
+
let serialized: string;
|
|
103
|
+
try { serialized = JSON.stringify(value); } catch { return "Structured output must be JSON-serializable."; }
|
|
104
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_STRUCTURED_OUTPUT_BYTES) return `Structured output exceeds ${MAX_STRUCTURED_OUTPUT_BYTES} bytes.`;
|
|
105
|
+
if (structuredOutputDepth(value) > MAX_STRUCTURED_OUTPUT_DEPTH) return `Structured output exceeds depth ${MAX_STRUCTURED_OUTPUT_DEPTH}.`;
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
export interface RunNodeInput {
|
|
88
110
|
id: string;
|
|
89
111
|
agent: string;
|
|
@@ -91,7 +113,10 @@ export interface RunNodeInput {
|
|
|
91
113
|
paths: string[];
|
|
92
114
|
access: Node["access"];
|
|
93
115
|
model?: string;
|
|
94
|
-
|
|
116
|
+
mode?: Node["mode"];
|
|
117
|
+
turnBudget?: number;
|
|
118
|
+
forkContext?: string[];
|
|
119
|
+
inputBindings?: Record<string, string>;
|
|
95
120
|
dependsOn: string[];
|
|
96
121
|
}
|
|
97
122
|
|
|
@@ -122,7 +147,6 @@ export function createRun(
|
|
|
122
147
|
concurrency: number;
|
|
123
148
|
worktree: boolean;
|
|
124
149
|
background?: boolean;
|
|
125
|
-
timeoutMs?: number;
|
|
126
150
|
summarize?: boolean;
|
|
127
151
|
summaryAgent?: string;
|
|
128
152
|
nodes: RunNodeInput[];
|
|
@@ -146,6 +170,15 @@ export function createRun(
|
|
|
146
170
|
return { ok: false, error: `Node "${node.id}" depends on unknown node "${dep}".` };
|
|
147
171
|
}
|
|
148
172
|
}
|
|
173
|
+
for (const source of Object.values(node.inputBindings ?? {})) {
|
|
174
|
+
const dependencyId = source.split("#", 1)[0];
|
|
175
|
+
if (!node.dependsOn.includes(dependencyId)) {
|
|
176
|
+
return { ok: false, error: `Node "${node.id}" input binding must reference a dependency: "${source}".` };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (node.forkContext?.some((dependencyId) => !node.dependsOn.includes(dependencyId))) {
|
|
180
|
+
return { ok: false, error: `Node "${node.id}" forkContext must reference dependencies only.` };
|
|
181
|
+
}
|
|
149
182
|
normalized.push({
|
|
150
183
|
id: node.id,
|
|
151
184
|
workerKey: "", // filled after the run id is assigned
|
|
@@ -154,7 +187,10 @@ export function createRun(
|
|
|
154
187
|
paths: normalizedPaths.paths,
|
|
155
188
|
access: node.access,
|
|
156
189
|
model: node.model,
|
|
157
|
-
|
|
190
|
+
mode: node.mode,
|
|
191
|
+
turnBudget: node.turnBudget,
|
|
192
|
+
forkContext: node.forkContext ? [...new Set(node.forkContext)] : undefined,
|
|
193
|
+
inputBindings: node.inputBindings ? { ...node.inputBindings } : undefined,
|
|
158
194
|
dependsOn: [...new Set(node.dependsOn)],
|
|
159
195
|
status: "pending",
|
|
160
196
|
createdAt: Date.now(),
|
|
@@ -208,8 +244,6 @@ export function createRun(
|
|
|
208
244
|
concurrency: input.concurrency,
|
|
209
245
|
worktree: input.worktree,
|
|
210
246
|
background: input.background ?? true,
|
|
211
|
-
timeoutMs: input.timeoutMs,
|
|
212
|
-
deadlineAt: input.timeoutMs ? Date.now() + input.timeoutMs : undefined,
|
|
213
247
|
nodes: {},
|
|
214
248
|
createdAt: Date.now(),
|
|
215
249
|
updatedAt: Date.now(),
|
|
@@ -526,40 +560,6 @@ export function cancelNode(runId: string, nodeId: string): { ok: boolean; runnin
|
|
|
526
560
|
return { ok: true, runningNodeIds };
|
|
527
561
|
}
|
|
528
562
|
|
|
529
|
-
/**
|
|
530
|
-
* Fail a run for exceeding its hard wall-clock cap: pending nodes are
|
|
531
|
-
* cancelled and the run is marked failed; live workers must be terminated by
|
|
532
|
-
* the caller.
|
|
533
|
-
*/
|
|
534
|
-
export function failRunTimeout(runId: string, errorMessage: string): { ok: boolean; runningNodeIds: string[]; error?: string } {
|
|
535
|
-
const run = state.runs[runId];
|
|
536
|
-
if (!run) return { ok: false, runningNodeIds: [], error: `Run "${runId}" not found.` };
|
|
537
|
-
if (run.status !== "running") return { ok: false, runningNodeIds: [], error: `Run "${runId}" is already ${run.status}.` };
|
|
538
|
-
const runningNodeIds: string[] = [];
|
|
539
|
-
for (const node of Object.values(run.nodes)) {
|
|
540
|
-
if (node.status === "running") {
|
|
541
|
-
// The node's worker is terminated by the caller; clearing the spawn means
|
|
542
|
-
// its late close event cannot pollute a retried (reset) node.
|
|
543
|
-
node.status = "failed";
|
|
544
|
-
node.errorMessage = errorMessage;
|
|
545
|
-
node.completedAt = Date.now();
|
|
546
|
-
node.spawn = undefined;
|
|
547
|
-
node.updatedAt = Date.now();
|
|
548
|
-
runningNodeIds.push(node.id);
|
|
549
|
-
} else if (node.status === "pending") {
|
|
550
|
-
node.status = "cancelled";
|
|
551
|
-
node.completedAt = Date.now();
|
|
552
|
-
node.updatedAt = Date.now();
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
run.status = "failed";
|
|
556
|
-
run.errorMessage = errorMessage;
|
|
557
|
-
run.finishedAt = Date.now();
|
|
558
|
-
run.updatedAt = Date.now();
|
|
559
|
-
markStateDirty();
|
|
560
|
-
return { ok: true, runningNodeIds };
|
|
561
|
-
}
|
|
562
|
-
|
|
563
563
|
/**
|
|
564
564
|
* Reset failed and cancelled nodes of a settled run back to pending so the
|
|
565
565
|
* run can be re-dispatched. Completed nodes are retained. The run returns to
|
|
@@ -612,6 +612,7 @@ export function retryRun(runId: string, nodeIds?: string[]): { ok: boolean; rese
|
|
|
612
612
|
node.errorMessage = undefined;
|
|
613
613
|
node.completedAt = undefined;
|
|
614
614
|
node.spawn = undefined;
|
|
615
|
+
node.nodeFollowUpSent = undefined;
|
|
615
616
|
node.updatedAt = Date.now();
|
|
616
617
|
}
|
|
617
618
|
const reset = [...resetSet];
|
|
@@ -620,9 +621,6 @@ export function retryRun(runId: string, nodeIds?: string[]): { ok: boolean; rese
|
|
|
620
621
|
run.finishedAt = undefined;
|
|
621
622
|
run.completionNotified = false;
|
|
622
623
|
run.settledMessageSent = false;
|
|
623
|
-
// Re-arm the run-level cap: a stale deadline would re-fail the retried run
|
|
624
|
-
// on the very next poll.
|
|
625
|
-
run.deadlineAt = run.timeoutMs ? Date.now() + run.timeoutMs : undefined;
|
|
626
624
|
run.updatedAt = Date.now();
|
|
627
625
|
markStateDirty();
|
|
628
626
|
return { ok: true, reset };
|
package/src/tools.ts
CHANGED
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
scheduleRun,
|
|
11
11
|
type DispatchCtx,
|
|
12
12
|
} from "./run-machine";
|
|
13
|
-
import { createRun, getRun, getSummary, markRunCompletionDelivered, retryRun } from "./state";
|
|
14
|
-
import { TeammateCancelParams, TeammateRetryParams, TeammateRunParams } from "./types";
|
|
13
|
+
import { createRun, getRun, getSummary, listNodes, markRunCompletionDelivered, retryRun, MAX_FANOUT_ITEMS } from "./state";
|
|
14
|
+
import { TeammateCancelParams, TeammateFanoutParams, TeammateLeaderMessageParams, TeammateRetryParams, TeammateRunParams } from "./types";
|
|
15
|
+
import { sendWorkerSteer } from "./spawner";
|
|
15
16
|
import { ensureTeamWidget, openTeamConsole, refreshTeamUI } from "./ui";
|
|
16
17
|
|
|
17
18
|
function dispatchContext(ctx: ExtensionContext): DispatchCtx {
|
|
@@ -48,6 +49,10 @@ async function gatherForeground(runId: string, signal: AbortSignal | undefined):
|
|
|
48
49
|
return buildRunSummary(runId);
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
function resolveTargetNode(target: string): { node: import("./types").Node } | undefined {
|
|
53
|
+
return listNodes().map((node) => ({ node })).find(({ node }) => node.id === target || node.workerKey === target);
|
|
54
|
+
}
|
|
55
|
+
|
|
51
56
|
export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
52
57
|
pi.registerTool({
|
|
53
58
|
name: "teammate_run",
|
|
@@ -71,7 +76,6 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
71
76
|
concurrency: params.concurrency ?? 4,
|
|
72
77
|
worktree: params.worktree ?? false,
|
|
73
78
|
background: params.background ?? true,
|
|
74
|
-
timeoutMs: params.timeoutMs,
|
|
75
79
|
summarize: params.summarize,
|
|
76
80
|
summaryAgent: params.summaryAgent,
|
|
77
81
|
nodes: params.tasks.map((task) => ({
|
|
@@ -81,7 +85,10 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
81
85
|
paths: task.paths,
|
|
82
86
|
access: task.access ?? "read",
|
|
83
87
|
model: task.model,
|
|
84
|
-
|
|
88
|
+
mode: task.mode,
|
|
89
|
+
turnBudget: task.turnBudget,
|
|
90
|
+
forkContext: task.forkContext,
|
|
91
|
+
inputBindings: task.inputBindings,
|
|
85
92
|
dependsOn: task.dependsOn ?? [],
|
|
86
93
|
})),
|
|
87
94
|
});
|
|
@@ -101,6 +108,58 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
101
108
|
},
|
|
102
109
|
});
|
|
103
110
|
|
|
111
|
+
pi.registerTool({
|
|
112
|
+
name: "teammate_fanout",
|
|
113
|
+
promptSnippet: "Fan out a completed node's structured array output",
|
|
114
|
+
label: "Fan Out Tasks",
|
|
115
|
+
description: "Leader-only bounded fanout. Validates a completed structured array before creating a separate child run.",
|
|
116
|
+
parameters: TeammateFanoutParams,
|
|
117
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
118
|
+
const source = getRun(params.runId)?.nodes[params.nodeId];
|
|
119
|
+
if (!source || source.status !== "completed") throw new Error("Fanout source must be a completed node.");
|
|
120
|
+
if (!Array.isArray(source.structuredOutput)) throw new Error("Fanout source must be a structured array.");
|
|
121
|
+
if (source.structuredOutput.length > MAX_FANOUT_ITEMS) throw new Error(`Fanout is limited to ${MAX_FANOUT_ITEMS} items.`);
|
|
122
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
123
|
+
const dctx = dispatchContext(ctx);
|
|
124
|
+
ensureRunContext(dctx);
|
|
125
|
+
const tasks = source.structuredOutput.map((item, index) => ({
|
|
126
|
+
id: `${params.nodeId}-${index + 1}`,
|
|
127
|
+
agent: params.agent,
|
|
128
|
+
prompt: `${params.prompt}\n\nFanout item:\n${JSON.stringify(item)}`,
|
|
129
|
+
paths: params.paths,
|
|
130
|
+
access: params.access ?? "read" as const,
|
|
131
|
+
model: params.model,
|
|
132
|
+
mode: "json" as const,
|
|
133
|
+
turnBudget: params.turnBudget,
|
|
134
|
+
dependsOn: [],
|
|
135
|
+
}));
|
|
136
|
+
if (!resolveAgent(params.agent, cwd)) throw new Error(`Agent "${params.agent}" not found.`);
|
|
137
|
+
const created = createRun({ cwd, concurrency: params.concurrency ?? 4, worktree: false, background: params.background ?? true, summarize: true, nodes: tasks });
|
|
138
|
+
if (!created.ok) throw new Error(created.error ?? "Failed to create fanout run.");
|
|
139
|
+
publishStateSnapshot();
|
|
140
|
+
scheduleRun(created.run.id, dctx);
|
|
141
|
+
return { content: [{ type: "text", text: `Started fanout run [${created.run.id}] with ${tasks.length} item(s).` }], details: {} };
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
pi.registerTool({
|
|
146
|
+
name: "teammate_message",
|
|
147
|
+
promptSnippet: "Send a runtime steer to a running RPC teammate",
|
|
148
|
+
label: "Message Teammate",
|
|
149
|
+
description: "Leader-only runtime steer. Sends through the existing teammate_message protocol to a running RPC worker; no message is available for JSON-mode workers.",
|
|
150
|
+
parameters: TeammateLeaderMessageParams,
|
|
151
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
152
|
+
if (!params.target.includes(":")) throw new Error("Target must be a run-qualified worker key.");
|
|
153
|
+
const target = resolveTargetNode(params.target);
|
|
154
|
+
if (!target?.node.spawn || target.node.status !== "running" || target.node.spawn.mode !== "rpc") {
|
|
155
|
+
throw new Error("Target worker is not a running RPC teammate.");
|
|
156
|
+
}
|
|
157
|
+
const sent = sendWorkerSteer(target.node.workerKey, params.body);
|
|
158
|
+
if (!sent) throw new Error("Target worker steering stream is unavailable.");
|
|
159
|
+
return { content: [{ type: "text", text: `Steer sent to ${target.node.workerKey}.` }], details: {} };
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
104
163
|
pi.registerTool({
|
|
105
164
|
name: "teammate_cancel",
|
|
106
165
|
promptSnippet: "Cancel a run and stop its workers",
|