@pi-archimedes/subagent 2.10.4 → 2.11.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 +2 -2
- package/package.json +10 -10
- package/src/agent-panel.mcp.test.ts +76 -0
- package/src/agent-panel.tools.test.ts +162 -0
- package/src/agent-panel.ts +37 -8
- package/src/agent-panel.width-audit.test.ts +258 -0
- package/src/agents-parse.test.ts +252 -0
- package/src/agents.ts +72 -5
- package/src/compact.test.ts +136 -57
- package/src/compact.ts +57 -37
- package/src/dispatch.test.ts +73 -1
- package/src/dispatch.ts +20 -2
- package/src/execute.test.ts +9 -1
- package/src/execute.ts +26 -29
- package/src/expanded.test.ts +472 -0
- package/src/expanded.ts +202 -82
- package/src/format.test.ts +71 -29
- package/src/format.ts +71 -28
- package/src/frontmatter-io.test.ts +62 -0
- package/src/frontmatter-io.ts +16 -2
- package/src/handlers.test.ts +21 -3
- package/src/handlers.ts +22 -3
- package/src/index.output-schema.test.ts +317 -0
- package/src/index.test.ts +566 -0
- package/src/index.ts +169 -16
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +244 -0
- package/src/row-block.ts +209 -0
- package/src/run-outcome.test.ts +71 -0
- package/src/run-outcome.ts +84 -0
- package/src/spawn.test.ts +204 -19
- package/src/spawn.ts +49 -17
- package/src/stream.test.ts +215 -17
- package/src/stream.ts +47 -9
- package/src/thinking-validation.test.ts +96 -0
- package/src/thinking-validation.ts +78 -0
- package/src/tool-declarations.test.ts +58 -0
- package/src/tool-row.width-audit.test.ts +720 -0
- package/src/tool-schema.test.ts +154 -0
- package/src/tool-schema.ts +106 -4
- package/src/tool-validation.test.ts +321 -0
- package/src/tool-validation.ts +262 -0
- package/src/types.ts +86 -0
- package/src/usage.test.ts +95 -0
- package/src/usage.ts +87 -0
- package/src/cost.test.ts +0 -62
- package/src/cost.ts +0 -8
package/src/spawn.test.ts
CHANGED
|
@@ -1,18 +1,46 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
2
|
import * as net from "node:net";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import type { ChildProcess } from "node:child_process";
|
|
5
|
+
import { Events } from "@pi-archimedes/core/events";
|
|
6
|
+
import type { AgentConfig } from "./agents.js";
|
|
7
|
+
|
|
8
|
+
// The child's argv is a contract with the `pi` CLI, and CLI strings are invisible
|
|
9
|
+
// to tsc — these tests are the only guard on what the child actually receives.
|
|
10
|
+
// `node:child_process` is mocked so no real pi process is spawned.
|
|
11
|
+
const spawnMock = vi.hoisted(() => vi.fn());
|
|
12
|
+
vi.mock("node:child_process", () => ({ spawn: spawnMock }));
|
|
13
|
+
|
|
14
|
+
const { startAskSocketServer, spawnSubagent } = await import("./spawn.js");
|
|
5
15
|
|
|
6
16
|
// startAskSocketServer is tested directly (not via spawnSubagent, which would
|
|
7
|
-
// spawn a real `pi` process).
|
|
8
|
-
//
|
|
9
|
-
//
|
|
17
|
+
// spawn a real `pi` process). Each test gets its OWN bus stand-in — the host
|
|
18
|
+
// handle is passed in rather than reached for, so there is no process-wide
|
|
19
|
+
// singleton to leak between tests any more.
|
|
20
|
+
|
|
21
|
+
/** Stands in for the host's `createEventBus()` (same emit/on/unsubscribe shape). */
|
|
22
|
+
function fakeBus() {
|
|
23
|
+
const listeners = new Map<string, Array<(data: unknown) => void>>();
|
|
24
|
+
return {
|
|
25
|
+
emit(channel: string, data: unknown): void {
|
|
26
|
+
for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
|
|
27
|
+
},
|
|
28
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
29
|
+
const subs = listeners.get(channel) ?? [];
|
|
30
|
+
subs.push(handler);
|
|
31
|
+
listeners.set(channel, subs);
|
|
32
|
+
return () => {
|
|
33
|
+
const current = listeners.get(channel) ?? [];
|
|
34
|
+
const idx = current.indexOf(handler);
|
|
35
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
10
40
|
|
|
11
41
|
const cleanups: Array<() => void> = [];
|
|
12
|
-
const unsubs: Array<() => void> = [];
|
|
13
42
|
|
|
14
43
|
afterEach(() => {
|
|
15
|
-
unsubs.splice(0).forEach((u) => u());
|
|
16
44
|
cleanups.splice(0).forEach((c) => c());
|
|
17
45
|
});
|
|
18
46
|
|
|
@@ -20,6 +48,83 @@ function track(fn: () => void): void {
|
|
|
20
48
|
cleanups.push(fn);
|
|
21
49
|
}
|
|
22
50
|
|
|
51
|
+
// ── spawnSubagent argv ──────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** A minimal spawn target: the argv assertions never touch the process. */
|
|
54
|
+
function fakeSpawnResult(): ChildProcess {
|
|
55
|
+
const child = new EventEmitter() as ChildProcess & { pid: number };
|
|
56
|
+
Object.assign(child, { pid: 4242, killed: false, kill: () => true });
|
|
57
|
+
return child;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run spawnSubagent against the mocked spawn and return the argv it received. */
|
|
61
|
+
function argvFor(agent: Partial<AgentConfig> | undefined): string[] {
|
|
62
|
+
spawnMock.mockReset();
|
|
63
|
+
spawnMock.mockReturnValue(fakeSpawnResult());
|
|
64
|
+
const events = fakeBus();
|
|
65
|
+
const socket = startAskSocketServer(agent?.name ?? "general", events);
|
|
66
|
+
track(socket.cleanup);
|
|
67
|
+
spawnSubagent({
|
|
68
|
+
task: "do the thing",
|
|
69
|
+
model: undefined,
|
|
70
|
+
activeModel: undefined,
|
|
71
|
+
cwd: "/tmp",
|
|
72
|
+
signal: undefined,
|
|
73
|
+
events,
|
|
74
|
+
agent: agent
|
|
75
|
+
? { name: agent.name ?? "general", description: "d", systemPrompt: "p", source: "user", filePath: "/tmp/a.md", ...agent }
|
|
76
|
+
: undefined,
|
|
77
|
+
});
|
|
78
|
+
const call = spawnMock.mock.calls[0] as [string, string[]] | undefined;
|
|
79
|
+
if (!call) throw new Error("spawn was not called");
|
|
80
|
+
return call[1];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("spawnSubagent tool allowlist (closed set, ADR 0028)", () => {
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
spawnMock.mockReset();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("a non-empty tools allowlist also passes --no-mcp, so the list stays closed", () => {
|
|
89
|
+
const argv = argvFor({ tools: ["read", "bash"] });
|
|
90
|
+
expect(argv).toContain("--tools");
|
|
91
|
+
expect(argv[argv.indexOf("--tools") + 1]).toBe("read,bash");
|
|
92
|
+
expect(argv).toContain("--no-mcp");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("mcp: true is the only way the allowlist stops being closed", () => {
|
|
96
|
+
const argv = argvFor({ tools: ["read", "bash"], mcp: true });
|
|
97
|
+
expect(argv).toContain("--tools");
|
|
98
|
+
expect(argv).not.toContain("--no-mcp");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("an empty tools list changes neither the tool set nor MCP", () => {
|
|
102
|
+
const argv = argvFor({ tools: [] });
|
|
103
|
+
expect(argv).not.toContain("--tools");
|
|
104
|
+
expect(argv).not.toContain("--no-mcp");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("mcp: true without an allowlist passes neither flag (nothing to close)", () => {
|
|
108
|
+
const argv = argvFor({ mcp: true });
|
|
109
|
+
expect(argv).not.toContain("--tools");
|
|
110
|
+
expect(argv).not.toContain("--no-mcp");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("the recursion guard survives the closed allowlist", () => {
|
|
114
|
+
const argv = argvFor({ tools: ["read"] });
|
|
115
|
+
const excludeIdx = argv.indexOf("--exclude-tools");
|
|
116
|
+
expect(excludeIdx).toBeGreaterThan(-1);
|
|
117
|
+
expect(argv[excludeIdx + 1]).toContain("subagent");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("an agent with no config at all gets neither --tools nor --no-mcp", () => {
|
|
121
|
+
const argv = argvFor(undefined);
|
|
122
|
+
expect(argv).not.toContain("--tools");
|
|
123
|
+
expect(argv).not.toContain("--no-mcp");
|
|
124
|
+
expect(argv).toContain("--exclude-tools");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
23
128
|
function connectClient(socketPath: string): Promise<net.Socket> {
|
|
24
129
|
return new Promise((resolve, reject) => {
|
|
25
130
|
const client = net.connect(socketPath, () => resolve(client));
|
|
@@ -40,12 +145,13 @@ function writeAskRequest(client: net.Socket, requestId: string, toolCallId?: str
|
|
|
40
145
|
|
|
41
146
|
describe("startAskSocketServer", () => {
|
|
42
147
|
it("a child ask_request carrying toolCallId → the bus ASK_REQUEST carries the same toolCallId", async () => {
|
|
43
|
-
const
|
|
148
|
+
const events = fakeBus();
|
|
149
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
|
|
44
150
|
track(cleanup);
|
|
45
151
|
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
46
152
|
|
|
47
153
|
const requests: unknown[] = [];
|
|
48
|
-
|
|
154
|
+
events.on(Events.ASK_REQUEST, (p) => requests.push(p));
|
|
49
155
|
|
|
50
156
|
const client = await connectClient(socketPath);
|
|
51
157
|
writeAskRequest(client, "req-1", "tool-9");
|
|
@@ -57,9 +163,9 @@ describe("startAskSocketServer", () => {
|
|
|
57
163
|
expect(req.toolCallId).toBe("tool-9");
|
|
58
164
|
expect(req.questions).toEqual([{ id: "q1", question: "Which?", options: [{ label: "A" }] }]);
|
|
59
165
|
|
|
60
|
-
// Deliver the response (drains pending) before closing so no ASK_CANCEL
|
|
61
|
-
//
|
|
62
|
-
|
|
166
|
+
// Deliver the response (drains pending) before closing so no ASK_CANCEL is
|
|
167
|
+
// signalled for a request that was already answered.
|
|
168
|
+
events.emit(Events.ASK_RESPONSE, {
|
|
63
169
|
requestId: "req-1",
|
|
64
170
|
cancelled: false,
|
|
65
171
|
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
@@ -69,15 +175,60 @@ describe("startAskSocketServer", () => {
|
|
|
69
175
|
await new Promise((r) => setTimeout(r, 50)); // close handler ran (no-op)
|
|
70
176
|
});
|
|
71
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The ask round-trip, over the handle: the child's socket request goes out on
|
|
180
|
+
* the bus, the answer comes back on the SAME handle, and the server writes it
|
|
181
|
+
* back down the socket. Before this migration both halves went through the
|
|
182
|
+
* suite's `globalThis` bus; the transport changed, the socket contract did not.
|
|
183
|
+
*/
|
|
184
|
+
it("round-trip: ask_request in → ASK_REQUEST out on the handle → ASK_RESPONSE in → ask_response back down the socket", async () => {
|
|
185
|
+
const events = fakeBus();
|
|
186
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
|
|
187
|
+
track(cleanup);
|
|
188
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
189
|
+
|
|
190
|
+
const frames: unknown[] = [];
|
|
191
|
+
const requests: unknown[] = [];
|
|
192
|
+
events.on(Events.ASK_REQUEST, (p) => requests.push(p));
|
|
193
|
+
|
|
194
|
+
const client = await connectClient(socketPath);
|
|
195
|
+
client.on("data", (chunk) => {
|
|
196
|
+
for (const line of chunk.toString("utf-8").split("\n")) {
|
|
197
|
+
if (line.trim()) frames.push(JSON.parse(line));
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
writeAskRequest(client, "req-rt", "tool-rt");
|
|
202
|
+
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
203
|
+
|
|
204
|
+
events.emit(Events.ASK_RESPONSE, {
|
|
205
|
+
requestId: "req-rt",
|
|
206
|
+
cancelled: false,
|
|
207
|
+
results: [{ id: "q1", selectedOptions: ["A"], customInput: undefined }],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await vi.waitFor(() => expect(frames.length).toBe(1));
|
|
211
|
+
expect(frames[0]).toEqual({
|
|
212
|
+
type: "ask_response",
|
|
213
|
+
requestId: "req-rt",
|
|
214
|
+
cancelled: false,
|
|
215
|
+
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
client.destroy();
|
|
219
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
220
|
+
});
|
|
221
|
+
|
|
72
222
|
it("closing the child socket → ASK_CANCEL for the pending requestId + source", async () => {
|
|
73
|
-
const
|
|
223
|
+
const events = fakeBus();
|
|
224
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
|
|
74
225
|
track(cleanup);
|
|
75
226
|
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
76
227
|
|
|
77
228
|
const cancels: unknown[] = [];
|
|
78
|
-
|
|
229
|
+
events.on(Events.ASK_CANCEL, (p) => cancels.push(p));
|
|
79
230
|
const requests: unknown[] = [];
|
|
80
|
-
|
|
231
|
+
events.on(Events.ASK_REQUEST, (p) => requests.push(p));
|
|
81
232
|
|
|
82
233
|
const client = await connectClient(socketPath);
|
|
83
234
|
writeAskRequest(client, "req-2");
|
|
@@ -91,21 +242,22 @@ describe("startAskSocketServer", () => {
|
|
|
91
242
|
});
|
|
92
243
|
|
|
93
244
|
it("no ASK_CANCEL once the response has already been delivered (pending drained)", async () => {
|
|
94
|
-
const
|
|
245
|
+
const events = fakeBus();
|
|
246
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
|
|
95
247
|
track(cleanup);
|
|
96
248
|
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
97
249
|
|
|
98
250
|
const cancels: unknown[] = [];
|
|
99
|
-
|
|
251
|
+
events.on(Events.ASK_CANCEL, (p) => cancels.push(p));
|
|
100
252
|
const requests: unknown[] = [];
|
|
101
|
-
|
|
253
|
+
events.on(Events.ASK_REQUEST, (p) => requests.push(p));
|
|
102
254
|
|
|
103
255
|
const client = await connectClient(socketPath);
|
|
104
256
|
writeAskRequest(client, "req-3");
|
|
105
257
|
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
106
258
|
|
|
107
259
|
// Deliver the response (drains pending) before the socket closes
|
|
108
|
-
|
|
260
|
+
events.emit(Events.ASK_RESPONSE, {
|
|
109
261
|
requestId: "req-3",
|
|
110
262
|
cancelled: false,
|
|
111
263
|
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
@@ -117,3 +269,36 @@ describe("startAskSocketServer", () => {
|
|
|
117
269
|
expect(cancels.length).toBe(0);
|
|
118
270
|
});
|
|
119
271
|
});
|
|
272
|
+
|
|
273
|
+
// ── thinking level (pi warns and drops; we must not feed it one) ───────────
|
|
274
|
+
|
|
275
|
+
describe("spawnSubagent thinking level", () => {
|
|
276
|
+
afterEach(() => {
|
|
277
|
+
spawnMock.mockReset();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("passes a level pi accepts through to --thinking", () => {
|
|
281
|
+
const argv = argvFor({ thinking: "xhigh" });
|
|
282
|
+
const at = argv.indexOf("--thinking");
|
|
283
|
+
expect(at).toBeGreaterThan(-1);
|
|
284
|
+
expect(argv[at + 1]).toBe("xhigh");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("does not pass a level pi would warn about and drop", () => {
|
|
288
|
+
// `--thinking ultra` is a `warning` diagnostic and a dropped flag in pi's
|
|
289
|
+
// own parser (dist/cli/args.js), so the child ran at the default thinking
|
|
290
|
+
// level while its config said otherwise — and in `--mode json` that warning
|
|
291
|
+
// is stderr, which the stream reader only shows for a failed run. Passing
|
|
292
|
+
// nothing is the same outcome, said out loud at dispatch instead.
|
|
293
|
+
const argv = argvFor({ thinking: "ultra" });
|
|
294
|
+
expect(argv).not.toContain("--thinking");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("is case-sensitive, exactly like pi's list", () => {
|
|
298
|
+
expect(argvFor({ thinking: "Medium" })).not.toContain("--thinking");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("passes no --thinking when the config sets none", () => {
|
|
302
|
+
expect(argvFor({})).not.toContain("--thinking");
|
|
303
|
+
});
|
|
304
|
+
});
|
package/src/spawn.ts
CHANGED
|
@@ -4,7 +4,9 @@ import * as net from "node:net";
|
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
import {
|
|
7
|
+
import type { EventBus } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Events, emitOn } from "@pi-archimedes/core/events";
|
|
9
|
+
import { resolveThinkingLevel } from "./thinking-validation.js";
|
|
8
10
|
import type { AgentConfig } from "./agents.js";
|
|
9
11
|
|
|
10
12
|
export interface SpawnOptions {
|
|
@@ -14,6 +16,8 @@ export interface SpawnOptions {
|
|
|
14
16
|
cwd: string | undefined;
|
|
15
17
|
signal: AbortSignal | undefined;
|
|
16
18
|
agent: AgentConfig | undefined;
|
|
19
|
+
/** The parent's host handle (`pi.events`) — see `startAskSocketServer`. */
|
|
20
|
+
events: EventBus;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/**
|
|
@@ -73,25 +77,32 @@ function resolvePiBinary(): string {
|
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
/**
|
|
76
|
-
* Start a Unix socket server that bridges the child's ask tool to the parent
|
|
80
|
+
* Start a Unix socket server that bridges the child's ask tool to the parent's
|
|
81
|
+
* event handle.
|
|
77
82
|
*
|
|
78
83
|
* The child's ask tool (in packages/ask) connects to PI_SUBAGENT_SOCKET and sends:
|
|
79
84
|
* { type: "ask_request", requestId, questions, toolCallId } as a JSON line
|
|
80
85
|
* and waits for:
|
|
81
86
|
* { type: "ask_response", requestId, cancelled, results } as a JSON line
|
|
82
87
|
*
|
|
83
|
-
* We forward the request onto the
|
|
88
|
+
* We forward the request onto the handle (ASK_REQUEST, carrying toolCallId), the
|
|
84
89
|
* ask package (or the bridge, in bridge mode) answers it, then emits
|
|
85
|
-
* ASK_RESPONSE on the
|
|
90
|
+
* ASK_RESPONSE on the same handle, and we write it back to the socket connection.
|
|
86
91
|
*
|
|
87
92
|
* When the socket closes (child exit, or the child's own 5-min timeout →
|
|
88
93
|
* socket.end()) we signal the bridge via ASK_CANCEL for any still-pending ask.
|
|
89
94
|
* We do NOT emit ASK_RESPONSE ourselves — the bridge is the sole emitter
|
|
90
95
|
* (no double-decrement of its refcount).
|
|
91
96
|
*
|
|
97
|
+
* @param events the parent's `pi.events`, injected: this is a plain function, not
|
|
98
|
+
* an extension, so the handle has to come from the caller that has
|
|
99
|
+
* one. The subscription it opens is closed by the returned
|
|
100
|
+
* `cleanup()` — a per-spawn subscription with a per-spawn
|
|
101
|
+
* teardown, which is why it cannot accumulate across runs.
|
|
102
|
+
*
|
|
92
103
|
* Returns the socket path and a cleanup function.
|
|
93
104
|
*/
|
|
94
|
-
export function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
|
|
105
|
+
export function startAskSocketServer(agentName: string, events: EventBus): { socketPath: string; cleanup: () => void } {
|
|
95
106
|
// Use named pipes on Windows, Unix domain sockets elsewhere.
|
|
96
107
|
// Linux socket path limit is 108 chars — keep it short.
|
|
97
108
|
const id = randomUUID().slice(0, 8);
|
|
@@ -103,8 +114,8 @@ export function startAskSocketServer(agentName: string): { socketPath: string; c
|
|
|
103
114
|
// Map of pending ask requests: requestId → write-back callback
|
|
104
115
|
const pending = new Map<string, (response: unknown) => void>();
|
|
105
116
|
|
|
106
|
-
// Listen for ASK_RESPONSE
|
|
107
|
-
const unsubResponse =
|
|
117
|
+
// Listen for ASK_RESPONSE on the handle and route back to the waiting socket conn
|
|
118
|
+
const unsubResponse = events.on(Events.ASK_RESPONSE, (payload: unknown) => {
|
|
108
119
|
const data = payload as {
|
|
109
120
|
requestId: string;
|
|
110
121
|
cancelled: boolean;
|
|
@@ -152,9 +163,9 @@ export function startAskSocketServer(agentName: string): { socketPath: string; c
|
|
|
152
163
|
}
|
|
153
164
|
});
|
|
154
165
|
socketRequestIds.add(msg.requestId);
|
|
155
|
-
// Forward
|
|
166
|
+
// Forward onto the handle — the ask package (or the bridge, in bridge mode)
|
|
156
167
|
// answers it; toolCallId rides along for Client correlation
|
|
157
|
-
|
|
168
|
+
emitOn(events, Events.ASK_REQUEST, {
|
|
158
169
|
source: `subagent:${agentName}`,
|
|
159
170
|
requestId: msg.requestId,
|
|
160
171
|
questions: msg.questions,
|
|
@@ -174,7 +185,7 @@ export function startAskSocketServer(agentName: string): { socketPath: string; c
|
|
|
174
185
|
socket.on("close", () => {
|
|
175
186
|
for (const rid of socketRequestIds) {
|
|
176
187
|
if (pending.has(rid)) {
|
|
177
|
-
|
|
188
|
+
emitOn(events, Events.ASK_CANCEL, { requestId: rid, source: `subagent:${agentName}` });
|
|
178
189
|
pending.delete(rid);
|
|
179
190
|
}
|
|
180
191
|
}
|
|
@@ -208,7 +219,7 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
|
208
219
|
const agentName = options.agent?.name ?? "general";
|
|
209
220
|
|
|
210
221
|
// Start ask bridge socket before spawning so the env var is ready
|
|
211
|
-
const { socketPath, cleanup: cleanupSocket } = startAskSocketServer(agentName);
|
|
222
|
+
const { socketPath, cleanup: cleanupSocket } = startAskSocketServer(agentName, options.events);
|
|
212
223
|
|
|
213
224
|
// Build CLI args
|
|
214
225
|
const args: string[] = ["--mode", "json", "--no-session", "-p"];
|
|
@@ -219,14 +230,35 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
|
219
230
|
args.push("--model", model);
|
|
220
231
|
}
|
|
221
232
|
|
|
222
|
-
// Thinking level from agent config
|
|
223
|
-
|
|
224
|
-
|
|
233
|
+
// Thinking level from agent config. An unknown level is NOT passed on: pi
|
|
234
|
+
// warns and drops the flag itself, which leaves the child on the default
|
|
235
|
+
// level with the explanation in a stderr nobody reads for a successful run.
|
|
236
|
+
// `index.ts` reports the same drop where the user can see it.
|
|
237
|
+
const thinking = resolveThinkingLevel(options.agent?.thinking);
|
|
238
|
+
if (thinking.level !== undefined) {
|
|
239
|
+
args.push("--thinking", thinking.level);
|
|
225
240
|
}
|
|
226
241
|
|
|
227
|
-
// Tool allowlist from agent config
|
|
228
|
-
|
|
229
|
-
|
|
242
|
+
// Tool allowlist from agent config.
|
|
243
|
+
//
|
|
244
|
+
// pi 1.0.4 changed what a non-empty `--tools` does to MCP tools. It no longer
|
|
245
|
+
// filters them: unless the list is empty or names an `mcp__` entry, they stay
|
|
246
|
+
// REGISTERED — reachable through codemode and `tool_search` — even though they
|
|
247
|
+
// are not *declared* to the model (`agent-session.js`: `_allowlistFiltersMcp`
|
|
248
|
+
// false → `_isAllowedTool` keeps them, `_isActivatable` withholds the
|
|
249
|
+
// declaration unless `tool_search` can load them). So the child's tool box is
|
|
250
|
+
// wider than the three names in the config, which is the hole ADR 0028 closed:
|
|
251
|
+
// `--no-mcp` stops the servers connecting at all, so there is nothing to reach.
|
|
252
|
+
// A config that wants the servers asks with `mcp: true` — verified against a
|
|
253
|
+
// live 1.2.0 child: with `--tools read` it sees exactly `read` either way, so
|
|
254
|
+
// the opt-in's observable effect today is registration, not a declared list
|
|
255
|
+
// (see ADR 0028's verification note for the open follow-up).
|
|
256
|
+
const tools = options.agent?.tools ?? [];
|
|
257
|
+
if (tools.length > 0) {
|
|
258
|
+
args.push("--tools", tools.join(","));
|
|
259
|
+
if (options.agent?.mcp !== true) {
|
|
260
|
+
args.push("--no-mcp");
|
|
261
|
+
}
|
|
230
262
|
}
|
|
231
263
|
|
|
232
264
|
// Always exclude the subagent tool itself to prevent infinite recursion
|