pi-squad 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/package.json +1 -1
- 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
|
|
package/package.json
CHANGED
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, {
|