@raidou/pi-pm-subagents 0.1.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/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentSession,
|
|
3
|
+
ContextUsage,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
formatSubagentSummary,
|
|
9
|
+
type LiveSubagent,
|
|
10
|
+
MAX_REUSE_FOLLOWUPS,
|
|
11
|
+
SubagentManager,
|
|
12
|
+
} from './manager.js'
|
|
13
|
+
|
|
14
|
+
type StubSession = Pick<
|
|
15
|
+
AgentSession,
|
|
16
|
+
'dispose' | 'abort' | 'steer' | 'subscribe' | 'prompt' | 'getContextUsage'
|
|
17
|
+
> & { messages: AgentSession['messages'] }
|
|
18
|
+
|
|
19
|
+
function makeStubSession(contextUsage?: ContextUsage): {
|
|
20
|
+
session: StubSession
|
|
21
|
+
steerMock: ReturnType<typeof vi.fn>
|
|
22
|
+
promptMock: ReturnType<typeof vi.fn>
|
|
23
|
+
abortMock: ReturnType<typeof vi.fn>
|
|
24
|
+
} {
|
|
25
|
+
const steerMock = vi.fn().mockResolvedValue(undefined)
|
|
26
|
+
const promptMock = vi.fn().mockResolvedValue(undefined)
|
|
27
|
+
const abortMock = vi.fn().mockResolvedValue(undefined)
|
|
28
|
+
|
|
29
|
+
const session: StubSession = {
|
|
30
|
+
messages: [],
|
|
31
|
+
dispose: () => {},
|
|
32
|
+
abort: abortMock,
|
|
33
|
+
steer: steerMock,
|
|
34
|
+
subscribe: () => () => {},
|
|
35
|
+
prompt: promptMock,
|
|
36
|
+
getContextUsage: vi.fn().mockReturnValue(contextUsage),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { session, steerMock, promptMock, abortMock }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function registerSubagent(
|
|
43
|
+
manager: SubagentManager,
|
|
44
|
+
session: StubSession,
|
|
45
|
+
overrides: Partial<{
|
|
46
|
+
id: number
|
|
47
|
+
status: 'running' | 'done' | 'failed' | 'killed'
|
|
48
|
+
followUpCount: number
|
|
49
|
+
}> = {},
|
|
50
|
+
): LiveSubagent {
|
|
51
|
+
const id = overrides.id ?? 1
|
|
52
|
+
const status = overrides.status ?? 'done'
|
|
53
|
+
const followUpCount = overrides.followUpCount ?? 0
|
|
54
|
+
|
|
55
|
+
const subagent: LiveSubagent = {
|
|
56
|
+
id,
|
|
57
|
+
title: `Task ${id}`,
|
|
58
|
+
previousEntries: [],
|
|
59
|
+
prompt: `Do task ${id}`,
|
|
60
|
+
status,
|
|
61
|
+
session: session as unknown as AgentSession,
|
|
62
|
+
startedAt: Date.now() - 1000,
|
|
63
|
+
completedAt: status !== 'running' ? Date.now() : undefined,
|
|
64
|
+
followUpCount,
|
|
65
|
+
activeTools: [],
|
|
66
|
+
role: 'worker',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
;(
|
|
70
|
+
manager as unknown as { subagents: Map<number, LiveSubagent> }
|
|
71
|
+
).subagents.set(id, subagent)
|
|
72
|
+
return subagent
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('formatSubagentSummary', () => {
|
|
76
|
+
it('renders context usage between title and follow symbol', () => {
|
|
77
|
+
const { session } = makeStubSession()
|
|
78
|
+
const manager = new SubagentManager()
|
|
79
|
+
const subagent = registerSubagent(manager, session, {
|
|
80
|
+
id: 1,
|
|
81
|
+
status: 'done',
|
|
82
|
+
followUpCount: 2,
|
|
83
|
+
})
|
|
84
|
+
subagent.contextUsage = {
|
|
85
|
+
tokens: 60000,
|
|
86
|
+
contextWindow: 200000,
|
|
87
|
+
percent: 30,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const summary = formatSubagentSummary(subagent, 80)
|
|
91
|
+
|
|
92
|
+
expect(summary).toBe('done #1 Task 1 60k/200k ⟳ 2 1s')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('renders ? when tokens is null', () => {
|
|
96
|
+
const { session } = makeStubSession()
|
|
97
|
+
const manager = new SubagentManager()
|
|
98
|
+
const subagent = registerSubagent(manager, session, { id: 1 })
|
|
99
|
+
subagent.contextUsage = {
|
|
100
|
+
tokens: null,
|
|
101
|
+
contextWindow: 200000,
|
|
102
|
+
percent: null,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
expect(formatSubagentSummary(subagent, 80)).toContain(' Task 1 ? ')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('renders ? when contextUsage is undefined', () => {
|
|
109
|
+
const { session } = makeStubSession()
|
|
110
|
+
const manager = new SubagentManager()
|
|
111
|
+
const subagent = registerSubagent(manager, session, { id: 1 })
|
|
112
|
+
|
|
113
|
+
expect(formatSubagentSummary(subagent, 80)).toContain(' Task 1 ? ')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('SubagentManager.followup', () => {
|
|
118
|
+
describe('subagent not found', () => {
|
|
119
|
+
it('throws when subagent id does not exist', async () => {
|
|
120
|
+
const manager = new SubagentManager()
|
|
121
|
+
await expect(manager.followup(99, 'title', 'task')).rejects.toThrow(
|
|
122
|
+
'Subagent #99 not found',
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('done subagent followup (regression)', () => {
|
|
128
|
+
it('calls run, onStart, resets fields, increments followUpCount', async () => {
|
|
129
|
+
const { session, promptMock } = makeStubSession()
|
|
130
|
+
const onStartMock = vi.fn()
|
|
131
|
+
const onStatusChangeMock = vi.fn()
|
|
132
|
+
const manager = new SubagentManager({
|
|
133
|
+
onEachStart: onStartMock,
|
|
134
|
+
onStatusChange: onStatusChangeMock,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
registerSubagent(manager, session, {
|
|
138
|
+
id: 1,
|
|
139
|
+
status: 'done',
|
|
140
|
+
followUpCount: 0,
|
|
141
|
+
})
|
|
142
|
+
promptMock.mockReturnValue(new Promise(() => {}))
|
|
143
|
+
|
|
144
|
+
const result = await manager.followup(1, 'New Title', 'New Task')
|
|
145
|
+
|
|
146
|
+
expect(result.id).toBe(1)
|
|
147
|
+
expect(result.status).toBe('running')
|
|
148
|
+
expect(result.title).toBe('New Title')
|
|
149
|
+
expect(result.prompt).toBe('New Task')
|
|
150
|
+
expect(result.followUpCount).toBe(1)
|
|
151
|
+
expect(result.startedAt).toBeGreaterThan(0)
|
|
152
|
+
expect(result.completedAt).toBeUndefined()
|
|
153
|
+
expect(onStartMock).toHaveBeenCalledOnce()
|
|
154
|
+
expect(onStatusChangeMock).toHaveBeenCalled()
|
|
155
|
+
expect(promptMock).toHaveBeenCalledOnce()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it(`throws when followUpCount reaches MAX_REUSE_FOLLOWUPS (${MAX_REUSE_FOLLOWUPS})`, async () => {
|
|
159
|
+
const { session } = makeStubSession()
|
|
160
|
+
const manager = new SubagentManager()
|
|
161
|
+
registerSubagent(manager, session, {
|
|
162
|
+
id: 1,
|
|
163
|
+
status: 'done',
|
|
164
|
+
followUpCount: MAX_REUSE_FOLLOWUPS,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
await expect(manager.followup(1, 'title', 'task')).rejects.toThrow(
|
|
168
|
+
`Subagent #1 follow-up budget exhausted (${MAX_REUSE_FOLLOWUPS}/${MAX_REUSE_FOLLOWUPS}). Start a fresh subagent instead.`,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
expect(session.prompt).not.toHaveBeenCalled()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('running subagent followup (steer)', () => {
|
|
176
|
+
it('calls session.steer with prefixed task text', async () => {
|
|
177
|
+
const { session, steerMock, promptMock } = makeStubSession()
|
|
178
|
+
const onStartMock = vi.fn()
|
|
179
|
+
const onStatusChangeMock = vi.fn()
|
|
180
|
+
const manager = new SubagentManager({
|
|
181
|
+
onEachStart: onStartMock,
|
|
182
|
+
onStatusChange: onStatusChangeMock,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
registerSubagent(manager, session, {
|
|
186
|
+
id: 1,
|
|
187
|
+
status: 'running',
|
|
188
|
+
followUpCount: 0,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const result = await manager.followup(1, 'New Title', 'New Task')
|
|
192
|
+
|
|
193
|
+
expect(result.id).toBe(1)
|
|
194
|
+
expect(result.status).toBe('running')
|
|
195
|
+
expect(result.title).toBe('New Title')
|
|
196
|
+
expect(result.prompt).toBe('New Task')
|
|
197
|
+
expect(result.followUpCount).toBe(1)
|
|
198
|
+
expect(steerMock).toHaveBeenCalledOnce()
|
|
199
|
+
expect(steerMock).toHaveBeenCalledWith('New Task')
|
|
200
|
+
expect(promptMock).not.toHaveBeenCalled()
|
|
201
|
+
expect(onStartMock).not.toHaveBeenCalled()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('does not reset startedAt / completedAt / message', async () => {
|
|
205
|
+
const { session, steerMock } = makeStubSession()
|
|
206
|
+
const manager = new SubagentManager()
|
|
207
|
+
const subagent = registerSubagent(manager, session, {
|
|
208
|
+
id: 1,
|
|
209
|
+
status: 'running',
|
|
210
|
+
followUpCount: 0,
|
|
211
|
+
})
|
|
212
|
+
const originalStartedAt = Date.now() - 5000
|
|
213
|
+
subagent.startedAt = originalStartedAt
|
|
214
|
+
|
|
215
|
+
await manager.followup(1, 'title', 'task')
|
|
216
|
+
|
|
217
|
+
expect(steerMock).toHaveBeenCalled()
|
|
218
|
+
expect(subagent.startedAt).toBe(originalStartedAt)
|
|
219
|
+
expect(subagent.completedAt).toBeUndefined()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('increments followUpCount each time', async () => {
|
|
223
|
+
const { session, steerMock } = makeStubSession()
|
|
224
|
+
const manager = new SubagentManager()
|
|
225
|
+
registerSubagent(manager, session, {
|
|
226
|
+
id: 1,
|
|
227
|
+
status: 'running',
|
|
228
|
+
followUpCount: 3,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
await manager.followup(1, 't1', 'task 1')
|
|
232
|
+
expect(steerMock).toHaveBeenCalledTimes(1)
|
|
233
|
+
|
|
234
|
+
await manager.followup(1, 't2', 'task 2')
|
|
235
|
+
expect(steerMock).toHaveBeenCalledTimes(2)
|
|
236
|
+
|
|
237
|
+
const subagent = (
|
|
238
|
+
manager as unknown as { subagents: Map<number, LiveSubagent> }
|
|
239
|
+
).subagents.get(1)
|
|
240
|
+
expect(subagent?.followUpCount).toBe(5)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it(`throws when followUpCount reaches MAX_REUSE_FOLLOWUPS (${MAX_REUSE_FOLLOWUPS})`, async () => {
|
|
244
|
+
const { session, steerMock } = makeStubSession()
|
|
245
|
+
const manager = new SubagentManager()
|
|
246
|
+
registerSubagent(manager, session, {
|
|
247
|
+
id: 1,
|
|
248
|
+
status: 'running',
|
|
249
|
+
followUpCount: MAX_REUSE_FOLLOWUPS,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
await expect(manager.followup(1, 'title', 'task')).rejects.toThrow(
|
|
253
|
+
`Subagent #1 follow-up budget exhausted (${MAX_REUSE_FOLLOWUPS}/${MAX_REUSE_FOLLOWUPS}). Start a fresh subagent instead.`,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
expect(steerMock).not.toHaveBeenCalled()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('saves previousEntry.status as done (not running)', async () => {
|
|
260
|
+
const { session, steerMock } = makeStubSession()
|
|
261
|
+
const manager = new SubagentManager()
|
|
262
|
+
const subagent = registerSubagent(manager, session, {
|
|
263
|
+
id: 1,
|
|
264
|
+
status: 'running',
|
|
265
|
+
followUpCount: 0,
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
await manager.followup(1, 'New Title', 'task')
|
|
269
|
+
|
|
270
|
+
expect(steerMock).toHaveBeenCalled()
|
|
271
|
+
expect(subagent.previousEntries).toHaveLength(1)
|
|
272
|
+
expect(subagent.previousEntries[0]).toMatchObject({
|
|
273
|
+
title: 'Task 1',
|
|
274
|
+
status: 'done',
|
|
275
|
+
followUpCount: 0,
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
describe('previousEntries accumulation', () => {
|
|
281
|
+
it('accumulates previous titles with full metadata on each followup', async () => {
|
|
282
|
+
const { session } = makeStubSession()
|
|
283
|
+
const manager = new SubagentManager()
|
|
284
|
+
const subagent = registerSubagent(manager, session, {
|
|
285
|
+
id: 1,
|
|
286
|
+
status: 'done',
|
|
287
|
+
followUpCount: 0,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
await manager.followup(1, 'Title 1', 'task 1')
|
|
291
|
+
await manager.followup(1, 'Title 2', 'task 2')
|
|
292
|
+
await manager.followup(1, 'Title 3', 'task 3')
|
|
293
|
+
|
|
294
|
+
expect(subagent.previousEntries).toHaveLength(3)
|
|
295
|
+
expect(subagent.previousEntries[0]).toMatchObject({
|
|
296
|
+
title: 'Title 2',
|
|
297
|
+
status: 'done',
|
|
298
|
+
followUpCount: 2,
|
|
299
|
+
})
|
|
300
|
+
expect(subagent.previousEntries[1]).toMatchObject({
|
|
301
|
+
title: 'Title 1',
|
|
302
|
+
status: 'done',
|
|
303
|
+
followUpCount: 1,
|
|
304
|
+
})
|
|
305
|
+
expect(subagent.previousEntries[2]).toMatchObject({
|
|
306
|
+
title: 'Task 1',
|
|
307
|
+
status: 'done',
|
|
308
|
+
followUpCount: 0,
|
|
309
|
+
})
|
|
310
|
+
expect(subagent.title).toBe('Title 3')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('does not modify previousEntries when followup budget exhausted', async () => {
|
|
314
|
+
const { session } = makeStubSession()
|
|
315
|
+
const manager = new SubagentManager()
|
|
316
|
+
const subagent = registerSubagent(manager, session, {
|
|
317
|
+
id: 1,
|
|
318
|
+
status: 'done',
|
|
319
|
+
followUpCount: MAX_REUSE_FOLLOWUPS,
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
await expect(manager.followup(1, 'New Title', 'task')).rejects.toThrow()
|
|
323
|
+
|
|
324
|
+
expect(subagent.previousEntries).toEqual([])
|
|
325
|
+
expect(subagent.title).toBe('Task 1')
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
describe('SubagentManager subscribe contextUsage', () => {
|
|
331
|
+
it('initializes contextUsage on subscribe', () => {
|
|
332
|
+
const contextUsage: ContextUsage = {
|
|
333
|
+
tokens: 50000,
|
|
334
|
+
contextWindow: 200000,
|
|
335
|
+
percent: 25.0,
|
|
336
|
+
}
|
|
337
|
+
const { session } = makeStubSession(contextUsage)
|
|
338
|
+
const manager = new SubagentManager()
|
|
339
|
+
const subagent = registerSubagent(manager, session, { id: 1 })
|
|
340
|
+
|
|
341
|
+
expect(subagent.contextUsage).toBeUndefined()
|
|
342
|
+
;(manager as unknown as { subscribe: (s: LiveSubagent) => void }).subscribe(
|
|
343
|
+
subagent,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
expect(subagent.contextUsage).toEqual(contextUsage)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('updates contextUsage on message_end event', () => {
|
|
350
|
+
const initialUsage: ContextUsage = {
|
|
351
|
+
tokens: 50000,
|
|
352
|
+
contextWindow: 200000,
|
|
353
|
+
percent: 25.0,
|
|
354
|
+
}
|
|
355
|
+
const updatedUsage: ContextUsage = {
|
|
356
|
+
tokens: 100000,
|
|
357
|
+
contextWindow: 200000,
|
|
358
|
+
percent: 50.0,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const subscribeMock = vi.fn().mockReturnValue(() => {})
|
|
362
|
+
const getContextUsageMock = vi.fn().mockReturnValue(initialUsage)
|
|
363
|
+
|
|
364
|
+
const session = {
|
|
365
|
+
messages: [],
|
|
366
|
+
dispose: () => {},
|
|
367
|
+
abort: vi.fn().mockResolvedValue(undefined),
|
|
368
|
+
steer: vi.fn().mockResolvedValue(undefined),
|
|
369
|
+
subscribe: subscribeMock,
|
|
370
|
+
prompt: vi.fn().mockResolvedValue(undefined),
|
|
371
|
+
getContextUsage: getContextUsageMock,
|
|
372
|
+
} as unknown as StubSession
|
|
373
|
+
|
|
374
|
+
const manager = new SubagentManager()
|
|
375
|
+
const subagent = registerSubagent(manager, session, { id: 1 })
|
|
376
|
+
;(manager as unknown as { subscribe: (s: LiveSubagent) => void }).subscribe(
|
|
377
|
+
subagent,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
expect(subagent.contextUsage).toEqual(initialUsage)
|
|
381
|
+
|
|
382
|
+
getContextUsageMock.mockReturnValue(updatedUsage)
|
|
383
|
+
const calls = subscribeMock.mock.calls
|
|
384
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
385
|
+
const subscribeCallback = calls[0]?.[0]
|
|
386
|
+
if (subscribeCallback) {
|
|
387
|
+
subscribeCallback({ type: 'message_end', turnIndex: 0 })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
expect(subagent.contextUsage).toEqual(updatedUsage)
|
|
391
|
+
})
|
|
392
|
+
})
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { ThinkingLevel } from '@earendil-works/pi-agent-core'
|
|
5
|
+
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
6
|
+
import {
|
|
7
|
+
type AgentSession,
|
|
8
|
+
type ContextUsage,
|
|
9
|
+
createAgentSession,
|
|
10
|
+
DefaultResourceLoader,
|
|
11
|
+
getAgentDir,
|
|
12
|
+
SessionManager,
|
|
13
|
+
} from '@earendil-works/pi-coding-agent'
|
|
14
|
+
|
|
15
|
+
import { formatContextUsage, formatElapsed } from '../utils/format.js'
|
|
16
|
+
import { lastMessageText } from '../utils/messages.js'
|
|
17
|
+
import { FOLLOW_SYMBOL } from './consts.ts'
|
|
18
|
+
import type { FleetEntryBase } from './fleet.js'
|
|
19
|
+
import {
|
|
20
|
+
runInSubagentSpawnContext,
|
|
21
|
+
SUBAGENT_SESSION_ID_PREFIX,
|
|
22
|
+
} from './identity.js'
|
|
23
|
+
|
|
24
|
+
const subagentDirFor = (cwd: string, agentDir: string): string => {
|
|
25
|
+
const safeCwd = cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')
|
|
26
|
+
return join(agentDir, 'sessions', 'pi-pm-subagents', `--${safeCwd}--`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_SUBAGENT_OUTPUT_BYTES = 50 * 1024
|
|
30
|
+
|
|
31
|
+
export const MAX_REUSE_FOLLOWUPS = 50
|
|
32
|
+
export const MAX_CONCURRENCY_SUBAGENT = 5
|
|
33
|
+
|
|
34
|
+
export type SubagentStatus = 'running' | 'done' | 'failed' | 'killed'
|
|
35
|
+
|
|
36
|
+
export interface SpawnOptions {
|
|
37
|
+
cwd: string
|
|
38
|
+
model?: Model<Api>
|
|
39
|
+
thinkingLevel?: ThinkingLevel
|
|
40
|
+
tools?: readonly string[]
|
|
41
|
+
systemPrompt?: string
|
|
42
|
+
role?: string
|
|
43
|
+
onComplete?: (subagent: LiveSubagent, lastMessage: string) => Promise<void>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LiveSubagent {
|
|
47
|
+
id: number
|
|
48
|
+
title: string
|
|
49
|
+
previousEntries: FleetEntryBase[]
|
|
50
|
+
prompt: string
|
|
51
|
+
status: SubagentStatus
|
|
52
|
+
session: AgentSession
|
|
53
|
+
startedAt: number
|
|
54
|
+
completedAt?: number
|
|
55
|
+
followUpCount: number
|
|
56
|
+
activeTools: string[]
|
|
57
|
+
role: string
|
|
58
|
+
contextUsage?: ContextUsage
|
|
59
|
+
onComplete?: (subagent: LiveSubagent, lastMessage: string) => Promise<void>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SubagentManagerOptions {
|
|
63
|
+
onStatusChange?: () => void
|
|
64
|
+
onEachStart?: (subagent: LiveSubagent) => void
|
|
65
|
+
onEachEnd?: (subagent: LiveSubagent) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatSubagentSummary(
|
|
69
|
+
subagent: LiveSubagent,
|
|
70
|
+
titleWidth = 80,
|
|
71
|
+
): string {
|
|
72
|
+
return [
|
|
73
|
+
subagent.status,
|
|
74
|
+
`#${subagent.id}`,
|
|
75
|
+
subagent.title.slice(0, titleWidth),
|
|
76
|
+
formatContextUsage(subagent.contextUsage),
|
|
77
|
+
`${FOLLOW_SYMBOL} ${subagent.followUpCount}`,
|
|
78
|
+
formatElapsed(subagent),
|
|
79
|
+
].join(' ')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class SubagentManager {
|
|
83
|
+
private subagents = new Map<number, LiveSubagent>()
|
|
84
|
+
private seq = 0
|
|
85
|
+
|
|
86
|
+
constructor(private options: SubagentManagerOptions = {}) {}
|
|
87
|
+
|
|
88
|
+
private countRunning(): number {
|
|
89
|
+
return [...this.subagents.values()].filter(
|
|
90
|
+
(subagent) => subagent.status === 'running',
|
|
91
|
+
).length
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
list(): LiveSubagent[] {
|
|
95
|
+
return [...this.subagents.values()]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get(id: number): LiveSubagent | undefined {
|
|
99
|
+
return this.subagents.get(id)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
latest(): LiveSubagent | undefined {
|
|
103
|
+
let latest: LiveSubagent | undefined
|
|
104
|
+
for (const subagent of this.subagents.values()) {
|
|
105
|
+
if (!latest || subagent.id > latest.id) latest = subagent
|
|
106
|
+
}
|
|
107
|
+
return latest
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async createLoader(
|
|
111
|
+
options: SpawnOptions,
|
|
112
|
+
): Promise<DefaultResourceLoader> {
|
|
113
|
+
const loader = new DefaultResourceLoader({
|
|
114
|
+
cwd: options.cwd,
|
|
115
|
+
agentDir: getAgentDir(),
|
|
116
|
+
systemPromptOverride: (base) => `${base}\n\n${options.systemPrompt}`,
|
|
117
|
+
})
|
|
118
|
+
await loader.reload()
|
|
119
|
+
return loader
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async createNewSubagent(
|
|
123
|
+
title: string,
|
|
124
|
+
prompt: string,
|
|
125
|
+
options: SpawnOptions,
|
|
126
|
+
): Promise<LiveSubagent> {
|
|
127
|
+
if (this.countRunning() >= MAX_CONCURRENCY_SUBAGENT) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Subagent concurrency limit reached (${MAX_CONCURRENCY_SUBAGENT}). Wait for an existing subagent to finish, or abort one.`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
this.seq += 1
|
|
133
|
+
const id = this.seq
|
|
134
|
+
const subagentSessionDir = subagentDirFor(options.cwd, getAgentDir())
|
|
135
|
+
mkdirSync(subagentSessionDir, { recursive: true })
|
|
136
|
+
const created = await runInSubagentSpawnContext(id, async () => {
|
|
137
|
+
const loader = await this.createLoader(options)
|
|
138
|
+
return createAgentSession({
|
|
139
|
+
cwd: options.cwd,
|
|
140
|
+
model: options.model,
|
|
141
|
+
thinkingLevel: options.thinkingLevel,
|
|
142
|
+
tools: options.tools ? [...options.tools] : undefined,
|
|
143
|
+
resourceLoader: loader,
|
|
144
|
+
sessionManager: SessionManager.create(options.cwd, subagentSessionDir, {
|
|
145
|
+
id: `${SUBAGENT_SESSION_ID_PREFIX}${id}-${Date.now()}`,
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const activeTools: string[] = options.tools ? [...options.tools] : []
|
|
151
|
+
const subagent: LiveSubagent = {
|
|
152
|
+
id,
|
|
153
|
+
title,
|
|
154
|
+
previousEntries: [],
|
|
155
|
+
prompt,
|
|
156
|
+
status: 'running',
|
|
157
|
+
session: created.session,
|
|
158
|
+
startedAt: Date.now(),
|
|
159
|
+
followUpCount: 0,
|
|
160
|
+
activeTools,
|
|
161
|
+
role: options.role ?? 'worker',
|
|
162
|
+
onComplete: options.onComplete,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.subagents.set(id, subagent)
|
|
166
|
+
this.subscribe(subagent)
|
|
167
|
+
this.options.onEachStart?.(subagent)
|
|
168
|
+
this.options.onStatusChange?.()
|
|
169
|
+
void this.run(subagent, prompt)
|
|
170
|
+
return subagent
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async followup(
|
|
174
|
+
id: number,
|
|
175
|
+
title: string,
|
|
176
|
+
prompt: string,
|
|
177
|
+
): Promise<LiveSubagent> {
|
|
178
|
+
const followupSubagent = this.subagents.get(id)
|
|
179
|
+
if (!followupSubagent) throw new Error(`Subagent #${id} not found`)
|
|
180
|
+
if (followupSubagent.followUpCount >= MAX_REUSE_FOLLOWUPS)
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Subagent #${id} follow-up budget exhausted (${MAX_REUSE_FOLLOWUPS}/${MAX_REUSE_FOLLOWUPS}). Start a fresh subagent instead.`,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const wasRunning = followupSubagent.status === 'running'
|
|
186
|
+
const prevStartedAt = followupSubagent.startedAt
|
|
187
|
+
followupSubagent.previousEntries.unshift({
|
|
188
|
+
title: followupSubagent.title,
|
|
189
|
+
status: wasRunning ? 'done' : followupSubagent.status,
|
|
190
|
+
followUpCount: followupSubagent.followUpCount,
|
|
191
|
+
startedAt: prevStartedAt,
|
|
192
|
+
completedAt: wasRunning
|
|
193
|
+
? Date.now()
|
|
194
|
+
: (followupSubagent.completedAt ?? Date.now()),
|
|
195
|
+
})
|
|
196
|
+
followupSubagent.title = title
|
|
197
|
+
followupSubagent.prompt = prompt
|
|
198
|
+
followupSubagent.followUpCount += 1
|
|
199
|
+
|
|
200
|
+
if (followupSubagent.status === 'running') {
|
|
201
|
+
await followupSubagent.session.steer(prompt)
|
|
202
|
+
this.options.onStatusChange?.()
|
|
203
|
+
return followupSubagent
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
followupSubagent.status = 'running'
|
|
207
|
+
followupSubagent.startedAt = Date.now()
|
|
208
|
+
followupSubagent.completedAt = undefined
|
|
209
|
+
this.options.onStatusChange?.()
|
|
210
|
+
this.options.onEachStart?.(followupSubagent)
|
|
211
|
+
void this.run(followupSubagent, prompt)
|
|
212
|
+
return followupSubagent
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async steer(id: number, text: string): Promise<boolean> {
|
|
216
|
+
const subagent = this.subagents.get(id)
|
|
217
|
+
if (!subagent || subagent.status !== 'running') return false
|
|
218
|
+
await subagent.session.steer(text)
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async abort(id: number): Promise<boolean> {
|
|
223
|
+
const subagent = this.subagents.get(id)
|
|
224
|
+
if (!subagent || subagent.status !== 'running') return false
|
|
225
|
+
subagent.status = 'killed'
|
|
226
|
+
this.options.onStatusChange?.()
|
|
227
|
+
await subagent.session.abort()
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
disposeAll(): void {
|
|
232
|
+
const disposedSessions = new Set<AgentSession>()
|
|
233
|
+
for (const subagent of this.subagents.values()) {
|
|
234
|
+
if (subagent.status === 'running') {
|
|
235
|
+
subagent.status = 'killed'
|
|
236
|
+
this.options.onEachEnd?.(subagent)
|
|
237
|
+
}
|
|
238
|
+
if (!disposedSessions.has(subagent.session)) {
|
|
239
|
+
subagent.session.dispose()
|
|
240
|
+
disposedSessions.add(subagent.session)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
this.subagents.clear()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private subscribe(subagent: LiveSubagent): void {
|
|
247
|
+
subagent.contextUsage = subagent.session.getContextUsage()
|
|
248
|
+
subagent.session.subscribe((event) => {
|
|
249
|
+
if (event.type === 'message_end') {
|
|
250
|
+
subagent.contextUsage = subagent.session.getContextUsage()
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async run(subagent: LiveSubagent, prompt: string): Promise<void> {
|
|
256
|
+
let lastMessage: string | undefined
|
|
257
|
+
try {
|
|
258
|
+
await subagent.session.prompt(prompt)
|
|
259
|
+
const finalText = lastMessageText(
|
|
260
|
+
subagent.session.messages,
|
|
261
|
+
MAX_SUBAGENT_OUTPUT_BYTES,
|
|
262
|
+
)
|
|
263
|
+
lastMessage = finalText ?? '(Subagent finished without a final message.)'
|
|
264
|
+
if (subagent.status === 'running') subagent.status = 'done'
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (subagent.status === 'running') {
|
|
267
|
+
lastMessage = error instanceof Error ? error.message : String(error)
|
|
268
|
+
subagent.status = 'failed'
|
|
269
|
+
}
|
|
270
|
+
} finally {
|
|
271
|
+
subagent.completedAt = Date.now()
|
|
272
|
+
this.options.onStatusChange?.()
|
|
273
|
+
await subagent.onComplete?.(subagent, lastMessage ?? '')
|
|
274
|
+
this.options.onEachEnd?.(subagent)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|