pi-squad 0.1.0

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/src/router.ts ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * router.ts — @mention parsing and cross-agent message delivery.
3
+ *
4
+ * Parses assistant text for @agentname patterns.
5
+ * Routes messages to target agents via steer() if running,
6
+ * or queues them for delivery on next spawn.
7
+ */
8
+
9
+ import type { AgentPool } from "./agent-pool.js";
10
+ import type { TaskMessage } from "./types.js";
11
+ import * as store from "./store.js";
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ export type EscalationListener = (taskId: string, agentName: string, message: string) => void;
18
+
19
+ // ============================================================================
20
+ // Router
21
+ // ============================================================================
22
+
23
+ export class Router {
24
+ private pool: AgentPool;
25
+ private squadId: string;
26
+ private escalationListeners: EscalationListener[] = [];
27
+
28
+ constructor(pool: AgentPool, squadId: string) {
29
+ this.pool = pool;
30
+ this.squadId = squadId;
31
+ }
32
+
33
+ /** Subscribe to escalation events (agent blocked, needs human) */
34
+ onEscalation(listener: EscalationListener): () => void {
35
+ this.escalationListeners.push(listener);
36
+ return () => {
37
+ const idx = this.escalationListeners.indexOf(listener);
38
+ if (idx !== -1) this.escalationListeners.splice(idx, 1);
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Process an assistant message for signals:
44
+ * - @mentions → route to target agent
45
+ * - Block signals → detect and escalate
46
+ */
47
+ processMessage(taskId: string, fromAgent: string, text: string): void {
48
+ // Parse @mentions
49
+ const mentions = this.parseMentions(text, fromAgent);
50
+ for (const mention of mentions) {
51
+ this.routeMention(taskId, fromAgent, mention.target, mention.message);
52
+ }
53
+
54
+ // Detect block signals
55
+ if (this.isBlockSignal(text)) {
56
+ for (const listener of this.escalationListeners) {
57
+ listener(taskId, fromAgent, this.extractBlockReason(text));
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Route a message from one agent to another.
64
+ */
65
+ routeMention(
66
+ sourceTaskId: string,
67
+ fromAgent: string,
68
+ targetAgent: string,
69
+ message: string,
70
+ ): void {
71
+ // Log the mention in the source task
72
+ store.appendMessage(this.squadId, sourceTaskId, {
73
+ ts: store.now(),
74
+ from: fromAgent,
75
+ type: "mention",
76
+ to: targetAgent,
77
+ text: message,
78
+ });
79
+
80
+ // Find if target agent is running
81
+ const targetTaskId = this.pool.getTaskIdForAgent(targetAgent);
82
+
83
+ if (targetTaskId && this.pool.isRunning(targetTaskId)) {
84
+ // Target is running — steer them
85
+ const steerMessage = `[squad] Message from @${fromAgent} (working on ${sourceTaskId}):\n${message}`;
86
+ this.pool.steer(targetTaskId, steerMessage);
87
+
88
+ // Log in target task too
89
+ store.appendMessage(this.squadId, targetTaskId, {
90
+ ts: store.now(),
91
+ from: fromAgent,
92
+ type: "mention",
93
+ to: targetAgent,
94
+ text: message,
95
+ });
96
+ } else {
97
+ // Target not running — queue for later
98
+ this.pool.queueMessage(targetAgent, {
99
+ ts: store.now(),
100
+ from: fromAgent,
101
+ type: "mention",
102
+ to: targetAgent,
103
+ text: message,
104
+ });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Route a human message to an agent.
110
+ */
111
+ routeHumanMessage(taskId: string, message: string): void {
112
+ store.appendMessage(this.squadId, taskId, {
113
+ ts: store.now(),
114
+ from: "human",
115
+ type: "message",
116
+ text: message,
117
+ });
118
+
119
+ if (this.pool.isRunning(taskId)) {
120
+ this.pool.steer(taskId, `[squad] Human: ${message}`);
121
+ } else {
122
+ const task = store.loadTask(this.squadId, taskId);
123
+ if (task) {
124
+ this.pool.queueMessage(task.agent, {
125
+ ts: store.now(),
126
+ from: "human",
127
+ type: "message",
128
+ text: message,
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ // =========================================================================
135
+ // Parsing
136
+ // =========================================================================
137
+
138
+ /**
139
+ * Parse @mentions from text.
140
+ * Matches @agentname followed by text until the next @mention or end of line.
141
+ */
142
+ private parseMentions(
143
+ text: string,
144
+ fromAgent: string,
145
+ ): Array<{ target: string; message: string }> {
146
+ const mentions: Array<{ target: string; message: string }> = [];
147
+ // Match @word at start of line or after whitespace, capture until next @mention or newline
148
+ const regex = /(?:^|\s)@(\w+)\s+([^\n@]*(?:\n(?!.*@\w).*)*)/gm;
149
+
150
+ for (const match of text.matchAll(regex)) {
151
+ const target = match[1];
152
+ const message = match[2].trim();
153
+
154
+ // Don't route self-mentions
155
+ if (target === fromAgent) continue;
156
+
157
+ // Don't route empty messages
158
+ if (!message) continue;
159
+
160
+ // Check if target is a known agent
161
+ const projectCwd = store.loadSquad(this.squadId)?.cwd;
162
+ const agentDef = store.loadAgentDef(target, projectCwd);
163
+ if (agentDef) {
164
+ mentions.push({ target, message });
165
+ }
166
+ }
167
+
168
+ return mentions;
169
+ }
170
+
171
+ /**
172
+ * Detect if text indicates the agent is blocked.
173
+ */
174
+ private isBlockSignal(text: string): boolean {
175
+ const lower = text.toLowerCase();
176
+ const blockPatterns = [
177
+ /\bi(?:'m| am) blocked\b/,
178
+ /\bcannot proceed\b/,
179
+ /\bcan't proceed\b/,
180
+ /\bneed .+ (?:before|to proceed|to continue)/,
181
+ /\bwaiting (?:for|on) .+ (?:input|decision|response)/,
182
+ /\bblocked(?:\s+because|\s+by|\s*:)/,
183
+ ];
184
+ return blockPatterns.some((p) => p.test(lower));
185
+ }
186
+
187
+ /**
188
+ * Extract the block reason from text.
189
+ */
190
+ private extractBlockReason(text: string): string {
191
+ // Try to find the line with the block signal
192
+ const lines = text.split("\n");
193
+ for (const line of lines) {
194
+ const lower = line.toLowerCase();
195
+ if (
196
+ lower.includes("blocked") ||
197
+ lower.includes("cannot proceed") ||
198
+ lower.includes("can't proceed") ||
199
+ lower.includes("waiting for") ||
200
+ lower.includes("waiting on")
201
+ ) {
202
+ return line.trim();
203
+ }
204
+ }
205
+ return text.slice(0, 200);
206
+ }
207
+ }