@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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: follow-up-queue
|
|
3
|
+
description: Agent Teams serializes automatic teammate reports through Pi's void sendUserMessage API with lifecycle matching, retry backoff, and session invalidation
|
|
4
|
+
type: project
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Pi's extension `sendUserMessage` API returns `void`; asynchronous preflight failures are routed to the runtime error handler rather than thrown to the caller. Concurrent automatic reports can also race Pi's prompt startup and produce `Agent is already processing a prompt`.
|
|
10
|
+
|
|
11
|
+
## How to apply
|
|
12
|
+
|
|
13
|
+
- `packages/agent-teams/src/follow-up-queue.ts` owns a FIFO report queue and one active dispatch.
|
|
14
|
+
- A dispatch is matched by its exact prompt in `before_agent_start`, confirmed by `agent_start`, and released only by the corresponding `agent_settled` path. Reports arriving during an active leader run remain queued.
|
|
15
|
+
- A watchdog restores a batch when no `agent_start` occurs; retries use bounded exponential backoff rather than a microtask spin.
|
|
16
|
+
- `reset()` increments a generation and clears timers, so delayed callbacks from an old session cannot deliver to a replacement session.
|
|
17
|
+
- Keep `sendMainSessionFollowUp` as a synchronous enqueue operation; do not call Pi's void API directly from worker completion paths.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: node-terminal-followups
|
|
3
|
+
description: Background Agent Teams runs notify the main session after each non-summary teammate reaches a terminal outcome, including pre-spawn setup failures, then send a separate run summary when all nodes settle
|
|
4
|
+
type: project
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
The leader needs incremental results while independent teammates are still working. Waiting for the run-level settle message hides completed work and delays downstream attention. Setup failures must use the same delivery contract as child-process failures.
|
|
10
|
+
|
|
11
|
+
## How to apply
|
|
12
|
+
|
|
13
|
+
- `run-machine.ts` sends one node follow-up for each non-summary node in a background run when it reports `completed`/`failed`, when its child process reaches a terminal outcome, or when pre-spawn setup fails (agent/state-file/worktree setup).
|
|
14
|
+
- `Node.nodeFollowUpSent` prevents duplicate follow-ups when a worker report is followed by process-close finalization; retry clears the marker.
|
|
15
|
+
- The node follow-up uses the worker's full terminal report/body and does not stop remaining teammates.
|
|
16
|
+
- The existing run-settled follow-up remains separate and is sent once after all nodes settle; foreground (`background=false`) runs continue to return their result inline without automatic node follow-ups.
|
|
17
|
+
- Worker turn budgets default to 100 assistant turns as a high safety cap; explicit lower budgets remain available for deliberately bounded tasks.
|
package/README.md
CHANGED
|
@@ -50,7 +50,8 @@ teammate_run({
|
|
|
50
50
|
- `access` defaults to `read`; declare `write` explicitly.
|
|
51
51
|
- `dependsOn` edges must form a DAG (duplicate ids, unknown references, and cycles are rejected before any worker starts).
|
|
52
52
|
- `worktree: true` runs every node in its own git worktree and captures each diff for integration review.
|
|
53
|
-
- `background` defaults to `true`: teammates always run in the background — the call returns the run id immediately, the model turn stays free, and
|
|
53
|
+
- `background` defaults to `true`: teammates always run in the background — the call returns the run id immediately, the model turn stays free, and each teammate's terminal outcome sends its full deliverable in an immediate follow-up while the remaining teammates continue. A final run-completion follow-up is sent after all nodes settle. The team leader does not sleep or busy-wait while tasks execute. Pass `background=false` to block and gather inline (it detaches after 5 minutes so the turn is never hung).
|
|
54
|
+
- `turnBudget` defaults to a high safety cap of 100 assistant turns; use an explicit lower value only when a task needs tighter bounds. This protects against edge cases without interrupting normal work.
|
|
54
55
|
- `timeoutMs` is a run-level hard cap: when exceeded, the run fails and live workers are terminated.
|
|
55
56
|
- A session-wide cap of 8 worker processes applies in addition to each run's `concurrency` limit.
|
|
56
57
|
- Multi-node runs append a `__summary` node by default (`summarize=false` to skip). Single-task runs stay compact unless `summarize=true`.
|
|
@@ -86,6 +87,7 @@ State is session-scoped and dies with the session (`~/.pi/agent/teammate/<sessio
|
|
|
86
87
|
|
|
87
88
|
```
|
|
88
89
|
agent-teams/
|
|
90
|
+
├── index.ts — package-root extension entry point
|
|
89
91
|
├── package.json — Pi package manifest
|
|
90
92
|
├── agents/ — bundled agent definitions (worker/reviewer/specialist/observer)
|
|
91
93
|
├── src/
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fradser/pi-agent-teams",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Multi-agent team system for Pi — DAG orchestration, leader inbox, and one-way worker reports",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
|
+
"index.ts",
|
|
25
26
|
"src",
|
|
26
27
|
"agents",
|
|
27
28
|
".memory",
|
|
@@ -34,10 +35,10 @@
|
|
|
34
35
|
},
|
|
35
36
|
"pi": {
|
|
36
37
|
"extensions": [
|
|
37
|
-
"./
|
|
38
|
+
"./index.ts"
|
|
38
39
|
]
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
|
-
"@fradser/pi-kit": "0.1.
|
|
42
|
+
"@fradser/pi-kit": "0.1.1"
|
|
42
43
|
}
|
|
43
44
|
}
|
package/src/console-viewport.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
|
2
2
|
|
|
3
3
|
/** Leave room for the console borders, title, spacing, and footer. */
|
|
4
4
|
export function maxConsoleBody(rows: number): number {
|
|
5
|
-
|
|
5
|
+
// Detail chrome: border, header, spacer, spacer, footer, border.
|
|
6
|
+
return Math.max(3, rows - 7);
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
/** Wrap source records before calculating scroll positions. */
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export interface FollowUpReport {
|
|
2
|
+
subject: string;
|
|
3
|
+
body: string;
|
|
4
|
+
runId?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface FollowUpQueueOptions {
|
|
8
|
+
isIdle: () => boolean;
|
|
9
|
+
dispatch: (content: string) => void;
|
|
10
|
+
onFailure?: (message: string) => void;
|
|
11
|
+
agentStartTimeoutMs?: number;
|
|
12
|
+
retryBaseDelayMs?: number;
|
|
13
|
+
retryMaxDelayMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PendingDispatch {
|
|
17
|
+
reports: FollowUpReport[];
|
|
18
|
+
content: string;
|
|
19
|
+
prepared: boolean;
|
|
20
|
+
started: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Serializes automatic reports sent through Pi's void sendUserMessage API.
|
|
25
|
+
*
|
|
26
|
+
* The API reports asynchronous preflight failures through the runtime error
|
|
27
|
+
* channel, so this queue uses agent lifecycle events for success and a bounded
|
|
28
|
+
* watchdog for attempts that never reach agent_start.
|
|
29
|
+
*/
|
|
30
|
+
export class FollowUpQueue {
|
|
31
|
+
private readonly isIdle: FollowUpQueueOptions["isIdle"];
|
|
32
|
+
private readonly dispatch: FollowUpQueueOptions["dispatch"];
|
|
33
|
+
private readonly onFailure: (message: string) => void;
|
|
34
|
+
private readonly agentStartTimeoutMs: number;
|
|
35
|
+
private readonly retryBaseDelayMs: number;
|
|
36
|
+
private readonly retryMaxDelayMs: number;
|
|
37
|
+
private pending: FollowUpReport[] = [];
|
|
38
|
+
private active: PendingDispatch | undefined;
|
|
39
|
+
private pumpScheduled = false;
|
|
40
|
+
private retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
private watchdogTimer: ReturnType<typeof setTimeout> | undefined;
|
|
42
|
+
private generation = 0;
|
|
43
|
+
private failureCount = 0;
|
|
44
|
+
|
|
45
|
+
constructor(options: FollowUpQueueOptions) {
|
|
46
|
+
this.isIdle = options.isIdle;
|
|
47
|
+
this.dispatch = options.dispatch;
|
|
48
|
+
this.onFailure = options.onFailure ?? (() => {});
|
|
49
|
+
this.agentStartTimeoutMs = options.agentStartTimeoutMs ?? 30_000;
|
|
50
|
+
this.retryBaseDelayMs = options.retryBaseDelayMs ?? 1_000;
|
|
51
|
+
this.retryMaxDelayMs = options.retryMaxDelayMs ?? 30_000;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
enqueue(report: FollowUpReport): void {
|
|
55
|
+
this.pending.push(report);
|
|
56
|
+
this.schedulePump();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Match the active dispatch to the prompt that Pi is about to start. */
|
|
60
|
+
onBeforeAgentStart(prompt: string): void {
|
|
61
|
+
if (!this.active || this.active.prepared || prompt !== this.active.content) return;
|
|
62
|
+
this.active.prepared = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Mark the matching dispatch started after before_agent_start completes. */
|
|
66
|
+
onAgentStart(): void {
|
|
67
|
+
if (!this.active || !this.active.prepared || this.active.started) return;
|
|
68
|
+
this.active.started = true;
|
|
69
|
+
this.clearWatchdog();
|
|
70
|
+
this.failureCount = 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Release a completed dispatch; unrelated settled events are ignored. */
|
|
74
|
+
onAgentSettled(): void {
|
|
75
|
+
if (this.active && !this.active.started) return;
|
|
76
|
+
if (this.active) {
|
|
77
|
+
this.active = undefined;
|
|
78
|
+
this.failureCount = 0;
|
|
79
|
+
}
|
|
80
|
+
this.schedulePump();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Drop all reports and invalidate callbacks from the previous session. */
|
|
84
|
+
reset(): void {
|
|
85
|
+
this.generation++;
|
|
86
|
+
this.pending = [];
|
|
87
|
+
this.active = undefined;
|
|
88
|
+
this.pumpScheduled = false;
|
|
89
|
+
this.failureCount = 0;
|
|
90
|
+
this.clearRetryTimer();
|
|
91
|
+
this.clearWatchdog();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get pendingCount(): number {
|
|
95
|
+
return this.pending.length + (this.active?.reports.length ?? 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private schedulePump(): void {
|
|
99
|
+
if (this.pumpScheduled) return;
|
|
100
|
+
this.pumpScheduled = true;
|
|
101
|
+
const generation = this.generation;
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
this.pumpScheduled = false;
|
|
104
|
+
if (generation !== this.generation) return;
|
|
105
|
+
this.pump();
|
|
106
|
+
}, 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private pump(): void {
|
|
110
|
+
if (this.active || this.retryTimer || this.pending.length === 0 || !this.isIdle()) return;
|
|
111
|
+
const reports = this.pending.splice(0);
|
|
112
|
+
const content = formatReports(reports);
|
|
113
|
+
const queuedIntoActiveRun = !this.isIdle();
|
|
114
|
+
this.active = {
|
|
115
|
+
reports,
|
|
116
|
+
content,
|
|
117
|
+
prepared: queuedIntoActiveRun,
|
|
118
|
+
started: queuedIntoActiveRun,
|
|
119
|
+
};
|
|
120
|
+
const generation = this.generation;
|
|
121
|
+
try {
|
|
122
|
+
this.dispatch(content);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
this.failActive(generation, error instanceof Error ? error.message : String(error));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!queuedIntoActiveRun) {
|
|
128
|
+
this.watchdogTimer = setTimeout(() => {
|
|
129
|
+
if (generation !== this.generation || !this.active || this.active.started) return;
|
|
130
|
+
this.failActive(generation, "Pi did not start the automatic teammate follow-up.");
|
|
131
|
+
}, this.agentStartTimeoutMs);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private failActive(generation: number, message: string): void {
|
|
136
|
+
if (generation !== this.generation || !this.active) return;
|
|
137
|
+
const failed = this.active;
|
|
138
|
+
this.active = undefined;
|
|
139
|
+
this.pending.unshift(...failed.reports);
|
|
140
|
+
this.clearWatchdog();
|
|
141
|
+
this.failureCount++;
|
|
142
|
+
const delay = Math.min(
|
|
143
|
+
this.retryMaxDelayMs,
|
|
144
|
+
this.retryBaseDelayMs * 2 ** Math.max(0, this.failureCount - 1),
|
|
145
|
+
);
|
|
146
|
+
this.onFailure(`${message} Retrying in ${Math.ceil(delay / 1000)}s.`);
|
|
147
|
+
this.retryTimer = setTimeout(() => {
|
|
148
|
+
this.retryTimer = undefined;
|
|
149
|
+
if (generation === this.generation) this.schedulePump();
|
|
150
|
+
}, delay);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private clearRetryTimer(): void {
|
|
154
|
+
if (!this.retryTimer) return;
|
|
155
|
+
clearTimeout(this.retryTimer);
|
|
156
|
+
this.retryTimer = undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private clearWatchdog(): void {
|
|
160
|
+
if (!this.watchdogTimer) return;
|
|
161
|
+
clearTimeout(this.watchdogTimer);
|
|
162
|
+
this.watchdogTimer = undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function formatReports(reports: FollowUpReport[]): string {
|
|
167
|
+
return reports
|
|
168
|
+
.map(({ subject, body }) => `Teammate update: ${subject}\n${body}`)
|
|
169
|
+
.join("\n\n");
|
|
170
|
+
}
|
package/src/guidance.ts
CHANGED
|
@@ -43,8 +43,10 @@ ${agents}
|
|
|
43
43
|
### Dispatch a run in one call
|
|
44
44
|
|
|
45
45
|
Use teammate_run with a tasks array: each task has id, agent, prompt, paths,
|
|
46
|
-
access (read default, write explicit), optional dependsOn, model,
|
|
47
|
-
|
|
46
|
+
access (read default, write explicit), optional dependsOn, model, turnBudget
|
|
47
|
+
(default 100 assistant turns), forkContext, and inputBindings. Use teammate_fanout only from the leader after
|
|
48
|
+
validating a completed node's bounded structured array output. The scheduler
|
|
49
|
+
starts root nodes immediately, bounds concurrency,
|
|
48
50
|
defers overlapping shared-workspace writes — advisory coordination across all
|
|
49
51
|
runs, not file-access isolation — unless worktree=true, and auto-starts
|
|
50
52
|
downstream nodes when their dependencies complete. access and paths coordinate
|
|
@@ -52,13 +54,14 @@ scheduling only; a worker's real capabilities come from its agent definition's
|
|
|
52
54
|
tools list.
|
|
53
55
|
|
|
54
56
|
Teammates run in the background by default: the call returns the run id
|
|
55
|
-
immediately, and the main session is free. When
|
|
56
|
-
its final deliverable
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
immediately, and the main session is free. When any teammate reaches a
|
|
58
|
+
terminal outcome, its full final deliverable is sent in an immediate follow-up;
|
|
59
|
+
the remaining teammates continue running. The harness also delivers one run
|
|
60
|
+
completion follow-up after all nodes settle. Pass background=false only when
|
|
61
|
+
you strictly need inline synchronous blocking (it detaches after 5 minutes so
|
|
62
|
+
the turn is never hung). A session-wide cap of 8 worker processes applies in
|
|
63
|
+
addition to each run's concurrency. Multi-node runs append a __summary node by
|
|
64
|
+
default; pass summarize=false to skip it.
|
|
62
65
|
|
|
63
66
|
### DO NOT poll status
|
|
64
67
|
|
package/src/index.ts
CHANGED
|
@@ -15,22 +15,16 @@ import { ensureTeamWidget, refreshTeamUI, stopUiTimers } from "./ui";
|
|
|
15
15
|
import { registerLeaderTools, registerTeamCommand } from "./tools";
|
|
16
16
|
import { registerWorkerCapabilities, workerOutboxBinding } from "./worker";
|
|
17
17
|
import { terminateAllWorkers } from "./spawner";
|
|
18
|
+
import { FollowUpQueue } from "./follow-up-queue";
|
|
18
19
|
|
|
19
20
|
const STATE_DIR_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
21
|
|
|
21
22
|
let leaderPi: ExtensionAPI | undefined;
|
|
23
|
+
let leaderCtx: ExtensionContext | undefined;
|
|
24
|
+
let followUpQueue: FollowUpQueue | undefined;
|
|
22
25
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
leaderPi?.sendMessage({
|
|
26
|
-
customType: "teammate-update",
|
|
27
|
-
content: `Teammate update — ${subject}${runId ? ` [${runId}]` : ""}\n${body}`,
|
|
28
|
-
display: true,
|
|
29
|
-
details: { runId },
|
|
30
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
31
|
-
} catch {
|
|
32
|
-
// Late run events must not prevent shutdown.
|
|
33
|
-
}
|
|
26
|
+
function sendMainSessionFollowUp(subject: string, body: string, runId?: string): void {
|
|
27
|
+
followUpQueue?.enqueue({ subject, body, runId });
|
|
34
28
|
}
|
|
35
29
|
|
|
36
30
|
function dispatchContext(ctx: ExtensionContext): DispatchCtx {
|
|
@@ -52,28 +46,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
52
46
|
|
|
53
47
|
pi.on("session_start", async (_event, ctx) => {
|
|
54
48
|
resetState();
|
|
49
|
+
followUpQueue?.reset();
|
|
50
|
+
leaderCtx = ctx;
|
|
51
|
+
const sessionQueue = new FollowUpQueue({
|
|
52
|
+
isIdle: () => Boolean(leaderCtx?.isIdle()),
|
|
53
|
+
dispatch: (content) => leaderPi?.sendUserMessage(content, { deliverAs: "followUp" }),
|
|
54
|
+
onFailure: (message) => leaderCtx?.ui.notify(message, "warning"),
|
|
55
|
+
});
|
|
56
|
+
followUpQueue = sessionQueue;
|
|
55
57
|
const dispatchCtx = dispatchContext(ctx);
|
|
56
58
|
ensureTeamWidget(ctx);
|
|
57
59
|
const stateFile = stateFilePath(ctx.sessionManager.getSessionFile(), ctx.cwd || process.cwd());
|
|
58
60
|
initRunMachine(dispatchCtx, stateFile, {
|
|
59
|
-
sendUpdate:
|
|
60
|
-
notifyChange: () => refreshTeamUI(),
|
|
61
|
+
sendUpdate: sendMainSessionFollowUp,
|
|
62
|
+
notifyChange: () => refreshTeamUI(leaderCtx),
|
|
61
63
|
});
|
|
62
64
|
ctx.ui.setStatus("teammate", undefined);
|
|
63
65
|
refreshTeamUI(ctx);
|
|
64
66
|
void cleanupExpiredStateDirs(STATE_DIR_MAX_AGE_MS);
|
|
65
67
|
});
|
|
66
68
|
|
|
69
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
70
|
+
followUpQueue?.onBeforeAgentStart(event.prompt);
|
|
71
|
+
return {
|
|
72
|
+
systemPrompt: event.systemPrompt + buildTeamLeaderGuidance(ctx?.cwd ?? process.cwd()),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pi.on("agent_start", async () => {
|
|
77
|
+
followUpQueue?.onAgentStart();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
pi.on("agent_settled", async () => {
|
|
81
|
+
followUpQueue?.onAgentSettled();
|
|
82
|
+
});
|
|
83
|
+
|
|
67
84
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
68
85
|
stopUiTimers();
|
|
69
86
|
await terminateAllWorkers();
|
|
70
87
|
shutdownRunMachine();
|
|
71
88
|
removeSessionStateDir(ctx.sessionManager.getSessionFile(), ctx.cwd || process.cwd());
|
|
89
|
+
followUpQueue?.reset();
|
|
90
|
+
followUpQueue = undefined;
|
|
72
91
|
leaderPi = undefined;
|
|
92
|
+
leaderCtx = undefined;
|
|
73
93
|
resetState();
|
|
74
94
|
});
|
|
75
95
|
|
|
76
|
-
pi.on("before_agent_start", async (event, ctx) => ({
|
|
77
|
-
systemPrompt: event.systemPrompt + buildTeamLeaderGuidance(ctx?.cwd ?? process.cwd()),
|
|
78
|
-
}));
|
|
79
96
|
}
|