pi-squad 0.16.0 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/agent-pool.ts +24 -15
- package/src/plan-rules.ts +33 -2
- package/src/protocol.ts +22 -3
- package/src/router.ts +46 -6
- package/src/scheduler.ts +10 -7
package/README.md
CHANGED
|
@@ -127,7 +127,11 @@ Bundled agent definitions are copied to `~/.pi/squad/agents/` on first run. Edit
|
|
|
127
127
|
|
|
128
128
|
### Agent Collaboration
|
|
129
129
|
|
|
130
|
-
**Chain context**: When task A completes, its output is injected into
|
|
130
|
+
**Chain context**: When task A completes, its complete output is injected into downstream prompts across the full dependency-ancestor closure (ancestors first, diamond dependencies deduplicated), not only the final direct edge. Integration and QA tasks therefore receive the original contracts their inputs were built from.
|
|
131
|
+
|
|
132
|
+
**Completed-agent replies**: An `@agent` request aimed at an agent whose task already finished is answered immediately from that agent's durable task output. It is never queued for a nonexistent future spawn, and a blocker resolved this way does not escalate to the human.
|
|
133
|
+
|
|
134
|
+
**Report-only work**: Planning/review agents may complete with a substantive assistant artifact even when they needed no tool call. Their output still passes through mandatory independent orchestrator review.
|
|
131
135
|
|
|
132
136
|
**Shared filesystem**: All agents work in the same project directory. Upstream agents create files, downstream agents read and modify them.
|
|
133
137
|
|
|
@@ -135,6 +139,8 @@ Bundled agent definitions are copied to `~/.pi/squad/agents/` on first run. Edit
|
|
|
135
139
|
|
|
136
140
|
**@mention routing**: Agents write `@frontend what token format?` in their output. The router delivers it in real-time via RPC `steer()`.
|
|
137
141
|
|
|
142
|
+
**Main-session steering**: `squad_message` resolves an agent name to its live task, durably records the full message, and writes the documented JSONL RPC command `{"type":"steer","message":"..."}` to the child. pi-squad waits for Pi's final `agent_settled` event before closing the child; low-level `agent_end` no longer kills queued steering/follow-up continuations.
|
|
143
|
+
|
|
138
144
|
### Smart Planner
|
|
139
145
|
|
|
140
146
|
The planner creates task breakdowns with proper dependency ordering:
|
package/package.json
CHANGED
package/src/agent-pool.ts
CHANGED
|
@@ -270,16 +270,14 @@ export class AgentPool {
|
|
|
270
270
|
async steer(taskId: string, message: string): Promise<boolean> {
|
|
271
271
|
const agent = this.agents.get(taskId);
|
|
272
272
|
if (!agent || agent.aborted || agent.process.exitCode !== null) return false;
|
|
273
|
-
this.sendRpcCommand(agent.process, { type: "steer", message });
|
|
274
|
-
return true;
|
|
273
|
+
return this.sendRpcCommand(agent.process, { type: "steer", message });
|
|
275
274
|
}
|
|
276
275
|
|
|
277
276
|
/** Queue a follow-up message for after the current turn */
|
|
278
277
|
async followUp(taskId: string, message: string): Promise<boolean> {
|
|
279
278
|
const agent = this.agents.get(taskId);
|
|
280
279
|
if (!agent || agent.aborted || agent.process.exitCode !== null) return false;
|
|
281
|
-
this.sendRpcCommand(agent.process, { type: "follow_up", message });
|
|
282
|
-
return true;
|
|
280
|
+
return this.sendRpcCommand(agent.process, { type: "follow_up", message });
|
|
283
281
|
}
|
|
284
282
|
|
|
285
283
|
/** Abort the current operation */
|
|
@@ -301,7 +299,7 @@ export class AgentPool {
|
|
|
301
299
|
agent.process.kill("SIGTERM");
|
|
302
300
|
// Force kill after 5s
|
|
303
301
|
const timer = setTimeout(() => {
|
|
304
|
-
if (
|
|
302
|
+
if (agent.process.exitCode === null) agent.process.kill("SIGKILL");
|
|
305
303
|
}, 5000);
|
|
306
304
|
await new Promise<void>((resolve) => {
|
|
307
305
|
agent.process.on("exit", () => {
|
|
@@ -337,9 +335,17 @@ export class AgentPool {
|
|
|
337
335
|
// Internal
|
|
338
336
|
// =========================================================================
|
|
339
337
|
|
|
340
|
-
private sendRpcCommand(proc: ChildProcess, command: Record<string, unknown>):
|
|
341
|
-
if (!proc.stdin || proc.stdin.destroyed) return;
|
|
342
|
-
|
|
338
|
+
private sendRpcCommand(proc: ChildProcess, command: Record<string, unknown>): boolean {
|
|
339
|
+
if (!proc.stdin || proc.stdin.destroyed) return false;
|
|
340
|
+
try {
|
|
341
|
+
// Writable.write(false) means backpressure, not rejection; the bytes are
|
|
342
|
+
// still buffered. Reaching write() without throwing means the JSONL
|
|
343
|
+
// command was handed to the child stream.
|
|
344
|
+
proc.stdin.write(serializeJsonLine(command));
|
|
345
|
+
return true;
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
343
349
|
}
|
|
344
350
|
|
|
345
351
|
private handleRpcEvent(agent: AgentProcess, event: any): void {
|
|
@@ -394,10 +400,12 @@ export class AgentPool {
|
|
|
394
400
|
data: event,
|
|
395
401
|
});
|
|
396
402
|
} else if (event.type === "agent_end") {
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
debug("squad-pool", `agent_end from RPC: ${agent.agentName} (task: ${agent.taskId})`);
|
|
403
|
+
// agent_end is one low-level run. Pi may still process queued steer,
|
|
404
|
+
// follow-up, retry, or compaction continuations. Killing here races and
|
|
405
|
+
// drops main-session steering. agent_settled is the final lifecycle edge.
|
|
406
|
+
debug("squad-pool", `agent_end from RPC (awaiting settled): ${agent.agentName} (task: ${agent.taskId})`);
|
|
407
|
+
} else if (event.type === "agent_settled") {
|
|
408
|
+
debug("squad-pool", `agent_settled from RPC: ${agent.agentName} (task: ${agent.taskId})`);
|
|
401
409
|
// Mark the guard to prevent double-emit from proc.on("exit")
|
|
402
410
|
const guardFn = (agent as any)._agentEndEmitted;
|
|
403
411
|
if (guardFn) guardFn();
|
|
@@ -417,11 +425,12 @@ export class AgentPool {
|
|
|
417
425
|
filesModified: endActivity.modifiedFiles.size,
|
|
418
426
|
},
|
|
419
427
|
});
|
|
420
|
-
//
|
|
428
|
+
// The session is fully settled, so the RPC process can now close.
|
|
421
429
|
agent.process.kill("SIGTERM");
|
|
422
|
-
setTimeout(() => {
|
|
423
|
-
if (
|
|
430
|
+
const forceKill = setTimeout(() => {
|
|
431
|
+
if (agent.process.exitCode === null) agent.process.kill("SIGKILL");
|
|
424
432
|
}, 3000);
|
|
433
|
+
forceKill.unref();
|
|
425
434
|
} else if (event.type === "error") {
|
|
426
435
|
this.emit({
|
|
427
436
|
type: "error",
|
package/src/plan-rules.ts
CHANGED
|
@@ -53,6 +53,29 @@ function isQaLikeTask(task: PlanTaskInput): boolean {
|
|
|
53
53
|
return /\b(qa|test|tests|testing|verif\w*|review|audit)\b/.test(hay);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Task IDs explicitly named in a "depend on ..." Context sentence. */
|
|
57
|
+
function describedDependencies(task: PlanTaskInput, knownIds: Set<string>): string[] {
|
|
58
|
+
const refs = new Set<string>();
|
|
59
|
+
for (const clause of task.description.matchAll(/\bdepend(?:s|ing)?\s+on\b([^.;\n]*)/gi)) {
|
|
60
|
+
for (const quoted of clause[1].matchAll(/`([^`]+)`/g)) {
|
|
61
|
+
if (knownIds.has(quoted[1])) refs.add(quoted[1]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...refs];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function dependencyClosure(task: PlanTaskInput, byId: Map<string, PlanTaskInput>): Set<string> {
|
|
68
|
+
const closure = new Set<string>();
|
|
69
|
+
const visit = (id: string): void => {
|
|
70
|
+
if (closure.has(id)) return;
|
|
71
|
+
closure.add(id);
|
|
72
|
+
const dependency = byId.get(id);
|
|
73
|
+
if (dependency) for (const ancestor of dependency.depends) visit(ancestor);
|
|
74
|
+
};
|
|
75
|
+
for (const dependency of task.depends) visit(dependency);
|
|
76
|
+
return closure;
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
/**
|
|
57
80
|
* Validate a plan's structure. Errors block squad creation;
|
|
58
81
|
* warnings are returned to the plan author for correction.
|
|
@@ -125,8 +148,16 @@ export function validatePlan(tasks: PlanTaskInput[]): PlanValidation {
|
|
|
125
148
|
for (const t of tasks) {
|
|
126
149
|
if (!t.description || t.description.trim().length === 0) {
|
|
127
150
|
warnings.push(`Task "${t.id}" has no description — the agent only gets the title.`);
|
|
128
|
-
} else
|
|
129
|
-
|
|
151
|
+
} else {
|
|
152
|
+
if (!/verif|test|check|curl|run\b|npm |pnpm |cargo |pytest|tsc\b/i.test(t.description)) {
|
|
153
|
+
warnings.push(`Task "${t.id}" description has no Verify criterion — the agent won't know how to prove it's done.`);
|
|
154
|
+
}
|
|
155
|
+
const closure = dependencyClosure(t, byId);
|
|
156
|
+
for (const described of describedDependencies(t, ids)) {
|
|
157
|
+
if (!closure.has(described)) {
|
|
158
|
+
warnings.push(`Task "${t.id}" says it depends on "${described}", but that task is absent from its formal dependency closure.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
130
161
|
}
|
|
131
162
|
}
|
|
132
163
|
|
package/src/protocol.ts
CHANGED
|
@@ -90,9 +90,10 @@ function buildChainContext(task: Task, allTasks: Task[], squadId: string): strin
|
|
|
90
90
|
|
|
91
91
|
const sections: string[] = [];
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
// A downstream integration/QA task needs the contracts its direct inputs
|
|
94
|
+
// were built from, not only the last edge in the DAG. Walk the complete
|
|
95
|
+
// ancestor closure, ancestors first, and deduplicate diamond dependencies.
|
|
96
|
+
for (const dep of completedDependencyClosure(task, allTasks)) {
|
|
96
97
|
|
|
97
98
|
let section = `## ${dep.id} (done by ${dep.agent})\n**${dep.title}**\n`;
|
|
98
99
|
if (dep.output) {
|
|
@@ -119,6 +120,24 @@ ${sections.join("\n---\n\n")}
|
|
|
119
120
|
`;
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
function completedDependencyClosure(task: Task, allTasks: Task[]): Task[] {
|
|
124
|
+
const byId = new Map(allTasks.map((candidate) => [candidate.id, candidate]));
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
const ordered: Task[] = [];
|
|
127
|
+
|
|
128
|
+
const visit = (id: string): void => {
|
|
129
|
+
if (seen.has(id)) return;
|
|
130
|
+
seen.add(id);
|
|
131
|
+
const dependency = byId.get(id);
|
|
132
|
+
if (!dependency) return;
|
|
133
|
+
for (const ancestorId of dependency.depends) visit(ancestorId);
|
|
134
|
+
if (dependency.status === "done") ordered.push(dependency);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
for (const dependencyId of task.depends) visit(dependencyId);
|
|
138
|
+
return ordered;
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
// ============================================================================
|
|
123
142
|
// Sibling Awareness
|
|
124
143
|
// ============================================================================
|
package/src/router.ts
CHANGED
|
@@ -47,12 +47,18 @@ export class Router {
|
|
|
47
47
|
processMessage(taskId: string, fromAgent: string, text: string): void {
|
|
48
48
|
// Parse @mentions
|
|
49
49
|
const mentions = this.parseMentions(text, fromAgent);
|
|
50
|
+
let resolvedFromDurableOutput = false;
|
|
50
51
|
for (const mention of mentions) {
|
|
51
|
-
this.routeMention(taskId, fromAgent, mention.target, mention.message);
|
|
52
|
+
const resolved = this.routeMention(taskId, fromAgent, mention.target, mention.message);
|
|
53
|
+
// Only suppress escalation when the resolved mention itself expressed
|
|
54
|
+
// the blocker; an unrelated completed-agent FYI must not hide another block.
|
|
55
|
+
resolvedFromDurableOutput =
|
|
56
|
+
(resolved && this.isBlockSignal(mention.message)) || resolvedFromDurableOutput;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
//
|
|
55
|
-
|
|
59
|
+
// Do not wake the human for a blocker we immediately resolved from a
|
|
60
|
+
// completed agent's durable task output.
|
|
61
|
+
if (this.isBlockSignal(text) && !resolvedFromDurableOutput) {
|
|
56
62
|
for (const listener of this.escalationListeners) {
|
|
57
63
|
listener(taskId, fromAgent, this.extractBlockReason(text));
|
|
58
64
|
}
|
|
@@ -67,7 +73,7 @@ export class Router {
|
|
|
67
73
|
fromAgent: string,
|
|
68
74
|
targetAgent: string,
|
|
69
75
|
message: string,
|
|
70
|
-
):
|
|
76
|
+
): boolean {
|
|
71
77
|
// Log the mention in the source task
|
|
72
78
|
store.appendMessage(this.squadId, sourceTaskId, {
|
|
73
79
|
ts: store.now(),
|
|
@@ -93,8 +99,41 @@ export class Router {
|
|
|
93
99
|
to: targetAgent,
|
|
94
100
|
text: message,
|
|
95
101
|
});
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const targetTasks = store.loadAllTasks(this.squadId).filter((task) => task.agent === targetAgent);
|
|
106
|
+
const hasFutureRun = targetTasks.some((task) =>
|
|
107
|
+
task.status === "pending" || task.status === "blocked" || task.status === "suspended" || task.status === "in_progress",
|
|
108
|
+
);
|
|
109
|
+
if (!hasFutureRun) {
|
|
110
|
+
const completed = targetTasks.filter((task) => task.status === "done" && task.output);
|
|
111
|
+
if (completed.length > 0) {
|
|
112
|
+
const durableReply = [
|
|
113
|
+
`[squad] @${targetAgent} has completed and is no longer running. Durable completed output:`,
|
|
114
|
+
...completed.map((task) => `\n## ${task.id}: ${task.title}\n${task.output}`),
|
|
115
|
+
].join("\n");
|
|
116
|
+
const reply = {
|
|
117
|
+
ts: store.now(),
|
|
118
|
+
from: targetAgent,
|
|
119
|
+
type: "reply" as const,
|
|
120
|
+
to: fromAgent,
|
|
121
|
+
text: durableReply,
|
|
122
|
+
};
|
|
123
|
+
store.appendMessage(this.squadId, sourceTaskId, reply);
|
|
124
|
+
if (this.pool.isRunning(sourceTaskId)) {
|
|
125
|
+
this.pool.steer(sourceTaskId, durableReply);
|
|
126
|
+
} else {
|
|
127
|
+
this.pool.queueMessage(fromAgent, reply);
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Target has future work and may spawn again — queue for that run. If the
|
|
134
|
+
// target is terminal with no output, do not create an undeliverable queue;
|
|
135
|
+
// leave the blocker unresolved so it escalates to the main orchestrator.
|
|
136
|
+
if (hasFutureRun) {
|
|
98
137
|
this.pool.queueMessage(targetAgent, {
|
|
99
138
|
ts: store.now(),
|
|
100
139
|
from: fromAgent,
|
|
@@ -103,6 +142,7 @@ export class Router {
|
|
|
103
142
|
text: message,
|
|
104
143
|
});
|
|
105
144
|
}
|
|
145
|
+
return false;
|
|
106
146
|
}
|
|
107
147
|
|
|
108
148
|
/**
|
package/src/scheduler.ts
CHANGED
|
@@ -622,15 +622,18 @@ export class Scheduler {
|
|
|
622
622
|
const turnCount = event.data?.turnCount ?? 0;
|
|
623
623
|
const toolCallCount = event.data?.toolCallCount ?? 0;
|
|
624
624
|
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
625
|
+
// Tool use is strong evidence of work, but planning/review tasks can
|
|
626
|
+
// legitimately deliver a substantive report without calling a tool.
|
|
627
|
+
// Accept durable assistant output; the mandatory main-orchestrator
|
|
628
|
+
// review gate still decides whether the work satisfies the contract.
|
|
629
|
+
const hasSubstantiveOutput = store.loadMessages(this.squadId, event.taskId)
|
|
630
|
+
.some((message) => message.from === event.agentName && message.type === "text" && message.text.trim().length > 0);
|
|
631
|
+
const hadMeaningfulWork = turnCount > 0 && (toolCallCount > 0 || hasSubstantiveOutput);
|
|
629
632
|
if (hadMeaningfulWork) {
|
|
630
633
|
this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
|
|
631
634
|
} else {
|
|
632
|
-
// Agent exited without
|
|
633
|
-
// Common causes: rate limit
|
|
635
|
+
// Agent exited without a completed turn, tool work, or substantive
|
|
636
|
+
// assistant artifact. Common causes: rate limit/API/resource failure.
|
|
634
637
|
// Retry once before failing.
|
|
635
638
|
const retryKey = `spawn-retry:${event.taskId}`;
|
|
636
639
|
if (!this.spawnRetries.has(retryKey)) {
|
|
@@ -638,7 +641,7 @@ export class Scheduler {
|
|
|
638
641
|
const stderr = event.data?.stderr || "";
|
|
639
642
|
const reason = turnCount === 0
|
|
640
643
|
? `exited with 0 turns (likely rate limit or API error)`
|
|
641
|
-
: `exited with ${turnCount} turns but
|
|
644
|
+
: `exited with ${turnCount} turns but no tool calls or substantive output`;
|
|
642
645
|
logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
|
|
643
646
|
store.updateTaskStatus(this.squadId, event.taskId, "pending");
|
|
644
647
|
store.appendMessage(this.squadId, event.taskId, {
|