@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,436 @@
|
|
|
1
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core'
|
|
2
|
+
import type {
|
|
3
|
+
AssistantMessage,
|
|
4
|
+
TextContent,
|
|
5
|
+
ThinkingContent,
|
|
6
|
+
ToolCall,
|
|
7
|
+
ToolResultMessage,
|
|
8
|
+
UserMessage,
|
|
9
|
+
} from '@earendil-works/pi-ai'
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
formatToolNameWithArgs,
|
|
14
|
+
lastAssistantText,
|
|
15
|
+
lastMessageText,
|
|
16
|
+
messageText,
|
|
17
|
+
} from './messages.js'
|
|
18
|
+
|
|
19
|
+
function user(text: string): UserMessage {
|
|
20
|
+
return {
|
|
21
|
+
role: 'user',
|
|
22
|
+
content: [{ type: 'text', text }],
|
|
23
|
+
timestamp: Date.now(),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assistant(opts: {
|
|
28
|
+
text?: string
|
|
29
|
+
toolCall?: string
|
|
30
|
+
toolCallArgs?: Record<string, unknown>
|
|
31
|
+
toolCallId?: string
|
|
32
|
+
}): AssistantMessage {
|
|
33
|
+
const content: (TextContent | ThinkingContent | ToolCall)[] = []
|
|
34
|
+
if (opts.text) {
|
|
35
|
+
content.push({ type: 'text', text: opts.text })
|
|
36
|
+
}
|
|
37
|
+
if (opts.toolCall) {
|
|
38
|
+
content.push({
|
|
39
|
+
type: 'toolCall',
|
|
40
|
+
id: opts.toolCallId || 'call-123',
|
|
41
|
+
name: opts.toolCall,
|
|
42
|
+
arguments: opts.toolCallArgs || {},
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
role: 'assistant',
|
|
47
|
+
content,
|
|
48
|
+
api: 'openai-responses',
|
|
49
|
+
provider: 'openai',
|
|
50
|
+
model: 'gpt-4',
|
|
51
|
+
usage: {
|
|
52
|
+
input: 0,
|
|
53
|
+
output: 0,
|
|
54
|
+
cacheRead: 0,
|
|
55
|
+
cacheWrite: 0,
|
|
56
|
+
totalTokens: 0,
|
|
57
|
+
cost: {
|
|
58
|
+
input: 0,
|
|
59
|
+
output: 0,
|
|
60
|
+
cacheRead: 0,
|
|
61
|
+
cacheWrite: 0,
|
|
62
|
+
total: 0,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
stopReason: 'stop',
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toolResultText(text: string, isError = false): ToolResultMessage {
|
|
71
|
+
return {
|
|
72
|
+
role: 'toolResult',
|
|
73
|
+
toolCallId: 'call-123',
|
|
74
|
+
toolName: 'read',
|
|
75
|
+
content: [{ type: 'text', text }],
|
|
76
|
+
isError,
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toolResultMessage(
|
|
82
|
+
toolCallId: string,
|
|
83
|
+
toolName: string,
|
|
84
|
+
text: string,
|
|
85
|
+
isError = false,
|
|
86
|
+
): ToolResultMessage {
|
|
87
|
+
return {
|
|
88
|
+
role: 'toolResult',
|
|
89
|
+
toolCallId,
|
|
90
|
+
toolName,
|
|
91
|
+
content: [{ type: 'text', text }],
|
|
92
|
+
isError,
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('lastAssistantText', () => {
|
|
98
|
+
it('returns the text from the last assistant message', () => {
|
|
99
|
+
const messages: AgentMessage[] = [
|
|
100
|
+
user('task'),
|
|
101
|
+
assistant({ text: 'I will read it' }),
|
|
102
|
+
]
|
|
103
|
+
const result = lastAssistantText(messages)
|
|
104
|
+
expect(result).toBe('I will read it')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('finds the last assistant message when toolResult follows', () => {
|
|
108
|
+
const messages: AgentMessage[] = [
|
|
109
|
+
user('task'),
|
|
110
|
+
assistant({ text: 'Reading file', toolCall: 'read' }),
|
|
111
|
+
toolResultText('content'),
|
|
112
|
+
]
|
|
113
|
+
const result = lastAssistantText(messages)
|
|
114
|
+
expect(result).toBe('Reading file')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('finds the last assistant message when multiple assistants exist', () => {
|
|
118
|
+
const messages: AgentMessage[] = [
|
|
119
|
+
user('task'),
|
|
120
|
+
assistant({ text: 'first' }),
|
|
121
|
+
toolResultText('result'),
|
|
122
|
+
assistant({ text: 'second' }),
|
|
123
|
+
]
|
|
124
|
+
const result = lastAssistantText(messages)
|
|
125
|
+
expect(result).toBe('second')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('returns undefined when only user messages exist', () => {
|
|
129
|
+
const messages: AgentMessage[] = [user('task'), user('follow-up')]
|
|
130
|
+
const result = lastAssistantText(messages)
|
|
131
|
+
expect(result).toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('returns undefined when the last message is toolResult', () => {
|
|
135
|
+
const messages: AgentMessage[] = [
|
|
136
|
+
user('task'),
|
|
137
|
+
assistant({ toolCall: 'read' }),
|
|
138
|
+
toolResultText('File content here'),
|
|
139
|
+
]
|
|
140
|
+
const result = lastAssistantText(messages)
|
|
141
|
+
expect(result).toBeUndefined()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('returns undefined for empty messages array', () => {
|
|
145
|
+
const messages: AgentMessage[] = []
|
|
146
|
+
const result = lastAssistantText(messages)
|
|
147
|
+
expect(result).toBeUndefined()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('lastAssistantText with maxBytes', () => {
|
|
152
|
+
it('returns text unchanged when under byte limit', () => {
|
|
153
|
+
const messages: AgentMessage[] = [
|
|
154
|
+
user('task'),
|
|
155
|
+
assistant({ text: 'Short text' }),
|
|
156
|
+
]
|
|
157
|
+
const result = lastAssistantText(messages, 100)
|
|
158
|
+
expect(result).toBe('Short text')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('truncates text with suffix when over byte limit', () => {
|
|
162
|
+
const messages: AgentMessage[] = [
|
|
163
|
+
user('task'),
|
|
164
|
+
assistant({ text: 'a'.repeat(1000) }),
|
|
165
|
+
]
|
|
166
|
+
const result = lastAssistantText(messages, 50)
|
|
167
|
+
const defaultSuffix = '\n\n[Output truncated.]'
|
|
168
|
+
expect(result).toContain(defaultSuffix)
|
|
169
|
+
expect(result?.length).toBeLessThanOrEqual(50 + defaultSuffix.length + 10)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('returns undefined when no valid message exists regardless of maxBytes', () => {
|
|
173
|
+
const messages: AgentMessage[] = [user('task')]
|
|
174
|
+
const result = lastAssistantText(messages, 100)
|
|
175
|
+
expect(result).toBeUndefined()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('does not truncate when maxBytes is undefined', () => {
|
|
179
|
+
const messages: AgentMessage[] = [
|
|
180
|
+
user('task'),
|
|
181
|
+
assistant({ text: 'a'.repeat(1000) }),
|
|
182
|
+
]
|
|
183
|
+
const result = lastAssistantText(messages)
|
|
184
|
+
expect(result).toBe('a'.repeat(1000))
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('lastMessageText', () => {
|
|
189
|
+
it('returns the text from the last assistant message', () => {
|
|
190
|
+
const messages: AgentMessage[] = [
|
|
191
|
+
user('task'),
|
|
192
|
+
assistant({ text: 'I will read it' }),
|
|
193
|
+
]
|
|
194
|
+
const result = lastMessageText(messages)
|
|
195
|
+
expect(result).toBe('I will read it')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('returns undefined when only a user message exists', () => {
|
|
199
|
+
const messages: AgentMessage[] = [user('task')]
|
|
200
|
+
const result = lastMessageText(messages)
|
|
201
|
+
expect(result).toBeUndefined()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('returns undefined when the last message is not assistant or toolResult', () => {
|
|
205
|
+
const messages: AgentMessage[] = [user('task'), user('follow-up')]
|
|
206
|
+
const result = lastMessageText(messages)
|
|
207
|
+
expect(result).toBeUndefined()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('returns tool result text when the last message is a toolResult with content', () => {
|
|
211
|
+
const messages: AgentMessage[] = [
|
|
212
|
+
user('task'),
|
|
213
|
+
assistant({ toolCall: 'read' }),
|
|
214
|
+
toolResultText('File content here'),
|
|
215
|
+
]
|
|
216
|
+
const result = lastMessageText(messages)
|
|
217
|
+
expect(result).toBe('read\nFile content here')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('returns undefined when toolResult has empty text content', () => {
|
|
221
|
+
const messages: AgentMessage[] = [
|
|
222
|
+
user('task'),
|
|
223
|
+
assistant({ toolCall: 'read' }),
|
|
224
|
+
toolResultText(''),
|
|
225
|
+
]
|
|
226
|
+
const result = lastMessageText(messages)
|
|
227
|
+
expect(result).toBeUndefined()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('returns tool result text when assistant only made tool calls and never spoke again', () => {
|
|
231
|
+
const messages: AgentMessage[] = [
|
|
232
|
+
user('task'),
|
|
233
|
+
assistant({ text: 'I will read it' }),
|
|
234
|
+
assistant({ toolCall: 'read' }),
|
|
235
|
+
toolResultText('Tool output'),
|
|
236
|
+
]
|
|
237
|
+
const result = lastMessageText(messages)
|
|
238
|
+
expect(result).toBe('read\nTool output')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('returns tool result text even when isError is true', () => {
|
|
242
|
+
const messages: AgentMessage[] = [
|
|
243
|
+
user('task'),
|
|
244
|
+
assistant({ toolCall: 'read' }),
|
|
245
|
+
toolResultText('Error: file not found', true),
|
|
246
|
+
]
|
|
247
|
+
const result = lastMessageText(messages)
|
|
248
|
+
expect(result).toBe('read\nError: file not found')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('returns tool result text when last message is toolResult, ignoring earlier assistant text', () => {
|
|
252
|
+
const messages: AgentMessage[] = [
|
|
253
|
+
assistant({ text: 'hello' }),
|
|
254
|
+
toolResultText('world'),
|
|
255
|
+
]
|
|
256
|
+
const result = lastMessageText(messages)
|
|
257
|
+
expect(result).toBe('read\nworld')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('returns assistant text when toolResult has empty content', () => {
|
|
261
|
+
const messages: AgentMessage[] = [
|
|
262
|
+
assistant({ text: 'only assistant' }),
|
|
263
|
+
toolResultText(''),
|
|
264
|
+
]
|
|
265
|
+
const result = lastMessageText(messages)
|
|
266
|
+
expect(result).toBe('only assistant')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('returns tool result text when toolResult is the only message', () => {
|
|
270
|
+
const messages: AgentMessage[] = [toolResultText('only tool')]
|
|
271
|
+
const result = lastMessageText(messages)
|
|
272
|
+
expect(result).toBe('read\nonly tool')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('returns assistant text when last message is assistant', () => {
|
|
276
|
+
const messages: AgentMessage[] = [assistant({ text: 'last is assistant' })]
|
|
277
|
+
const result = lastMessageText(messages)
|
|
278
|
+
expect(result).toBe('last is assistant')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('returns tool result text when assistant has no text but toolResult does', () => {
|
|
282
|
+
const messages: AgentMessage[] = [
|
|
283
|
+
assistant({}),
|
|
284
|
+
toolResultText('tool wins'),
|
|
285
|
+
]
|
|
286
|
+
const result = lastMessageText(messages)
|
|
287
|
+
expect(result).toBe('read\ntool wins')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('returns undefined for empty messages array', () => {
|
|
291
|
+
const messages: AgentMessage[] = []
|
|
292
|
+
const result = lastMessageText(messages)
|
|
293
|
+
expect(result).toBeUndefined()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('returns tool result text with formatted toolName and arguments when matching ToolCall exists', () => {
|
|
297
|
+
const messages: AgentMessage[] = [
|
|
298
|
+
user('task'),
|
|
299
|
+
assistant({ toolCall: 'read', toolCallArgs: { path: 'x' } }),
|
|
300
|
+
toolResultText('File content here'),
|
|
301
|
+
]
|
|
302
|
+
const result = lastMessageText(messages)
|
|
303
|
+
expect(result).toBe('read({"path":"x"})\nFile content here')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('returns previous assistant text when last assistant has no text', () => {
|
|
307
|
+
const messages: AgentMessage[] = [
|
|
308
|
+
user('task'),
|
|
309
|
+
assistant({ text: 'first assistant' }),
|
|
310
|
+
assistant({ toolCall: 'read' }),
|
|
311
|
+
]
|
|
312
|
+
const result = lastMessageText(messages)
|
|
313
|
+
expect(result).toBe('first assistant')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('returns undefined when no assistant has text', () => {
|
|
317
|
+
const messages: AgentMessage[] = [
|
|
318
|
+
user('task'),
|
|
319
|
+
assistant({ toolCall: 'read' }),
|
|
320
|
+
assistant({}),
|
|
321
|
+
]
|
|
322
|
+
const result = lastMessageText(messages)
|
|
323
|
+
expect(result).toBeUndefined()
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('lastMessageText with maxBytes', () => {
|
|
328
|
+
it('returns text unchanged when under byte limit', () => {
|
|
329
|
+
const messages: AgentMessage[] = [
|
|
330
|
+
user('task'),
|
|
331
|
+
assistant({ text: 'Short text' }),
|
|
332
|
+
]
|
|
333
|
+
const result = lastMessageText(messages, 100)
|
|
334
|
+
expect(result).toBe('Short text')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('truncates text with suffix when over byte limit', () => {
|
|
338
|
+
const messages: AgentMessage[] = [
|
|
339
|
+
user('task'),
|
|
340
|
+
assistant({ text: 'a'.repeat(1000) }),
|
|
341
|
+
]
|
|
342
|
+
const result = lastMessageText(messages, 50)
|
|
343
|
+
const defaultSuffix = '\n\n[Output truncated.]'
|
|
344
|
+
expect(result).toContain(defaultSuffix)
|
|
345
|
+
expect(result?.length).toBeLessThanOrEqual(50 + defaultSuffix.length + 10)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('returns undefined when no valid message exists regardless of maxBytes', () => {
|
|
349
|
+
const messages: AgentMessage[] = [user('task')]
|
|
350
|
+
const result = lastMessageText(messages, 100)
|
|
351
|
+
expect(result).toBeUndefined()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('does not truncate when maxBytes is undefined', () => {
|
|
355
|
+
const messages: AgentMessage[] = [
|
|
356
|
+
user('task'),
|
|
357
|
+
assistant({ text: 'a'.repeat(1000) }),
|
|
358
|
+
]
|
|
359
|
+
const result = lastMessageText(messages)
|
|
360
|
+
expect(result).toBe('a'.repeat(1000))
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe('messageText', () => {
|
|
365
|
+
it('returns empty string for undefined message', () => {
|
|
366
|
+
expect(messageText(undefined)).toBe('')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('returns text from assistant message', () => {
|
|
370
|
+
const msg = assistant({ text: 'Hello world' })
|
|
371
|
+
expect(messageText(msg)).toBe('Hello world')
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('returns tool result text without prefix', () => {
|
|
375
|
+
const msg = toolResultText('output')
|
|
376
|
+
expect(messageText(msg)).toBe('output')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('returns empty string for empty tool result text', () => {
|
|
380
|
+
const msg = toolResultText('')
|
|
381
|
+
expect(messageText(msg)).toBe('')
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('returns empty string for message with only tool calls', () => {
|
|
385
|
+
const msg = assistant({ toolCall: 'read' })
|
|
386
|
+
expect(messageText(msg)).toBe('')
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
describe('formatToolNameWithArgs', () => {
|
|
391
|
+
it('returns toolName with arguments when matching ToolCall has arguments', () => {
|
|
392
|
+
const messages: AgentMessage[] = [
|
|
393
|
+
user('task'),
|
|
394
|
+
assistant({ toolCall: 'read', toolCallArgs: { path: 'file.txt' } }),
|
|
395
|
+
]
|
|
396
|
+
const toolResult = toolResultText('content')
|
|
397
|
+
const result = formatToolNameWithArgs(toolResult, messages)
|
|
398
|
+
expect(result).toBe('read({"path":"file.txt"})')
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('returns toolName without parens when matching ToolCall has empty arguments', () => {
|
|
402
|
+
const messages: AgentMessage[] = [
|
|
403
|
+
user('task'),
|
|
404
|
+
assistant({ toolCall: 'read', toolCallArgs: {} }),
|
|
405
|
+
]
|
|
406
|
+
const toolResult = toolResultText('content')
|
|
407
|
+
const result = formatToolNameWithArgs(toolResult, messages)
|
|
408
|
+
expect(result).toBe('read')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('returns toolName without parens when no matching ToolCall exists', () => {
|
|
412
|
+
const messages: AgentMessage[] = [user('task')]
|
|
413
|
+
const toolResult = toolResultText('content')
|
|
414
|
+
const result = formatToolNameWithArgs(toolResult, messages)
|
|
415
|
+
expect(result).toBe('read')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('returns toolName with complex arguments when matching ToolCall has complex arguments', () => {
|
|
419
|
+
const messages: AgentMessage[] = [
|
|
420
|
+
user('task'),
|
|
421
|
+
assistant({
|
|
422
|
+
toolCall: 'write',
|
|
423
|
+
toolCallArgs: {
|
|
424
|
+
path: 'file.txt',
|
|
425
|
+
content: 'hello world',
|
|
426
|
+
overwrite: true,
|
|
427
|
+
},
|
|
428
|
+
}),
|
|
429
|
+
]
|
|
430
|
+
const toolResult = toolResultMessage('call-123', 'write', 'done')
|
|
431
|
+
const result = formatToolNameWithArgs(toolResult, messages)
|
|
432
|
+
expect(result).toBe(
|
|
433
|
+
'write({"path":"file.txt","content":"hello world","overwrite":true})',
|
|
434
|
+
)
|
|
435
|
+
})
|
|
436
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core'
|
|
2
|
+
import type {
|
|
3
|
+
AssistantMessage,
|
|
4
|
+
TextContent,
|
|
5
|
+
ThinkingContent,
|
|
6
|
+
ToolCall,
|
|
7
|
+
ToolResultMessage,
|
|
8
|
+
} from '@earendil-works/pi-ai'
|
|
9
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
10
|
+
|
|
11
|
+
import { truncateToBytes } from './format.js'
|
|
12
|
+
import { PLUGIN_KEY } from './state.ts'
|
|
13
|
+
|
|
14
|
+
function isAssistantMessage(
|
|
15
|
+
message: AgentMessage | undefined,
|
|
16
|
+
): message is AssistantMessage {
|
|
17
|
+
return message?.role === 'assistant' && Array.isArray(message.content)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isToolResultMessage(
|
|
21
|
+
message: AgentMessage | undefined,
|
|
22
|
+
): message is ToolResultMessage {
|
|
23
|
+
return (
|
|
24
|
+
message?.role === 'toolResult' &&
|
|
25
|
+
'content' in message &&
|
|
26
|
+
Array.isArray(message.content)
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isToolCall(
|
|
31
|
+
block: TextContent | ThinkingContent | ToolCall,
|
|
32
|
+
): block is ToolCall {
|
|
33
|
+
return block.type === 'toolCall'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findToolCall(
|
|
37
|
+
toolCallId: string,
|
|
38
|
+
messages: readonly AgentMessage[],
|
|
39
|
+
): ToolCall | undefined {
|
|
40
|
+
for (const message of messages) {
|
|
41
|
+
if (isAssistantMessage(message)) {
|
|
42
|
+
for (const block of message.content) {
|
|
43
|
+
if (isToolCall(block) && block.id === toolCallId) {
|
|
44
|
+
return block
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatToolNameWithArgs(
|
|
53
|
+
message: ToolResultMessage,
|
|
54
|
+
messages: readonly AgentMessage[],
|
|
55
|
+
): string {
|
|
56
|
+
const toolCall = findToolCall(message.toolCallId, messages)
|
|
57
|
+
if (toolCall && Object.keys(toolCall.arguments).length > 0) {
|
|
58
|
+
return `${message.toolName}(${JSON.stringify(toolCall.arguments)})`
|
|
59
|
+
}
|
|
60
|
+
return message.toolName
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function messageText(message: AgentMessage | undefined): string {
|
|
64
|
+
if (!message) return ''
|
|
65
|
+
if (isAssistantMessage(message) || isToolResultMessage(message)) {
|
|
66
|
+
const text = message.content
|
|
67
|
+
.filter((block): block is TextContent => block.type === 'text')
|
|
68
|
+
.map((block) => block.text)
|
|
69
|
+
.join('\n')
|
|
70
|
+
.trim()
|
|
71
|
+
return text
|
|
72
|
+
}
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function lastAssistantText(
|
|
77
|
+
messages: readonly AgentMessage[],
|
|
78
|
+
maxBytes?: number,
|
|
79
|
+
): string | undefined {
|
|
80
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
81
|
+
const message = messages[i]
|
|
82
|
+
if (isAssistantMessage(message)) {
|
|
83
|
+
const text = messageText(message)
|
|
84
|
+
if (!text) continue
|
|
85
|
+
const result =
|
|
86
|
+
maxBytes !== undefined ? truncateToBytes(text, maxBytes) : text
|
|
87
|
+
return result || undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function lastMessageText(
|
|
94
|
+
messages: readonly AgentMessage[],
|
|
95
|
+
maxBytes?: number,
|
|
96
|
+
): string | undefined {
|
|
97
|
+
if (messages.length === 0) return undefined
|
|
98
|
+
|
|
99
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
100
|
+
const message = messages[i]
|
|
101
|
+
|
|
102
|
+
if (isAssistantMessage(message)) {
|
|
103
|
+
const text = messageText(message)
|
|
104
|
+
if (!text) continue
|
|
105
|
+
return maxBytes !== undefined ? truncateToBytes(text, maxBytes) : text
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (isToolResultMessage(message)) {
|
|
109
|
+
const text = messageText(message)
|
|
110
|
+
if (!text) continue
|
|
111
|
+
const name = formatToolNameWithArgs(message, messages)
|
|
112
|
+
const composed = `${name}\n${text}`
|
|
113
|
+
return maxBytes !== undefined
|
|
114
|
+
? truncateToBytes(composed, maxBytes)
|
|
115
|
+
: composed
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function notifyAgentMessage(pi: ExtensionAPI, content: string) {
|
|
123
|
+
pi.sendMessage(
|
|
124
|
+
{
|
|
125
|
+
customType: PLUGIN_KEY,
|
|
126
|
+
content,
|
|
127
|
+
display: true,
|
|
128
|
+
},
|
|
129
|
+
{ deliverAs: 'steer', triggerTurn: true },
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { parseModelRef } from './model-ref.js'
|
|
4
|
+
|
|
5
|
+
describe('parseModelRef', () => {
|
|
6
|
+
it('parses valid provider/id format', () => {
|
|
7
|
+
expect(parseModelRef('openai/gpt-4')).toEqual({
|
|
8
|
+
provider: 'openai',
|
|
9
|
+
id: 'gpt-4',
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('parses provider/id with dashes in id', () => {
|
|
14
|
+
expect(parseModelRef('anthropic/claude-3-5-sonnet')).toEqual({
|
|
15
|
+
provider: 'anthropic',
|
|
16
|
+
id: 'claude-3-5-sonnet',
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns undefined for missing id', () => {
|
|
21
|
+
expect(parseModelRef('openai/')).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('returns undefined for missing provider', () => {
|
|
25
|
+
expect(parseModelRef('/gpt-4')).toBeUndefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns undefined for no slashes', () => {
|
|
29
|
+
expect(parseModelRef('gpt-4')).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns undefined for empty string', () => {
|
|
33
|
+
expect(parseModelRef('')).toBeUndefined()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('parses provider with underscores', () => {
|
|
37
|
+
expect(parseModelRef('my_provider/model-name')).toEqual({
|
|
38
|
+
provider: 'my_provider',
|
|
39
|
+
id: 'model-name',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
2
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import { MODEL_DEFAULT } from '../models-config/subagent-model-constants.js'
|
|
5
|
+
|
|
6
|
+
export interface ModelOption {
|
|
7
|
+
key: string
|
|
8
|
+
text: string
|
|
9
|
+
provider: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function modelRefOf(model: Model<Api>): string {
|
|
13
|
+
return `${model.provider}/${model.id}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function modelOptionOf(model: Model<Api>): ModelOption {
|
|
17
|
+
return {
|
|
18
|
+
key: modelRefOf(model),
|
|
19
|
+
text: `${model.provider}/${model.name}`,
|
|
20
|
+
provider: model.provider,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseModelRef(
|
|
25
|
+
ref: string,
|
|
26
|
+
): { provider: string; id: string } | undefined {
|
|
27
|
+
const [provider, id] = ref.split('/')
|
|
28
|
+
if (!provider || !id) return undefined
|
|
29
|
+
return { provider, id }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveModelRef(
|
|
33
|
+
ctx: ExtensionContext,
|
|
34
|
+
ref: readonly (string | undefined)[],
|
|
35
|
+
): Model<Api> | undefined {
|
|
36
|
+
for (const r of ref) {
|
|
37
|
+
if (!r || r === MODEL_DEFAULT) continue
|
|
38
|
+
const parsed = parseModelRef(r)
|
|
39
|
+
if (!parsed) continue
|
|
40
|
+
const model = ctx.modelRegistry.find(parsed.provider, parsed.id)
|
|
41
|
+
if (model) return model
|
|
42
|
+
}
|
|
43
|
+
return undefined
|
|
44
|
+
}
|