@fradser/pi-agent-teams 0.5.2 → 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 +2 -1
- package/package.json +1 -1
- 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 +95 -62
- package/src/spawner.ts +132 -152
- 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,68 +159,18 @@ 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
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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;
|
|
160
168
|
}
|
|
161
169
|
|
|
162
|
-
const workerTimeouts = new WeakMap<ReturnType<typeof spawn>, WorkerTimeoutHandle>();
|
|
163
|
-
|
|
164
170
|
function isChildRunning(child: ReturnType<typeof spawn>): boolean {
|
|
165
171
|
return child.exitCode === null && child.signalCode === null;
|
|
166
172
|
}
|
|
167
173
|
|
|
168
|
-
export function registerWorkerTimeout(
|
|
169
|
-
child: ReturnType<typeof spawn>,
|
|
170
|
-
timeoutMs: number,
|
|
171
|
-
): WorkerTimeoutHandle {
|
|
172
|
-
let cancelled = false;
|
|
173
|
-
let disposed = false;
|
|
174
|
-
let timedOut = false;
|
|
175
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
176
|
-
|
|
177
|
-
const handle: WorkerTimeoutHandle = {
|
|
178
|
-
cancel() {
|
|
179
|
-
if (disposed) return;
|
|
180
|
-
cancelled = true;
|
|
181
|
-
if (timer) clearTimeout(timer);
|
|
182
|
-
disposed = true;
|
|
183
|
-
workerTimeouts.delete(child);
|
|
184
|
-
},
|
|
185
|
-
dispose() {
|
|
186
|
-
if (disposed) return;
|
|
187
|
-
if (timer) clearTimeout(timer);
|
|
188
|
-
disposed = true;
|
|
189
|
-
workerTimeouts.delete(child);
|
|
190
|
-
},
|
|
191
|
-
wasCancelled: () => cancelled,
|
|
192
|
-
didTimeout: () => timedOut,
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
timer = setTimeout(() => {
|
|
196
|
-
if (disposed || cancelled) return;
|
|
197
|
-
if (!isChildRunning(child)) {
|
|
198
|
-
handle.dispose();
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
try {
|
|
202
|
-
if (child.kill("SIGKILL")) timedOut = true;
|
|
203
|
-
} catch {
|
|
204
|
-
// A failed kill is not a timeout outcome; close/error will report the
|
|
205
|
-
// actual child result.
|
|
206
|
-
}
|
|
207
|
-
}, timeoutMs);
|
|
208
|
-
timer.unref?.();
|
|
209
|
-
workerTimeouts.set(child, handle);
|
|
210
|
-
return handle;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export function cancelWorkerTimeout(child: ReturnType<typeof spawn>): void {
|
|
214
|
-
workerTimeouts.get(child)?.cancel();
|
|
215
|
-
}
|
|
216
|
-
|
|
217
174
|
function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promise<boolean> {
|
|
218
175
|
return new Promise((resolve) => {
|
|
219
176
|
let settled = false;
|
|
@@ -238,7 +195,6 @@ function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promi
|
|
|
238
195
|
* resolve only after a bounded wait observes the child `close` event.
|
|
239
196
|
*/
|
|
240
197
|
export async function terminateChildProcess(child: ReturnType<typeof spawn>, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
|
|
241
|
-
cancelWorkerTimeout(child);
|
|
242
198
|
if (!isChildRunning(child)) return false;
|
|
243
199
|
|
|
244
200
|
const closedAfterTerm = waitForClose(child, graceMs);
|
|
@@ -289,9 +245,20 @@ export async function terminateAllWorkers(graceMs = CANCEL_GRACE_MS): Promise<vo
|
|
|
289
245
|
const TASK_ARG_LIMIT = 8000;
|
|
290
246
|
const OUTPUT_CAP = 16_000;
|
|
291
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
|
+
|
|
292
257
|
type JsonEvent = {
|
|
293
258
|
type?: string;
|
|
259
|
+
toolCallId?: string;
|
|
294
260
|
toolName?: string;
|
|
261
|
+
args?: unknown;
|
|
295
262
|
assistantMessageEvent?: {
|
|
296
263
|
type?: string;
|
|
297
264
|
delta?: string;
|
|
@@ -321,18 +288,53 @@ export function parseWorkerOutput(stdout: string): { text: string; usage?: Worke
|
|
|
321
288
|
return { text: state.text.trim(), usage: state.usage };
|
|
322
289
|
}
|
|
323
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
|
+
|
|
324
304
|
interface WorkerStreamState {
|
|
325
305
|
text: string;
|
|
326
306
|
thinking: string;
|
|
327
307
|
toolcallArgs: string;
|
|
328
308
|
activeTool?: string;
|
|
309
|
+
activeToolCallId?: string;
|
|
310
|
+
activeTools: Map<string, string>;
|
|
329
311
|
turns: number;
|
|
330
312
|
finalResponse?: boolean;
|
|
331
313
|
usage?: WorkerUsage;
|
|
332
314
|
}
|
|
333
315
|
|
|
334
316
|
function createWorkerStreamState(): WorkerStreamState {
|
|
335
|
-
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";
|
|
336
338
|
}
|
|
337
339
|
|
|
338
340
|
/** Human-readable label from a partially streamed tool-call argument JSON. */
|
|
@@ -363,9 +365,31 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
363
365
|
} catch {
|
|
364
366
|
return false;
|
|
365
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
|
+
}
|
|
366
386
|
if (event.type !== "message_update") {
|
|
367
387
|
if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
|
|
368
388
|
state.turns++;
|
|
389
|
+
state.activeTool = undefined;
|
|
390
|
+
state.activeToolCallId = undefined;
|
|
391
|
+
state.activeTools.clear();
|
|
392
|
+
state.thinking = "";
|
|
369
393
|
if (event.message.stopReason === "stop") state.finalResponse = true;
|
|
370
394
|
const parts = extractTextContent(event.message.content, "");
|
|
371
395
|
if (parts.trim()) state.text = parts;
|
|
@@ -386,14 +410,18 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
386
410
|
if (!sub) return false;
|
|
387
411
|
switch (sub.type) {
|
|
388
412
|
case "text_delta":
|
|
413
|
+
state.activeTool = undefined;
|
|
389
414
|
state.text += sub.delta ?? "";
|
|
390
415
|
return true;
|
|
391
416
|
case "thinking_delta":
|
|
417
|
+
state.activeTool = undefined;
|
|
392
418
|
state.thinking += sub.delta ?? "";
|
|
393
419
|
return true;
|
|
394
420
|
case "toolcall_start":
|
|
395
421
|
state.toolcallArgs = "";
|
|
396
422
|
state.activeTool = undefined;
|
|
423
|
+
state.activeToolCallId = undefined;
|
|
424
|
+
state.activeTools.clear();
|
|
397
425
|
return true;
|
|
398
426
|
case "toolcall_delta": {
|
|
399
427
|
state.toolcallArgs += sub.delta ?? "";
|
|
@@ -401,14 +429,14 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
401
429
|
if (label) state.activeTool = label;
|
|
402
430
|
return true;
|
|
403
431
|
}
|
|
404
|
-
case "toolcall_end":
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
// fall back to the bare tool name only when no label could be parsed.
|
|
408
|
-
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.
|
|
409
435
|
state.toolcallArgs = "";
|
|
436
|
+
state.activeTool = undefined;
|
|
437
|
+
state.activeToolCallId = undefined;
|
|
438
|
+
state.activeTools.clear();
|
|
410
439
|
return true;
|
|
411
|
-
}
|
|
412
440
|
default:
|
|
413
441
|
return false;
|
|
414
442
|
}
|
|
@@ -425,61 +453,11 @@ function truncate(text: string, cap: number): string {
|
|
|
425
453
|
return `${text.slice(0, cap)}\n... [truncated ${text.length - cap} chars]`;
|
|
426
454
|
}
|
|
427
455
|
|
|
428
|
-
export interface PiCliResolution {
|
|
429
|
-
/** Command to execute — either a runtime (node/bun) or a `pi` binary. */
|
|
430
|
-
command: string;
|
|
431
|
-
/** Leading args: the CLI script path when running via a runtime, else empty. */
|
|
432
|
-
args: string[];
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** Whether a file is the pi coding-agent CLI script (verified against its package manifest). */
|
|
436
|
-
function isPiPackageScript(filePath: string): boolean {
|
|
437
|
-
try {
|
|
438
|
-
const resolved = fs.realpathSync(filePath);
|
|
439
|
-
if (!/\.(mjs|cjs|js)$/.test(resolved)) return false;
|
|
440
|
-
let dir = path.dirname(resolved);
|
|
441
|
-
while (dir !== path.dirname(dir)) {
|
|
442
|
-
const pkgPath = path.join(dir, "package.json");
|
|
443
|
-
if (fs.existsSync(pkgPath)) {
|
|
444
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
|
|
445
|
-
return pkg.name === "@earendil-works/pi-coding-agent";
|
|
446
|
-
}
|
|
447
|
-
dir = path.dirname(dir);
|
|
448
|
-
}
|
|
449
|
-
} catch {
|
|
450
|
-
// Unreadable paths are simply not candidates.
|
|
451
|
-
}
|
|
452
|
-
return false;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
456
|
/**
|
|
456
|
-
* Resolve how to launch a
|
|
457
|
-
*
|
|
458
|
-
* Resolution order:
|
|
459
|
-
* 1. `process.argv[1]` — the current Pi process entry, verified against the
|
|
460
|
-
* package manifest (avoids mistaking unrelated scripts for the CLI).
|
|
461
|
-
* 2. The installed `@earendil-works/pi-coding-agent` package's `dist/cli.js`.
|
|
462
|
-
* 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.
|
|
463
459
|
*/
|
|
464
|
-
export
|
|
465
|
-
const argv1 = process.argv[1];
|
|
466
|
-
if (argv1 && isPiPackageScript(argv1)) {
|
|
467
|
-
return { command: process.execPath, args: [path.resolve(argv1)] };
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
try {
|
|
471
|
-
const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
472
|
-
const packageRoot = path.dirname(path.dirname(entry));
|
|
473
|
-
const cliPath = path.join(packageRoot, "dist", "cli.js");
|
|
474
|
-
if (fs.existsSync(cliPath)) {
|
|
475
|
-
return { command: process.execPath, args: [cliPath] };
|
|
476
|
-
}
|
|
477
|
-
} catch {
|
|
478
|
-
// Package resolution is best-effort; fall through to PATH.
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
return { command: "pi", args: [] };
|
|
482
|
-
}
|
|
460
|
+
export { resolvePiCli } from "@fradser/pi-kit";
|
|
483
461
|
|
|
484
462
|
/**
|
|
485
463
|
* Spawn a child Pi process that executes the task description in
|
|
@@ -491,11 +469,10 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
491
469
|
// binary surfaces later as a child "error" event through onError.
|
|
492
470
|
const cli = resolvePiCli();
|
|
493
471
|
const workerExtension = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
472
|
+
const mode = options.mode ?? "json";
|
|
494
473
|
const args: string[] = [
|
|
495
474
|
...cli.args,
|
|
496
|
-
"--print",
|
|
497
|
-
"--mode",
|
|
498
|
-
"json",
|
|
475
|
+
...(mode === "rpc" ? ["--mode", "rpc"] : ["--print", "--mode", mode]),
|
|
499
476
|
"--no-session",
|
|
500
477
|
"--no-extensions",
|
|
501
478
|
"--extension",
|
|
@@ -524,14 +501,14 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
524
501
|
error: error instanceof Error ? error.message : String(error),
|
|
525
502
|
});
|
|
526
503
|
|
|
504
|
+
let taskText = `Task: ${options.description}`;
|
|
527
505
|
try {
|
|
528
|
-
|
|
529
|
-
if (taskText.length > TASK_ARG_LIMIT) {
|
|
506
|
+
if (mode !== "rpc" && taskText.length > TASK_ARG_LIMIT) {
|
|
530
507
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "teammate-"));
|
|
531
508
|
const taskFile = path.join(tempDir, "task.md");
|
|
532
509
|
fs.writeFileSync(taskFile, taskText, { mode: 0o600 });
|
|
533
510
|
args.push(`@${taskFile}`);
|
|
534
|
-
} else {
|
|
511
|
+
} else if (mode !== "rpc") {
|
|
535
512
|
args.push(taskText);
|
|
536
513
|
}
|
|
537
514
|
} catch (error) {
|
|
@@ -544,7 +521,7 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
544
521
|
child = spawn(cli.command, args, {
|
|
545
522
|
cwd: options.cwd,
|
|
546
523
|
env: { ...process.env, ...options.env },
|
|
547
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
524
|
+
stdio: [mode === "rpc" ? "pipe" : "ignore", "pipe", "pipe"],
|
|
548
525
|
});
|
|
549
526
|
} catch (error) {
|
|
550
527
|
cleanupTempDir();
|
|
@@ -552,12 +529,16 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
552
529
|
}
|
|
553
530
|
if (options.workerName) workers.set(options.workerName, child);
|
|
554
531
|
|
|
532
|
+
if (mode === "rpc") {
|
|
533
|
+
child.stdin?.write(`${JSON.stringify({ type: "prompt", id: randomUUID(), message: taskText })}\n`);
|
|
534
|
+
}
|
|
535
|
+
|
|
555
536
|
const stdoutChunks: string[] = [];
|
|
556
537
|
const stderrChunks: string[] = [];
|
|
557
538
|
const streamState = createWorkerStreamState();
|
|
558
539
|
let stdoutBuffer = "";
|
|
559
|
-
let timeoutHandle: WorkerTimeoutHandle | undefined;
|
|
560
540
|
let settled = false;
|
|
541
|
+
let turnBudgetExceeded = false;
|
|
561
542
|
const emitProgress = () => options.onUpdate?.({
|
|
562
543
|
text: truncate(streamState.text, OUTPUT_CAP),
|
|
563
544
|
activeTool: streamState.activeTool,
|
|
@@ -565,24 +546,25 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
565
546
|
turns: streamState.turns,
|
|
566
547
|
finalResponse: streamState.finalResponse,
|
|
567
548
|
});
|
|
568
|
-
child.stdout
|
|
549
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
569
550
|
const text = chunk.toString();
|
|
570
|
-
stdoutChunks
|
|
551
|
+
appendCapped(stdoutChunks, text, OUTPUT_CAP * 2);
|
|
571
552
|
stdoutBuffer += text;
|
|
572
553
|
const lines = stdoutBuffer.split("\n");
|
|
573
554
|
stdoutBuffer = lines.pop() ?? "";
|
|
574
555
|
let changed = false;
|
|
575
|
-
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
|
+
}
|
|
576
563
|
if (changed) emitProgress();
|
|
577
564
|
});
|
|
578
|
-
child.stderr
|
|
579
|
-
|
|
580
|
-
if (options.timeoutMs) {
|
|
581
|
-
timeoutHandle = registerWorkerTimeout(child, options.timeoutMs);
|
|
582
|
-
}
|
|
565
|
+
child.stderr?.on("data", (chunk: Buffer) => appendCapped(stderrChunks, chunk.toString(), OUTPUT_CAP * 2));
|
|
583
566
|
|
|
584
567
|
child.on("error", (error) => {
|
|
585
|
-
timeoutHandle?.dispose();
|
|
586
568
|
cleanupTempDir();
|
|
587
569
|
settled = true;
|
|
588
570
|
if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
|
|
@@ -590,8 +572,6 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
590
572
|
});
|
|
591
573
|
|
|
592
574
|
child.on("close", (code, signal) => {
|
|
593
|
-
const timedOut = timeoutHandle?.didTimeout() ?? false;
|
|
594
|
-
timeoutHandle?.dispose();
|
|
595
575
|
cleanupTempDir();
|
|
596
576
|
// A spawn failure already reported via onError (Node fires error then
|
|
597
577
|
// close) — do not double-report through onExit, which would let a failed
|
|
@@ -599,12 +579,12 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
599
579
|
if (settled) return;
|
|
600
580
|
if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
|
|
601
581
|
const rawStdout = stdoutChunks.join("");
|
|
602
|
-
const parsed =
|
|
603
|
-
?
|
|
604
|
-
:
|
|
582
|
+
const parsed = mode === "text"
|
|
583
|
+
? { text: rawStdout.trim(), usage: undefined }
|
|
584
|
+
: parseWorkerOutput(rawStdout);
|
|
605
585
|
const stdout = truncate(parsed.text, OUTPUT_CAP);
|
|
606
586
|
const stderr = truncate(stderrChunks.join("").trim(), OUTPUT_CAP);
|
|
607
|
-
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 });
|
|
608
588
|
});
|
|
609
589
|
|
|
610
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 };
|