@pi-archimedes/subagent 2.7.3 → 2.8.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/package.json +3 -3
- package/src/spawn.test.ts +119 -0
- package/src/spawn.ts +36 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/subagent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
],
|
|
16
16
|
"main": "./src/index.ts",
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@pi-archimedes/core": "2.
|
|
19
|
-
"@pi-archimedes/todo": "2.
|
|
18
|
+
"@pi-archimedes/core": "2.8.0",
|
|
19
|
+
"@pi-archimedes/todo": "2.8.0"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"@earendil-works/pi-ai": ">=0.1.0",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import * as net from "node:net";
|
|
3
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
4
|
+
import { startAskSocketServer } from "./spawn.js";
|
|
5
|
+
|
|
6
|
+
// startAskSocketServer is tested directly (not via spawnSubagent, which would
|
|
7
|
+
// spawn a real `pi` process). The real bus is used — subscriptions are
|
|
8
|
+
// unsubscribed in afterEach so they don't leak into other test files in the
|
|
9
|
+
// same worker (the bus is a globalThis singleton).
|
|
10
|
+
|
|
11
|
+
const cleanups: Array<() => void> = [];
|
|
12
|
+
const unsubs: Array<() => void> = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
unsubs.splice(0).forEach((u) => u());
|
|
16
|
+
cleanups.splice(0).forEach((c) => c());
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function track(fn: () => void): void {
|
|
20
|
+
cleanups.push(fn);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function connectClient(socketPath: string): Promise<net.Socket> {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const client = net.connect(socketPath, () => resolve(client));
|
|
26
|
+
client.on("error", (err) => reject(err));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeAskRequest(client: net.Socket, requestId: string, toolCallId?: string): void {
|
|
31
|
+
client.write(
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
type: "ask_request",
|
|
34
|
+
requestId,
|
|
35
|
+
toolCallId,
|
|
36
|
+
questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
|
|
37
|
+
}) + "\n",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("startAskSocketServer", () => {
|
|
42
|
+
it("a child ask_request carrying toolCallId → the bus ASK_REQUEST carries the same toolCallId", async () => {
|
|
43
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent");
|
|
44
|
+
track(cleanup);
|
|
45
|
+
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
46
|
+
|
|
47
|
+
const requests: unknown[] = [];
|
|
48
|
+
unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
|
|
49
|
+
|
|
50
|
+
const client = await connectClient(socketPath);
|
|
51
|
+
writeAskRequest(client, "req-1", "tool-9");
|
|
52
|
+
|
|
53
|
+
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
54
|
+
const req = requests[0] as { source: string; requestId: string; toolCallId?: string; questions: unknown[] };
|
|
55
|
+
expect(req.source).toBe("subagent:test-agent");
|
|
56
|
+
expect(req.requestId).toBe("req-1");
|
|
57
|
+
expect(req.toolCallId).toBe("tool-9");
|
|
58
|
+
expect(req.questions).toEqual([{ id: "q1", question: "Which?", options: [{ label: "A" }] }]);
|
|
59
|
+
|
|
60
|
+
// Deliver the response (drains pending) before closing so no ASK_CANCEL
|
|
61
|
+
// leaks onto the global bus queue for the next test
|
|
62
|
+
getBus().emit(Events.ASK_RESPONSE, {
|
|
63
|
+
requestId: "req-1",
|
|
64
|
+
cancelled: false,
|
|
65
|
+
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
66
|
+
});
|
|
67
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
68
|
+
client.destroy();
|
|
69
|
+
await new Promise((r) => setTimeout(r, 50)); // close handler ran (no-op)
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("closing the child socket → ASK_CANCEL for the pending requestId + source", async () => {
|
|
73
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent");
|
|
74
|
+
track(cleanup);
|
|
75
|
+
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
76
|
+
|
|
77
|
+
const cancels: unknown[] = [];
|
|
78
|
+
unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
|
|
79
|
+
const requests: unknown[] = [];
|
|
80
|
+
unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
|
|
81
|
+
|
|
82
|
+
const client = await connectClient(socketPath);
|
|
83
|
+
writeAskRequest(client, "req-2");
|
|
84
|
+
|
|
85
|
+
// Wait until the request is registered (pending) before closing
|
|
86
|
+
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
87
|
+
client.destroy();
|
|
88
|
+
|
|
89
|
+
await vi.waitFor(() => expect(cancels.length).toBe(1));
|
|
90
|
+
expect(cancels[0]).toEqual({ requestId: "req-2", source: "subagent:test-agent" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("no ASK_CANCEL once the response has already been delivered (pending drained)", async () => {
|
|
94
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent");
|
|
95
|
+
track(cleanup);
|
|
96
|
+
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
97
|
+
|
|
98
|
+
const cancels: unknown[] = [];
|
|
99
|
+
unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
|
|
100
|
+
const requests: unknown[] = [];
|
|
101
|
+
unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
|
|
102
|
+
|
|
103
|
+
const client = await connectClient(socketPath);
|
|
104
|
+
writeAskRequest(client, "req-3");
|
|
105
|
+
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
106
|
+
|
|
107
|
+
// Deliver the response (drains pending) before the socket closes
|
|
108
|
+
getBus().emit(Events.ASK_RESPONSE, {
|
|
109
|
+
requestId: "req-3",
|
|
110
|
+
cancelled: false,
|
|
111
|
+
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
112
|
+
});
|
|
113
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
114
|
+
client.destroy();
|
|
115
|
+
await new Promise((r) => setTimeout(r, 50)); // close handler ran
|
|
116
|
+
|
|
117
|
+
expect(cancels.length).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/spawn.ts
CHANGED
|
@@ -67,17 +67,22 @@ function resolvePiBinary(): string {
|
|
|
67
67
|
* Start a Unix socket server that bridges the child's ask tool to the parent bus.
|
|
68
68
|
*
|
|
69
69
|
* The child's ask tool (in packages/ask) connects to PI_SUBAGENT_SOCKET and sends:
|
|
70
|
-
* { type: "ask_request", requestId, questions } as a JSON line
|
|
70
|
+
* { type: "ask_request", requestId, questions, toolCallId } as a JSON line
|
|
71
71
|
* and waits for:
|
|
72
72
|
* { type: "ask_response", requestId, cancelled, results } as a JSON line
|
|
73
73
|
*
|
|
74
|
-
* We forward the request onto the bus (ASK_REQUEST
|
|
75
|
-
*
|
|
76
|
-
* to the socket connection.
|
|
74
|
+
* We forward the request onto the bus (ASK_REQUEST, carrying toolCallId), the
|
|
75
|
+
* ask package (or the bridge, in bridge mode) answers it, then emits
|
|
76
|
+
* ASK_RESPONSE on the bus, and we write it back to the socket connection.
|
|
77
|
+
*
|
|
78
|
+
* When the socket closes (child exit, or the child's own 5-min timeout →
|
|
79
|
+
* socket.end()) we signal the bridge via ASK_CANCEL for any still-pending ask.
|
|
80
|
+
* We do NOT emit ASK_RESPONSE ourselves — the bridge is the sole emitter
|
|
81
|
+
* (no double-decrement of its refcount).
|
|
77
82
|
*
|
|
78
83
|
* Returns the socket path and a cleanup function.
|
|
79
84
|
*/
|
|
80
|
-
function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
|
|
85
|
+
export function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
|
|
81
86
|
// Use named pipes on Windows, Unix domain sockets elsewhere.
|
|
82
87
|
// Linux socket path limit is 108 chars — keep it short.
|
|
83
88
|
const id = randomUUID().slice(0, 8);
|
|
@@ -110,6 +115,8 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
|
|
|
110
115
|
|
|
111
116
|
const server = net.createServer((socket) => {
|
|
112
117
|
let buffer = "";
|
|
118
|
+
// requestIds seen on this socket (for the ASK_CANCEL wiring below)
|
|
119
|
+
const socketRequestIds = new Set<string>();
|
|
113
120
|
|
|
114
121
|
socket.on("data", (chunk: Buffer) => {
|
|
115
122
|
buffer += chunk.toString("utf-8");
|
|
@@ -124,6 +131,7 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
|
|
|
124
131
|
type: string;
|
|
125
132
|
requestId: string;
|
|
126
133
|
questions: unknown[];
|
|
134
|
+
toolCallId?: string;
|
|
127
135
|
};
|
|
128
136
|
if (msg.type === "ask_request") {
|
|
129
137
|
// Register write-back so ASK_RESPONSE handler can find this socket
|
|
@@ -134,11 +142,14 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
|
|
|
134
142
|
// socket already closed
|
|
135
143
|
}
|
|
136
144
|
});
|
|
137
|
-
|
|
145
|
+
socketRequestIds.add(msg.requestId);
|
|
146
|
+
// Forward to bus — the ask package (or the bridge, in bridge mode)
|
|
147
|
+
// answers it; toolCallId rides along for Client correlation
|
|
138
148
|
getBus().emit(Events.ASK_REQUEST, {
|
|
139
149
|
source: `subagent:${agentName}`,
|
|
140
150
|
requestId: msg.requestId,
|
|
141
151
|
questions: msg.questions,
|
|
152
|
+
toolCallId: msg.toolCallId,
|
|
142
153
|
});
|
|
143
154
|
}
|
|
144
155
|
} catch {
|
|
@@ -147,6 +158,19 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
|
|
|
147
158
|
}
|
|
148
159
|
});
|
|
149
160
|
|
|
161
|
+
// Child exit (or the child's own 5-min timeout → socket.end()) closes the
|
|
162
|
+
// socket: signal the bridge via ASK_CANCEL for any still-pending ask so it
|
|
163
|
+
// can cancel the relayed Client request. We only signal — the bridge is
|
|
164
|
+
// the sole ASK_RESPONSE emitter (no double-decrement of its refcount).
|
|
165
|
+
socket.on("close", () => {
|
|
166
|
+
for (const rid of socketRequestIds) {
|
|
167
|
+
if (pending.has(rid)) {
|
|
168
|
+
getBus().emit(Events.ASK_CANCEL, { requestId: rid, source: `subagent:${agentName}` });
|
|
169
|
+
pending.delete(rid);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
150
174
|
socket.on("error", () => { /* connection dropped */ });
|
|
151
175
|
});
|
|
152
176
|
|
|
@@ -245,7 +269,12 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
|
245
269
|
forceKill.unref();
|
|
246
270
|
}
|
|
247
271
|
};
|
|
248
|
-
|
|
272
|
+
// A signal that is ALREADY aborted never dispatches its "abort" event (it
|
|
273
|
+
// fires exactly once, at abort time), so addEventListener alone would never
|
|
274
|
+
// kill the child — call the handler directly in that case. (kill is
|
|
275
|
+
// idempotent via the child.killed guard, so both branches coexist safely.)
|
|
276
|
+
if (options.signal.aborted) abortHandler();
|
|
277
|
+
else options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
249
278
|
child.on("exit", () => options.signal!.removeEventListener("abort", abortHandler));
|
|
250
279
|
}
|
|
251
280
|
|