@pi-archimedes/subagent 2.2.0 → 2.4.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 +6 -2
- package/src/agent-manager.ts +12 -1676
- package/src/agent-panel.ts +1442 -0
- package/src/agent-store.ts +309 -0
- package/src/compact.test.ts +19 -8
- package/src/compact.ts +141 -161
- package/src/expanded.ts +16 -26
- package/src/index.ts +8 -38
- package/src/stream.test.ts +267 -3
- package/src/stream.ts +35 -8
- package/src/tool-schema.ts +33 -0
package/src/stream.test.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
import { PassThrough } from "node:stream";
|
|
3
3
|
import type { ChildProcess } from "node:child_process";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
|
-
import {
|
|
5
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
6
|
+
import { streamEvents, type StreamCallbacks } from "./stream.js";
|
|
6
7
|
|
|
7
8
|
type FakeChild = ChildProcess & { stdout: PassThrough; stderr: PassThrough };
|
|
8
9
|
|
|
@@ -16,9 +17,12 @@ function fakeChild(): FakeChild {
|
|
|
16
17
|
return child;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
async function finishWith(
|
|
20
|
+
async function finishWith(
|
|
21
|
+
events: Array<Record<string, unknown>>,
|
|
22
|
+
callbacks: StreamCallbacks = {},
|
|
23
|
+
) {
|
|
20
24
|
const child = fakeChild();
|
|
21
|
-
const result = streamEvents(child);
|
|
25
|
+
const result = streamEvents(child, callbacks);
|
|
22
26
|
for (const event of events) {
|
|
23
27
|
child.stdout.write(`${JSON.stringify(event)}\n`);
|
|
24
28
|
}
|
|
@@ -26,6 +30,7 @@ async function finishWith(events: Array<Record<string, unknown>>) {
|
|
|
26
30
|
return result;
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
|
|
29
34
|
describe("streamEvents session identity", () => {
|
|
30
35
|
it("returns the logical child Pi session ID", async () => {
|
|
31
36
|
const result = await finishWith([{
|
|
@@ -45,3 +50,262 @@ describe("streamEvents session identity", () => {
|
|
|
45
50
|
expect(result.childSessionId).toBeUndefined();
|
|
46
51
|
});
|
|
47
52
|
});
|
|
53
|
+
|
|
54
|
+
describe("streamEvents todo mirroring", () => {
|
|
55
|
+
const start = {
|
|
56
|
+
type: "tool_execution_start",
|
|
57
|
+
toolCallId: "t1",
|
|
58
|
+
toolName: "manage_todo_list",
|
|
59
|
+
args: {
|
|
60
|
+
operation: "write",
|
|
61
|
+
todoList: [{ id: 1, title: "Fix auth", status: "not-started" }],
|
|
62
|
+
},
|
|
63
|
+
} as unknown as Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
it("mirrors the child-accepted state from result.details.todos", async () => {
|
|
66
|
+
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
67
|
+
const clears: Array<{ source: string }> = [];
|
|
68
|
+
const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
69
|
+
updates.push(p as (typeof updates)[number]),
|
|
70
|
+
);
|
|
71
|
+
const offClear = getBus().on(Events.TODOS_CLEAR, (p: unknown) =>
|
|
72
|
+
clears.push(p as { source: string }),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
await finishWith(
|
|
76
|
+
[
|
|
77
|
+
start,
|
|
78
|
+
{
|
|
79
|
+
type: "tool_execution_end",
|
|
80
|
+
toolCallId: "t1",
|
|
81
|
+
toolName: "manage_todo_list",
|
|
82
|
+
isError: false,
|
|
83
|
+
result: {
|
|
84
|
+
content: [{ type: "text", text: "ok" }],
|
|
85
|
+
details: {
|
|
86
|
+
operation: "write",
|
|
87
|
+
todos: [{ content: "Fix auth", status: "pending" }],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
{ agent: "t-acc" },
|
|
93
|
+
);
|
|
94
|
+
off();
|
|
95
|
+
offClear();
|
|
96
|
+
|
|
97
|
+
expect(updates).toHaveLength(1);
|
|
98
|
+
expect(updates[0]!.source).toBe("subagent:t-acc");
|
|
99
|
+
expect(updates[0]!.todos).toEqual([
|
|
100
|
+
{ content: "Fix auth", status: "pending" },
|
|
101
|
+
]);
|
|
102
|
+
// Bus re-drains queued events (emitted while unsubscribed) to the next
|
|
103
|
+
// subscriber, so stale "subagent:general" clears from earlier tests may
|
|
104
|
+
// be present — assert on the per-agent source instead of the raw count.
|
|
105
|
+
expect(clears.filter((c) => c.source === "subagent:t-acc")).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("mirrors nothing when the tool returned a validation rejection (result.isError)", async () => {
|
|
109
|
+
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
110
|
+
const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
111
|
+
updates.push(p as (typeof updates)[number]),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await finishWith(
|
|
115
|
+
[
|
|
116
|
+
start,
|
|
117
|
+
{
|
|
118
|
+
type: "tool_execution_end",
|
|
119
|
+
toolCallId: "t1",
|
|
120
|
+
toolName: "manage_todo_list",
|
|
121
|
+
isError: false,
|
|
122
|
+
result: {
|
|
123
|
+
content: [{ type: "text", text: "Validation failed" }],
|
|
124
|
+
details: {
|
|
125
|
+
operation: "write",
|
|
126
|
+
todos: [{ content: "Old item", status: "completed" }],
|
|
127
|
+
error: "Item 1: missing or invalid 'content'",
|
|
128
|
+
},
|
|
129
|
+
isError: true,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
{ agent: "t-rej" },
|
|
134
|
+
);
|
|
135
|
+
off();
|
|
136
|
+
|
|
137
|
+
expect(updates).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("mirrors nothing when the harness reported an error (event.isError)", async () => {
|
|
141
|
+
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
142
|
+
const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
143
|
+
updates.push(p as (typeof updates)[number]),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await finishWith(
|
|
147
|
+
[
|
|
148
|
+
start,
|
|
149
|
+
{
|
|
150
|
+
type: "tool_execution_end",
|
|
151
|
+
toolCallId: "t1",
|
|
152
|
+
toolName: "manage_todo_list",
|
|
153
|
+
isError: true,
|
|
154
|
+
result: {
|
|
155
|
+
content: [{ type: "text", text: "Aborted" }],
|
|
156
|
+
details: {},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
{ agent: "t-rej2" },
|
|
161
|
+
);
|
|
162
|
+
off();
|
|
163
|
+
|
|
164
|
+
expect(updates).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("falls back to normalized raw args when the accepted result has no details", async () => {
|
|
168
|
+
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
169
|
+
const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
170
|
+
updates.push(p as (typeof updates)[number]),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
await finishWith(
|
|
174
|
+
[
|
|
175
|
+
start,
|
|
176
|
+
{
|
|
177
|
+
type: "tool_execution_end",
|
|
178
|
+
toolCallId: "t1",
|
|
179
|
+
toolName: "manage_todo_list",
|
|
180
|
+
isError: false,
|
|
181
|
+
result: { content: [{ type: "text", text: "ok" }] },
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
{ agent: "t-nodetails" },
|
|
185
|
+
);
|
|
186
|
+
off();
|
|
187
|
+
|
|
188
|
+
expect(updates).toHaveLength(1);
|
|
189
|
+
expect(updates[0]!.source).toBe("subagent:t-nodetails");
|
|
190
|
+
expect(updates[0]!.todos).toEqual([
|
|
191
|
+
{ content: "Fix auth", status: "pending" },
|
|
192
|
+
]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("does not crash or emit when the end event has no matching start", async () => {
|
|
196
|
+
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
197
|
+
const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
198
|
+
updates.push(p as (typeof updates)[number]),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const result = await finishWith(
|
|
202
|
+
[
|
|
203
|
+
{
|
|
204
|
+
type: "tool_execution_end",
|
|
205
|
+
toolCallId: "orphan",
|
|
206
|
+
toolName: "manage_todo_list",
|
|
207
|
+
isError: false,
|
|
208
|
+
result: { content: [{ type: "text", text: "ok" }] },
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
{ agent: "t-orphan" },
|
|
212
|
+
);
|
|
213
|
+
off();
|
|
214
|
+
|
|
215
|
+
expect(result.exitCode).toBe(0);
|
|
216
|
+
expect(updates).toHaveLength(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("does not emit for non-todo tools with todoList-shaped args", async () => {
|
|
220
|
+
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
221
|
+
const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
222
|
+
updates.push(p as (typeof updates)[number]),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
await finishWith(
|
|
226
|
+
[
|
|
227
|
+
{
|
|
228
|
+
type: "tool_execution_start",
|
|
229
|
+
toolCallId: "b1",
|
|
230
|
+
toolName: "bash",
|
|
231
|
+
args: {
|
|
232
|
+
operation: "write",
|
|
233
|
+
todoList: [{ content: "Fix auth", status: "pending" }],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
type: "tool_execution_end",
|
|
238
|
+
toolCallId: "b1",
|
|
239
|
+
toolName: "bash",
|
|
240
|
+
isError: false,
|
|
241
|
+
result: {
|
|
242
|
+
content: [{ type: "text", text: "ok" }],
|
|
243
|
+
details: {
|
|
244
|
+
operation: "write",
|
|
245
|
+
todos: [{ content: "Fix auth", status: "pending" }],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
{ agent: "t-bash" },
|
|
251
|
+
);
|
|
252
|
+
off();
|
|
253
|
+
|
|
254
|
+
expect(updates).toHaveLength(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("uses a unique per-child source when the child provides a session id", async () => {
|
|
258
|
+
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
259
|
+
const clears: Array<{ source: string }> = [];
|
|
260
|
+
const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
261
|
+
updates.push(p as (typeof updates)[number]),
|
|
262
|
+
);
|
|
263
|
+
const offClear = getBus().on(Events.TODOS_CLEAR, (p: unknown) =>
|
|
264
|
+
clears.push(p as { source: string }),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const agent = "t-unique";
|
|
268
|
+
const idA = "11111111-1111-7111-8111-111111111111";
|
|
269
|
+
const idB = "22222222-2222-7222-8222-222222222222";
|
|
270
|
+
|
|
271
|
+
const accepted = {
|
|
272
|
+
type: "tool_execution_end",
|
|
273
|
+
toolCallId: "t1",
|
|
274
|
+
toolName: "manage_todo_list",
|
|
275
|
+
isError: false,
|
|
276
|
+
result: {
|
|
277
|
+
content: [{ type: "text", text: "ok" }],
|
|
278
|
+
details: {
|
|
279
|
+
operation: "write",
|
|
280
|
+
todos: [{ content: "Fix auth", status: "pending" }],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
} as unknown as Record<string, unknown>;
|
|
284
|
+
|
|
285
|
+
// Two concurrent children, same agent, distinct session ids.
|
|
286
|
+
await finishWith(
|
|
287
|
+
[{ type: "session", id: idA }, start, accepted],
|
|
288
|
+
{ agent },
|
|
289
|
+
);
|
|
290
|
+
await finishWith(
|
|
291
|
+
[{ type: "session", id: idB }, start, accepted],
|
|
292
|
+
{ agent },
|
|
293
|
+
);
|
|
294
|
+
off();
|
|
295
|
+
offClear();
|
|
296
|
+
|
|
297
|
+
const sources = updates.map((u) => u.source);
|
|
298
|
+
// Each child gets its own suffixed source equal to subagent:<agent>:<uuid>.
|
|
299
|
+
expect(sources).toContain(`subagent:${agent}:${idA}`);
|
|
300
|
+
expect(sources).toContain(`subagent:${agent}:${idB}`);
|
|
301
|
+
// The two concurrent children must NOT share a single source.
|
|
302
|
+
expect(sources.filter((s) => s.startsWith(`subagent:${agent}:`))).toEqual([
|
|
303
|
+
`subagent:${agent}:${idA}`,
|
|
304
|
+
`subagent:${agent}:${idB}`,
|
|
305
|
+
]);
|
|
306
|
+
// The clear on close carries the same suffixed source for each child.
|
|
307
|
+
expect(clears.filter((c) => c.source === `subagent:${agent}:${idA}`)).toHaveLength(1);
|
|
308
|
+
expect(clears.filter((c) => c.source === `subagent:${agent}:${idB}`)).toHaveLength(1);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
package/src/stream.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createInterface } from "node:readline";
|
|
|
2
2
|
import type { ChildProcess } from "node:child_process";
|
|
3
3
|
import type { StreamState, SubagentProgress, SubagentResult } from "./types.js";
|
|
4
4
|
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
5
|
+
import { normalizeTodoItems } from "@pi-archimedes/todo/prepare-args";
|
|
5
6
|
import {
|
|
6
7
|
type JsonEvent,
|
|
7
8
|
handleToolStart,
|
|
@@ -111,6 +112,12 @@ export function streamEvents(
|
|
|
111
112
|
|
|
112
113
|
const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
113
114
|
|
|
115
|
+
// toolCallId → raw todoList from tool_execution_start, consumed by the
|
|
116
|
+
// matching tool_execution_end. pi emits tool_execution_start with the
|
|
117
|
+
// raw (pre-prepareToolCall) arguments, so these may be unrepaired.
|
|
118
|
+
const pendingTodoArgs = new Map<string, unknown[]>();
|
|
119
|
+
let subagentSource = `subagent:${callbacks.agent ?? "general"}`;
|
|
120
|
+
|
|
114
121
|
rl.on("line", (line) => {
|
|
115
122
|
const trimmed = line.trim();
|
|
116
123
|
if (!trimmed) return;
|
|
@@ -130,26 +137,46 @@ export function streamEvents(
|
|
|
130
137
|
case "session": {
|
|
131
138
|
if (typeof event.id === "string" && event.id) {
|
|
132
139
|
state.childSessionId = event.id;
|
|
140
|
+
// A child with a session id gets a unique per-child todo source so
|
|
141
|
+
// concurrent children sharing an agent name don't clobber each
|
|
142
|
+
// other's accepted state when one clears on exit. Children without
|
|
143
|
+
// a session id (e.g. tests) keep the legacy subagent:<agent> form.
|
|
144
|
+
subagentSource = `subagent:${callbacks.agent ?? "general"}:${event.id}`;
|
|
133
145
|
}
|
|
134
146
|
break;
|
|
135
147
|
}
|
|
136
148
|
case "tool_execution_start": {
|
|
137
149
|
handleToolStart(state, event);
|
|
138
150
|
emitProgress();
|
|
139
|
-
|
|
140
|
-
if (event.toolName === "manage_todo_list") {
|
|
151
|
+
if (event.toolName === "manage_todo_list" && typeof event.toolCallId === "string") {
|
|
141
152
|
const args = event.args as Record<string, unknown> | undefined;
|
|
142
|
-
const todoList = args?.todoList
|
|
153
|
+
const todoList = args?.todoList;
|
|
143
154
|
if (Array.isArray(todoList)) {
|
|
144
|
-
|
|
145
|
-
source: `subagent:${callbacks.agent ?? "general"}`,
|
|
146
|
-
todos: todoList,
|
|
147
|
-
});
|
|
155
|
+
pendingTodoArgs.set(event.toolCallId, todoList);
|
|
148
156
|
}
|
|
149
157
|
}
|
|
150
158
|
break;
|
|
151
159
|
}
|
|
152
160
|
case "tool_execution_end": {
|
|
161
|
+
if (event.toolName === "manage_todo_list") {
|
|
162
|
+
const id = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
|
|
163
|
+
const stowed = id ? pendingTodoArgs.get(id) : undefined;
|
|
164
|
+
if (id) pendingTodoArgs.delete(id);
|
|
165
|
+
const result = event.result as Record<string, unknown> | undefined;
|
|
166
|
+
// Tool-level failure (todo's execute() RETURNS {isError:true} on validation
|
|
167
|
+
// rejection — it does not throw) arrives as event.isError=false with
|
|
168
|
+
// result.isError=true. Harness failures (abort, not-found, block, throw)
|
|
169
|
+
// arrive as event.isError=true. Check BOTH — the same convention
|
|
170
|
+
// handleToolResult in handlers.ts already uses.
|
|
171
|
+
const failed = event.isError === true || result?.isError === true;
|
|
172
|
+
if (!failed) {
|
|
173
|
+
const detailsTodos = (result?.details as Record<string, unknown> | undefined)?.todos;
|
|
174
|
+
const todos = normalizeTodoItems(Array.isArray(detailsTodos) ? detailsTodos : stowed);
|
|
175
|
+
if (todos) {
|
|
176
|
+
getBus().emit(Events.TODOS_UPDATE, { source: subagentSource, todos });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
153
180
|
handleToolEnd(state);
|
|
154
181
|
emitProgress();
|
|
155
182
|
handleToolResult(state, event);
|
|
@@ -218,7 +245,7 @@ export function streamEvents(
|
|
|
218
245
|
|
|
219
246
|
// Clear subagent todos from the bus on exit
|
|
220
247
|
getBus().emit(Events.TODOS_CLEAR, {
|
|
221
|
-
source:
|
|
248
|
+
source: subagentSource,
|
|
222
249
|
});
|
|
223
250
|
|
|
224
251
|
resolve(result);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
// ── JSON Schema for tool parameters (TypeBox) ──────────────────────────────
|
|
4
|
+
|
|
5
|
+
const TaskItem = Type.Object({
|
|
6
|
+
agent: Type.Optional(Type.String({
|
|
7
|
+
description: "Agent name for this task (optional). If omitted, runs config-less.",
|
|
8
|
+
})),
|
|
9
|
+
task: Type.String(),
|
|
10
|
+
model: Type.Optional(Type.String()),
|
|
11
|
+
cwd: Type.Optional(Type.String()),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const SUBAGENT_PARAMS_SCHEMA = Type.Object({
|
|
15
|
+
agent: Type.Optional(Type.String({
|
|
16
|
+
description: "Agent name (optional). If omitted, the subagent runs config-less — parent's current model, all tools, no system-prompt override. Call list_agents to see available agents.",
|
|
17
|
+
})),
|
|
18
|
+
task: Type.Optional(Type.String({
|
|
19
|
+
description: "Task description for the subagent. Required when not using 'tasks' array.",
|
|
20
|
+
})),
|
|
21
|
+
tasks: Type.Optional(Type.Array(TaskItem, {
|
|
22
|
+
description: "Multiple tasks for parallel execution. Required when not using 'task'.",
|
|
23
|
+
})),
|
|
24
|
+
model: Type.Optional(Type.String({
|
|
25
|
+
description: "Model override for the subagent",
|
|
26
|
+
})),
|
|
27
|
+
async: Type.Optional(Type.Boolean({
|
|
28
|
+
description: "Run asynchronously (fire-and-forget)",
|
|
29
|
+
})),
|
|
30
|
+
cwd: Type.Optional(Type.String({
|
|
31
|
+
description: "Working directory for the subagent",
|
|
32
|
+
})),
|
|
33
|
+
});
|