@tduyng/pi-subagent 0.0.0-stage → 0.0.2
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/LICENSE +178 -0
- package/README.md +80 -2
- package/package.json +51 -4
- package/src/bulk-read.ts +147 -0
- package/src/lib/child-runtime.ts +18 -0
- package/src/log-reducer.ts +242 -0
- package/src/redact.ts +17 -0
- package/src/subagent/agent.ts +112 -0
- package/src/subagent/drive.ts +187 -0
- package/src/subagent/durable.ts +271 -0
- package/src/subagent/format.ts +241 -0
- package/src/subagent/guards/cloak.ts +396 -0
- package/src/subagent/guards/git-interceptor.ts +73 -0
- package/src/subagent/helpers.ts +212 -0
- package/src/subagent/index.ts +853 -0
- package/src/subagent/recover.ts +268 -0
- package/src/subagent/session.ts +87 -0
- package/src/subagent/state.ts +63 -0
- package/src/subagent/structured.ts +202 -0
- package/src/subagent/types.ts +109 -0
- package/src/subagent/viewer.ts +184 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { appendFileSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
4
|
+
import { mkdir } from 'node:fs/promises'
|
|
5
|
+
import * as path from 'node:path'
|
|
6
|
+
|
|
7
|
+
import type { JsonValue, Model } from '@earendil-works/pi-ai'
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_MAX_BYTES,
|
|
10
|
+
DEFAULT_MAX_LINES,
|
|
11
|
+
DefaultResourceLoader,
|
|
12
|
+
getAgentDir,
|
|
13
|
+
truncateHead,
|
|
14
|
+
type ExtensionAPI,
|
|
15
|
+
type ExtensionContext
|
|
16
|
+
} from '@earendil-works/pi-coding-agent'
|
|
17
|
+
import { Text, isKeyRelease, matchesKey } from '@earendil-works/pi-tui'
|
|
18
|
+
import { type Infer, type JSONSchema, Validator } from 'ata-validator'
|
|
19
|
+
import { t } from 'ata-validator/t'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
import { createChildModelRuntime } from '../lib/child-runtime.js'
|
|
23
|
+
import { loadAgents, parseModelName, toThinking } from './agent.js'
|
|
24
|
+
import { drivePrompt } from './drive.js'
|
|
25
|
+
import { claimRun, findRunForTask, saveRun, syncRecord, type DurableRun, type DurableTask } from './durable.js'
|
|
26
|
+
import { recoverRuns, resumeTask } from './recover.js'
|
|
27
|
+
import { createChildSession } from './session.js'
|
|
28
|
+
import { normalizeJsonSchema } from './structured.js'
|
|
29
|
+
import {
|
|
30
|
+
acquirePermit,
|
|
31
|
+
changedPaths,
|
|
32
|
+
createWorktree,
|
|
33
|
+
findDuplicateTask,
|
|
34
|
+
gitStatus,
|
|
35
|
+
failureReason,
|
|
36
|
+
releasePermit,
|
|
37
|
+
resolveMaxTurns,
|
|
38
|
+
resolveTimeoutMs,
|
|
39
|
+
salvageFromMessages,
|
|
40
|
+
salvageOutput,
|
|
41
|
+
validateCwd,
|
|
42
|
+
worktreeBranch,
|
|
43
|
+
MIN_TIMEOUT_MS
|
|
44
|
+
} from './helpers.js'
|
|
45
|
+
import {
|
|
46
|
+
countByStatus,
|
|
47
|
+
extractForkText,
|
|
48
|
+
findLastAssistant,
|
|
49
|
+
formatDuration,
|
|
50
|
+
partialText,
|
|
51
|
+
resultText,
|
|
52
|
+
statusIcon,
|
|
53
|
+
taskHeadline,
|
|
54
|
+
toLabel
|
|
55
|
+
} from './format.js'
|
|
56
|
+
import { dismissTask, liveSessions, nextExpiryAt, registry, roster } from './state.js'
|
|
57
|
+
import {
|
|
58
|
+
DEFAULT_THINKING,
|
|
59
|
+
EMIT_INTERVAL_MS,
|
|
60
|
+
MAX_CONCURRENT,
|
|
61
|
+
MAX_TASKS,
|
|
62
|
+
RUNS_DIR,
|
|
63
|
+
WIDGET_KEY,
|
|
64
|
+
type RunDetails,
|
|
65
|
+
type RunStatus,
|
|
66
|
+
type TaskDetails,
|
|
67
|
+
type TaskSpec,
|
|
68
|
+
type ThinkingLevel
|
|
69
|
+
} from './types.js'
|
|
70
|
+
import { SubagentViewer, widgetLines } from './viewer.js'
|
|
71
|
+
|
|
72
|
+
export { parseFrontmatter, loadAgentsFrom, parseModelName } from './agent.js'
|
|
73
|
+
|
|
74
|
+
const MAX_FORK_CONTEXT_CHARS = 120_000
|
|
75
|
+
const RUN_SAVE_INTERVAL_MS = 5_000
|
|
76
|
+
const RUN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
|
77
|
+
|
|
78
|
+
/** The child bills to the parent's session, and `/cost` reads this line to say so. */
|
|
79
|
+
function recordOverhead(
|
|
80
|
+
sessionId: string,
|
|
81
|
+
purpose: string,
|
|
82
|
+
usage: { input: number; output: number; cost: number }
|
|
83
|
+
): void {
|
|
84
|
+
try {
|
|
85
|
+
appendFileSync(
|
|
86
|
+
path.join(getAgentDir(), 'overhead.jsonl'),
|
|
87
|
+
`${JSON.stringify({
|
|
88
|
+
at: Date.now(),
|
|
89
|
+
extension: 'subagent',
|
|
90
|
+
sessionId,
|
|
91
|
+
purpose,
|
|
92
|
+
input: usage.input,
|
|
93
|
+
output: usage.output,
|
|
94
|
+
costUsd: usage.cost
|
|
95
|
+
})}\n`,
|
|
96
|
+
'utf8'
|
|
97
|
+
)
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function removeWorktree(worktree: string): void {
|
|
102
|
+
try {
|
|
103
|
+
const repo = path.dirname(
|
|
104
|
+
execFileSync('git', ['-C', worktree, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
105
|
+
encoding: 'utf8'
|
|
106
|
+
}).trim()
|
|
107
|
+
)
|
|
108
|
+
execFileSync('git', ['-C', repo, 'worktree', 'remove', '--force', worktree], { stdio: 'ignore' })
|
|
109
|
+
execFileSync('git', ['-C', repo, 'branch', '-d', worktreeBranch(worktree)], { stdio: 'ignore' })
|
|
110
|
+
} catch {
|
|
111
|
+
rmSync(worktree, { recursive: true, force: true })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function removeStaleRunDirectories(currentSessionId: string): void {
|
|
116
|
+
const root = path.join(getAgentDir(), RUNS_DIR)
|
|
117
|
+
let sessions: string[]
|
|
118
|
+
try {
|
|
119
|
+
sessions = readdirSync(root)
|
|
120
|
+
} catch {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
const now = Date.now()
|
|
124
|
+
for (const sessionId of sessions) {
|
|
125
|
+
if (sessionId === currentSessionId) continue
|
|
126
|
+
const sessionDir = path.join(root, sessionId)
|
|
127
|
+
try {
|
|
128
|
+
if (now - statSync(sessionDir).mtimeMs <= RUN_RETENTION_MS) continue
|
|
129
|
+
for (const task of readdirSync(sessionDir)) {
|
|
130
|
+
const worktree = path.join(sessionDir, task, 'worktree')
|
|
131
|
+
if (statSync(worktree, { throwIfNoEntry: false })) removeWorktree(worktree)
|
|
132
|
+
}
|
|
133
|
+
rmSync(sessionDir, { recursive: true, force: true })
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ChildConfig {
|
|
139
|
+
model?: string
|
|
140
|
+
thinking?: ThinkingLevel
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const ChildConfigFile = t.object({ model: t.optional(t.string()), thinking: t.optional(t.string()) })
|
|
144
|
+
const isChildConfigFile = new Validator(ChildConfigFile).isValidObject
|
|
145
|
+
|
|
146
|
+
function readChildConfig(file: string): ChildConfig {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
149
|
+
if (!isChildConfigFile(parsed)) return {}
|
|
150
|
+
return { model: parsed.model?.trim() || undefined, thinking: toThinking(parsed.thinking) }
|
|
151
|
+
} catch {
|
|
152
|
+
return {}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function defaultChildConfig(agentDir = getAgentDir(), projectDir = process.cwd()): ChildConfig {
|
|
157
|
+
const agent = readChildConfig(path.join(agentDir, 'subagent.json'))
|
|
158
|
+
const project = readChildConfig(path.join(projectDir, '.pi', 'subagent.json'))
|
|
159
|
+
return { model: project.model ?? agent.model, thinking: project.thinking ?? agent.thinking }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function resolveChildThinking(
|
|
163
|
+
requested: ThinkingLevel | undefined,
|
|
164
|
+
profile: ThinkingLevel | undefined,
|
|
165
|
+
configured: ThinkingLevel | undefined
|
|
166
|
+
): ThinkingLevel {
|
|
167
|
+
return requested ?? profile ?? configured ?? DEFAULT_THINKING
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function resolveChildModelName(
|
|
171
|
+
requested: string | undefined,
|
|
172
|
+
profile: string | undefined,
|
|
173
|
+
configured: string | undefined
|
|
174
|
+
): string | undefined {
|
|
175
|
+
return profile || configured || requested?.trim() || undefined
|
|
176
|
+
}
|
|
177
|
+
export { toLabel, extractForkText } from './format.js'
|
|
178
|
+
export {
|
|
179
|
+
changedPaths,
|
|
180
|
+
createWorktree,
|
|
181
|
+
failureReason,
|
|
182
|
+
resolveMaxTurns,
|
|
183
|
+
resolveTimeoutMs,
|
|
184
|
+
worktreeBranch,
|
|
185
|
+
wrapUpMessage
|
|
186
|
+
} from './helpers.js'
|
|
187
|
+
|
|
188
|
+
export type { AgentProfile, TaskDetails, RunDetails, ThinkingLevel } from './types.js'
|
|
189
|
+
|
|
190
|
+
export type LoaderOptions = ConstructorParameters<typeof DefaultResourceLoader>[0]
|
|
191
|
+
|
|
192
|
+
const TASK_FIELDS = {
|
|
193
|
+
task: t.string({ description: 'Instructions.' }),
|
|
194
|
+
agent: t.optional(t.string({ description: 'Profile; omit for scout.' })),
|
|
195
|
+
cwd: t.optional(t.string()),
|
|
196
|
+
isolation: t.optional(t.string({ enum: ['worktree'] })),
|
|
197
|
+
schema: t.optional({
|
|
198
|
+
...t.union([t.string(), t.object({}, { additionalProperties: true })]),
|
|
199
|
+
description: 'Final JSON Schema; call structured_output once.'
|
|
200
|
+
}),
|
|
201
|
+
context: t.optional(t.string({ enum: ['fork'] })),
|
|
202
|
+
resume: t.optional(t.string({ description: 'Resume task id or all.' }))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function undescribed<T extends JSONSchema>(schema: T): T {
|
|
206
|
+
const clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(schema))
|
|
207
|
+
delete (clone as { description?: string }).description
|
|
208
|
+
return clone as T
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export const TASK_ITEM_FIELDS = Object.fromEntries(
|
|
212
|
+
Object.entries(TASK_FIELDS).map(([name, schema]) => [name, undescribed(schema)])
|
|
213
|
+
) as typeof TASK_FIELDS
|
|
214
|
+
|
|
215
|
+
const subagentParams = t.object({
|
|
216
|
+
...TASK_FIELDS,
|
|
217
|
+
task: t.optional(TASK_FIELDS.task),
|
|
218
|
+
tasks: t.optional(
|
|
219
|
+
t.array(t.object(TASK_ITEM_FIELDS), {
|
|
220
|
+
description: `Run up to ${MAX_TASKS} children; same fields.`
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
type SubagentParams = Infer<typeof subagentParams>
|
|
225
|
+
|
|
226
|
+
export default function subagentExtension(pi: ExtensionAPI): void {
|
|
227
|
+
let mountedCtx: ExtensionContext | undefined
|
|
228
|
+
let unregisterInput: (() => void) | undefined
|
|
229
|
+
let highlightIndex: number | null = null
|
|
230
|
+
let viewerOpen = false
|
|
231
|
+
let sweepTimer: ReturnType<typeof setTimeout> | undefined
|
|
232
|
+
|
|
233
|
+
function scheduleSweep(): void {
|
|
234
|
+
if (sweepTimer) clearTimeout(sweepTimer)
|
|
235
|
+
sweepTimer = undefined
|
|
236
|
+
const due = nextExpiryAt()
|
|
237
|
+
if (due === undefined) return
|
|
238
|
+
sweepTimer = setTimeout(
|
|
239
|
+
() => {
|
|
240
|
+
sweepTimer = undefined
|
|
241
|
+
syncWidget()
|
|
242
|
+
},
|
|
243
|
+
Math.max(1_000, due - Date.now())
|
|
244
|
+
)
|
|
245
|
+
sweepTimer.unref()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function syncWidget(): void {
|
|
249
|
+
const ctx = mountedCtx
|
|
250
|
+
if (!ctx?.hasUI) return
|
|
251
|
+
const items = roster()
|
|
252
|
+
if (highlightIndex !== null)
|
|
253
|
+
highlightIndex = items.length === 0 ? null : Math.min(highlightIndex, items.length - 1)
|
|
254
|
+
ctx.ui.setWidget(WIDGET_KEY, items.length === 0 ? undefined : widgetLines(ctx.ui.theme, highlightIndex))
|
|
255
|
+
scheduleSweep()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function openViewer(ctx: ExtensionContext, task: TaskDetails): Promise<void> {
|
|
259
|
+
const session = liveSessions.get(task.id)
|
|
260
|
+
if (!session) {
|
|
261
|
+
ctx.ui.notify('That agent session is no longer available.', 'warning')
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
viewerOpen = true
|
|
265
|
+
try {
|
|
266
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => new SubagentViewer(tui, theme, session, task, done))
|
|
267
|
+
} catch (error) {
|
|
268
|
+
try {
|
|
269
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error')
|
|
270
|
+
} catch {}
|
|
271
|
+
} finally {
|
|
272
|
+
viewerOpen = false
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function createNavHandler(ctx: ExtensionContext) {
|
|
277
|
+
return (data: string): { consume: boolean } | undefined => {
|
|
278
|
+
if (isKeyRelease(data) || viewerOpen) return undefined
|
|
279
|
+
const items = roster()
|
|
280
|
+
|
|
281
|
+
if (highlightIndex === null) {
|
|
282
|
+
if (matchesKey(data, 'down') && items.length > 0 && ctx.ui.getEditorText() === '') {
|
|
283
|
+
highlightIndex = 0
|
|
284
|
+
syncWidget()
|
|
285
|
+
return { consume: true }
|
|
286
|
+
}
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (matchesKey(data, 'down')) {
|
|
291
|
+
highlightIndex = Math.min(highlightIndex + 1, items.length - 1)
|
|
292
|
+
syncWidget()
|
|
293
|
+
return { consume: true }
|
|
294
|
+
}
|
|
295
|
+
if (matchesKey(data, 'up')) {
|
|
296
|
+
highlightIndex = Math.max(highlightIndex - 1, 0)
|
|
297
|
+
syncWidget()
|
|
298
|
+
return { consume: true }
|
|
299
|
+
}
|
|
300
|
+
if (matchesKey(data, 'escape')) {
|
|
301
|
+
highlightIndex = null
|
|
302
|
+
syncWidget()
|
|
303
|
+
return { consume: true }
|
|
304
|
+
}
|
|
305
|
+
if (matchesKey(data, 'd') || matchesKey(data, 'x')) {
|
|
306
|
+
const task = items[highlightIndex]
|
|
307
|
+
if (task) dismissTask(task.id)
|
|
308
|
+
syncWidget()
|
|
309
|
+
return { consume: true }
|
|
310
|
+
}
|
|
311
|
+
if (matchesKey(data, 'enter')) {
|
|
312
|
+
const task = items[highlightIndex]
|
|
313
|
+
highlightIndex = null
|
|
314
|
+
syncWidget()
|
|
315
|
+
if (task) void openViewer(ctx, task)
|
|
316
|
+
return { consume: true }
|
|
317
|
+
}
|
|
318
|
+
highlightIndex = null
|
|
319
|
+
syncWidget()
|
|
320
|
+
return undefined
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
325
|
+
setTimeout(() => removeStaleRunDirectories(ctx.sessionManager.getSessionId()), 0).unref?.()
|
|
326
|
+
if (ctx.hasUI) {
|
|
327
|
+
mountedCtx = ctx
|
|
328
|
+
unregisterInput ??= ctx.ui.onTerminalInput(createNavHandler(ctx))
|
|
329
|
+
syncWidget()
|
|
330
|
+
}
|
|
331
|
+
void recoverRuns({ pi, ctx, onEmit: syncWidget }).catch(() => {})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
pi.on('session_shutdown', async () => {
|
|
335
|
+
if (sweepTimer) clearTimeout(sweepTimer)
|
|
336
|
+
sweepTimer = undefined
|
|
337
|
+
unregisterInput?.()
|
|
338
|
+
unregisterInput = undefined
|
|
339
|
+
mountedCtx?.ui.setWidget(WIDGET_KEY, undefined)
|
|
340
|
+
mountedCtx = undefined
|
|
341
|
+
for (const session of liveSessions.values()) session.dispose()
|
|
342
|
+
liveSessions.clear()
|
|
343
|
+
registry.length = 0
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
pi.registerTool({
|
|
347
|
+
name: 'subagent',
|
|
348
|
+
label: 'Subagent',
|
|
349
|
+
description:
|
|
350
|
+
'Delegate read-only analysis to a child Pi session, on a cheap model: search, reading code and docs, research. ' +
|
|
351
|
+
'Keep implementation, debugging, builds and lint in the parent. ' +
|
|
352
|
+
'No profile has web access, so run web_search and web_fetch yourself in the parent; hand a scout text already saved to disk. ' +
|
|
353
|
+
'Use `schema` for structured output, `resume` for failed children, including ones kept from a crashed session. ' +
|
|
354
|
+
`Up to ${MAX_TASKS} tasks, ${MAX_CONCURRENT} concurrent.`,
|
|
355
|
+
promptSnippet: 'delegate bulk reads, parallel or worktree-isolated work to a child session',
|
|
356
|
+
exposure: 'direct',
|
|
357
|
+
namespace: {
|
|
358
|
+
name: 'subagent',
|
|
359
|
+
description: 'Delegation to a child Pi session, by profile, with worktree isolation.'
|
|
360
|
+
},
|
|
361
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
362
|
+
executionMode: 'parallel',
|
|
363
|
+
parameters: subagentParams,
|
|
364
|
+
|
|
365
|
+
async execute(_toolCallId, params: SubagentParams, signal, onUpdate, ctx) {
|
|
366
|
+
const resumeKey = (params.resume as string | undefined)?.trim() || undefined
|
|
367
|
+
const inputs = resumeKey
|
|
368
|
+
? []
|
|
369
|
+
: params.tasks?.length
|
|
370
|
+
? params.tasks
|
|
371
|
+
: params.task
|
|
372
|
+
? [{ ...params, task: params.task }]
|
|
373
|
+
: []
|
|
374
|
+
if (inputs.length === 0 && !resumeKey) throw new Error('Pass "task", "tasks", or "resume".')
|
|
375
|
+
if (!resumeKey && inputs.length > MAX_TASKS)
|
|
376
|
+
throw new Error(`At most ${MAX_TASKS} tasks per subagent call.`)
|
|
377
|
+
const duplicate = findDuplicateTask(inputs.map((input) => input.task))
|
|
378
|
+
if (duplicate) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`Tasks ${duplicate.first + 1} and ${duplicate.second + 1} are the same request. Drop one, or give each a distinct part of the job.`
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!ctx.model)
|
|
385
|
+
throw new Error('No model is active in this session, so there is nothing to run children with.')
|
|
386
|
+
const activeModel = ctx.model
|
|
387
|
+
|
|
388
|
+
const agents = loadAgents(ctx.cwd)
|
|
389
|
+
|
|
390
|
+
const runId = randomUUID()
|
|
391
|
+
const runDir = path.join(getAgentDir(), RUNS_DIR, ctx.sessionManager.getSessionId(), runId)
|
|
392
|
+
const startedAt = Date.now()
|
|
393
|
+
|
|
394
|
+
const config = defaultChildConfig()
|
|
395
|
+
const specs: TaskSpec[] = inputs.map((raw, index) => {
|
|
396
|
+
const input = raw as typeof raw & {
|
|
397
|
+
label?: string
|
|
398
|
+
thinking?: ThinkingLevel
|
|
399
|
+
maxTurns?: number
|
|
400
|
+
model?: string
|
|
401
|
+
timeoutMs?: number
|
|
402
|
+
}
|
|
403
|
+
if (!input.task.trim()) throw new Error(`Task ${index + 1} must not be empty.`)
|
|
404
|
+
const agentName = input.agent?.trim() || 'scout'
|
|
405
|
+
const agent = agents.get(agentName)
|
|
406
|
+
if (!agent) {
|
|
407
|
+
throw new Error(`Unknown agent "${agentName}". Known: ${[...agents.keys()].join(', ') || 'none'}`)
|
|
408
|
+
}
|
|
409
|
+
const thinking = resolveChildThinking(input.thinking, agent?.thinking, config.thinking)
|
|
410
|
+
let childModel: Model<any> | undefined
|
|
411
|
+
const modelName = resolveChildModelName(input.model, agent?.model, config.model)
|
|
412
|
+
if (modelName) {
|
|
413
|
+
const parsed = parseModelName(modelName)
|
|
414
|
+
if (!parsed) throw new Error(`Invalid model "${modelName}"; use provider/model-id.`)
|
|
415
|
+
childModel = ctx.modelRegistry.find(parsed.provider, parsed.id)
|
|
416
|
+
if (!childModel)
|
|
417
|
+
throw new Error(`Model not found: ${modelName}. Check /model for registered providers.`)
|
|
418
|
+
}
|
|
419
|
+
const timeoutMs =
|
|
420
|
+
resolveTimeoutMs(input.timeoutMs) ?? resolveTimeoutMs(agent?.maxExecutionTimeMs) ?? MIN_TIMEOUT_MS
|
|
421
|
+
return {
|
|
422
|
+
index,
|
|
423
|
+
id: `${runId}:${index}`,
|
|
424
|
+
label: input.label?.trim()
|
|
425
|
+
? toLabel(input.label, index)
|
|
426
|
+
: toLabel(agent?.name ?? input.task, index),
|
|
427
|
+
task: input.task,
|
|
428
|
+
agent,
|
|
429
|
+
cwd: path.resolve(ctx.cwd, input.cwd?.trim() || '.'),
|
|
430
|
+
isolation: input.isolation === 'worktree' ? 'worktree' : undefined,
|
|
431
|
+
dir: path.join(runDir, `task-${index + 1}`),
|
|
432
|
+
thinking,
|
|
433
|
+
model: childModel,
|
|
434
|
+
timeoutMs,
|
|
435
|
+
maxTurns: resolveMaxTurns(input.maxTurns as number | undefined, agent?.maxTurns),
|
|
436
|
+
forkContext: input.context === 'fork',
|
|
437
|
+
schema: normalizeJsonSchema(input.schema)
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const writers = specs.filter((spec) =>
|
|
442
|
+
spec.agent?.tools.some((tool) => tool === 'write' || tool === 'edit')
|
|
443
|
+
)
|
|
444
|
+
if (writers.length > 1) {
|
|
445
|
+
throw new Error('Parallel write-capable subagents are disabled. Run one writer at a time.')
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const tasks: TaskDetails[] = specs.map((spec) => ({
|
|
449
|
+
id: spec.id,
|
|
450
|
+
index: spec.index,
|
|
451
|
+
label: spec.label,
|
|
452
|
+
agent: spec.agent?.name,
|
|
453
|
+
status: 'queued',
|
|
454
|
+
cwd: spec.cwd,
|
|
455
|
+
provider: (spec.model ?? activeModel).provider,
|
|
456
|
+
model: (spec.model ?? activeModel).id,
|
|
457
|
+
thinking: spec.thinking,
|
|
458
|
+
color: spec.agent?.color,
|
|
459
|
+
forkContext: spec.forkContext,
|
|
460
|
+
hasSchema: spec.schema !== undefined,
|
|
461
|
+
maxTurns: spec.maxTurns,
|
|
462
|
+
partial: undefined,
|
|
463
|
+
structured: undefined
|
|
464
|
+
}))
|
|
465
|
+
registry.push(...tasks)
|
|
466
|
+
|
|
467
|
+
const snapshot = (status: RunStatus, finishedAt?: number): RunDetails => ({
|
|
468
|
+
status,
|
|
469
|
+
runId,
|
|
470
|
+
startedAt,
|
|
471
|
+
finishedAt,
|
|
472
|
+
tasks: tasks.map((task) => ({ ...task }))
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
let durableRun: DurableRun | undefined
|
|
476
|
+
let lastRunSave = 0
|
|
477
|
+
let lastEmit = 0
|
|
478
|
+
const emit = (force = false): void => {
|
|
479
|
+
const now = Date.now()
|
|
480
|
+
if (!force && now - lastEmit < EMIT_INTERVAL_MS) return
|
|
481
|
+
lastEmit = now
|
|
482
|
+
if (durableRun && now - lastRunSave >= RUN_SAVE_INTERVAL_MS) {
|
|
483
|
+
lastRunSave = now
|
|
484
|
+
saveRun(durableRun)
|
|
485
|
+
}
|
|
486
|
+
onUpdate?.({
|
|
487
|
+
content: [{ type: 'text', text: partialText(snapshot('running')) }],
|
|
488
|
+
details: snapshot('running')
|
|
489
|
+
})
|
|
490
|
+
syncWidget()
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const spec of specs) await validateCwd(spec.cwd)
|
|
494
|
+
emit(true)
|
|
495
|
+
|
|
496
|
+
const modelRuntime = await createChildModelRuntime(ctx)
|
|
497
|
+
|
|
498
|
+
const runTask = async (spec: TaskSpec, detail: TaskDetails, record: DurableTask): Promise<void> => {
|
|
499
|
+
let acquired = false
|
|
500
|
+
try {
|
|
501
|
+
await acquirePermit(signal)
|
|
502
|
+
acquired = true
|
|
503
|
+
await mkdir(spec.dir, { recursive: true, mode: 0o700 })
|
|
504
|
+
|
|
505
|
+
if (spec.isolation === 'worktree') {
|
|
506
|
+
const worktree = path.join(spec.dir, 'worktree')
|
|
507
|
+
await createWorktree(spec.cwd, worktree)
|
|
508
|
+
spec.cwd = worktree
|
|
509
|
+
detail.worktree = worktree
|
|
510
|
+
detail.cwd = worktree
|
|
511
|
+
record.worktree = worktree
|
|
512
|
+
record.cwd = worktree
|
|
513
|
+
saveRun(run)
|
|
514
|
+
emit(true)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const session = await createChildSession({
|
|
518
|
+
cwd: spec.cwd,
|
|
519
|
+
sessionDir: path.join(spec.dir, 'session'),
|
|
520
|
+
model: spec.model ?? activeModel,
|
|
521
|
+
modelRuntime,
|
|
522
|
+
thinking: spec.thinking,
|
|
523
|
+
parentSystemPrompt: ctx.getSystemPrompt(),
|
|
524
|
+
allTools: pi.getAllTools().map((tool) => tool.name),
|
|
525
|
+
agent: spec.agent,
|
|
526
|
+
schema: spec.schema,
|
|
527
|
+
onStructured: (value) => {
|
|
528
|
+
detail.structured = value
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
liveSessions.set(spec.id, session)
|
|
532
|
+
record.sessionFile = session.sessionFile
|
|
533
|
+
saveRun(run)
|
|
534
|
+
|
|
535
|
+
detail.status = 'running'
|
|
536
|
+
detail.startedAt = Date.now()
|
|
537
|
+
emit(true)
|
|
538
|
+
|
|
539
|
+
if (spec.forkContext) {
|
|
540
|
+
const parentEntries = ctx.sessionManager.getEntries()
|
|
541
|
+
let forkChars = 0
|
|
542
|
+
for (const entry of parentEntries) {
|
|
543
|
+
if (forkChars >= MAX_FORK_CONTEXT_CHARS) break
|
|
544
|
+
if (signal?.aborted) break
|
|
545
|
+
if (entry.type !== 'message') continue
|
|
546
|
+
const msg = entry.message as { role?: string; content?: JsonValue }
|
|
547
|
+
if (msg.role !== 'user' && msg.role !== 'assistant') continue
|
|
548
|
+
const text = extractForkText(msg.content)
|
|
549
|
+
if (text) {
|
|
550
|
+
const remaining = MAX_FORK_CONTEXT_CHARS - forkChars
|
|
551
|
+
const clipped =
|
|
552
|
+
text.length > remaining
|
|
553
|
+
? `${text.slice(0, remaining)}\n[fork context truncated]`
|
|
554
|
+
: text
|
|
555
|
+
forkChars += clipped.length
|
|
556
|
+
await session.sendCustomMessage(
|
|
557
|
+
{
|
|
558
|
+
customType: `fork-${msg.role}`,
|
|
559
|
+
content: [{ type: 'text', text: clipped }],
|
|
560
|
+
display: true
|
|
561
|
+
},
|
|
562
|
+
{ triggerTurn: false }
|
|
563
|
+
)
|
|
564
|
+
if (forkChars >= MAX_FORK_CONTEXT_CHARS) break
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const {
|
|
570
|
+
output: rawOutput,
|
|
571
|
+
assistant,
|
|
572
|
+
turnCount,
|
|
573
|
+
timedOut,
|
|
574
|
+
touched: newTouched,
|
|
575
|
+
schemaFallback
|
|
576
|
+
} = await drivePrompt({
|
|
577
|
+
session,
|
|
578
|
+
prompt: spec.task,
|
|
579
|
+
maxTurns: spec.maxTurns,
|
|
580
|
+
schema: spec.schema,
|
|
581
|
+
cwd: spec.cwd,
|
|
582
|
+
timeoutMs: spec.timeoutMs,
|
|
583
|
+
abortSignal: signal,
|
|
584
|
+
onEmit: emit,
|
|
585
|
+
detail
|
|
586
|
+
})
|
|
587
|
+
const assistantMsg = assistant ?? findLastAssistant(session.messages)
|
|
588
|
+
detail.output = rawOutput || undefined
|
|
589
|
+
if (newTouched.length > 0) detail.touched = newTouched
|
|
590
|
+
if (detail.worktree) detail.changes = changedPaths(await gitStatus(detail.worktree))
|
|
591
|
+
detail.sessionFile = session.sessionFile
|
|
592
|
+
detail.finishedAt = Date.now()
|
|
593
|
+
if (!detail.output || timedOut) {
|
|
594
|
+
const salvaged = salvageOutput(assistantMsg) ?? salvageFromMessages(session.messages)
|
|
595
|
+
if (salvaged && !detail.output) {
|
|
596
|
+
detail.output = salvaged
|
|
597
|
+
detail.partial = true
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
detail.hasSchema = spec.schema !== undefined && !schemaFallback
|
|
601
|
+
if (detail.hasSchema && detail.structured === undefined) detail.partial = true
|
|
602
|
+
const reason = failureReason({
|
|
603
|
+
hasAssistant: assistantMsg !== undefined,
|
|
604
|
+
timedOut,
|
|
605
|
+
duration: formatDuration(detail.startedAt),
|
|
606
|
+
hitTurnLimit: turnCount >= spec.maxTurns,
|
|
607
|
+
maxTurns: spec.maxTurns,
|
|
608
|
+
aborted: signal?.aborted === true,
|
|
609
|
+
hasSchema: spec.schema !== undefined && !schemaFallback,
|
|
610
|
+
hasStructured: detail.structured !== undefined,
|
|
611
|
+
output: detail.output ?? '',
|
|
612
|
+
stopReason: assistantMsg?.stopReason,
|
|
613
|
+
errorMessage: assistantMsg?.errorMessage
|
|
614
|
+
})
|
|
615
|
+
if (reason) {
|
|
616
|
+
detail.status = 'failed'
|
|
617
|
+
detail.error = reason
|
|
618
|
+
} else {
|
|
619
|
+
detail.status = 'completed'
|
|
620
|
+
}
|
|
621
|
+
} catch (error) {
|
|
622
|
+
detail.status = 'failed'
|
|
623
|
+
detail.error = error instanceof Error ? error.message : String(error)
|
|
624
|
+
detail.finishedAt ??= Date.now()
|
|
625
|
+
} finally {
|
|
626
|
+
if (detail.status === 'queued' || detail.status === 'running') {
|
|
627
|
+
detail.status = 'failed'
|
|
628
|
+
detail.error ??= 'Subagent aborted.'
|
|
629
|
+
detail.finishedAt ??= Date.now()
|
|
630
|
+
}
|
|
631
|
+
syncRecord(record, detail)
|
|
632
|
+
if (detail.usage !== undefined) {
|
|
633
|
+
recordOverhead(ctx.sessionManager.getSessionId(), spec.agent?.name ?? 'scout', detail.usage)
|
|
634
|
+
}
|
|
635
|
+
saveRun(run)
|
|
636
|
+
if (acquired) releasePermit()
|
|
637
|
+
emit(true)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const resumeModelRuntime = resumeKey ? await createChildModelRuntime(ctx) : undefined
|
|
642
|
+
|
|
643
|
+
const resumeKnownTask = async (task: TaskDetails): Promise<void> => {
|
|
644
|
+
const sessionId = ctx.sessionManager.getSessionId()
|
|
645
|
+
const run = findRunForTask(sessionId, task.id)
|
|
646
|
+
const record = run?.tasks.find((candidate) => candidate.id === task.id)
|
|
647
|
+
if (!run || !record) throw new Error(`No durable record for "${task.label}"; start the task again.`)
|
|
648
|
+
const knobs = params as { maxTurns?: number; timeoutMs?: number }
|
|
649
|
+
await resumeTask({
|
|
650
|
+
pi,
|
|
651
|
+
ctx,
|
|
652
|
+
run,
|
|
653
|
+
record,
|
|
654
|
+
task,
|
|
655
|
+
signal,
|
|
656
|
+
followUp: (params.task as string | undefined)?.trim() || undefined,
|
|
657
|
+
maxTurns: knobs.maxTurns,
|
|
658
|
+
timeoutMs: knobs.timeoutMs,
|
|
659
|
+
modelRuntime: resumeModelRuntime,
|
|
660
|
+
onEmit: emit
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (resumeKey) {
|
|
665
|
+
if (resumeKey === 'all') {
|
|
666
|
+
const failed = registry.filter((run) => run.status === 'failed')
|
|
667
|
+
if (!failed.length)
|
|
668
|
+
return {
|
|
669
|
+
content: [{ type: 'text', text: 'No failed tasks to resume.' }],
|
|
670
|
+
details: snapshot('completed', Date.now())
|
|
671
|
+
}
|
|
672
|
+
await Promise.allSettled(failed.map((task) => resumeKnownTask(task)))
|
|
673
|
+
if (signal?.aborted) throw new Error('Subagent aborted.')
|
|
674
|
+
const details: RunDetails = {
|
|
675
|
+
status: 'completed',
|
|
676
|
+
runId,
|
|
677
|
+
startedAt,
|
|
678
|
+
finishedAt: Date.now(),
|
|
679
|
+
tasks: failed.map((task) => ({ ...task }))
|
|
680
|
+
}
|
|
681
|
+
return { content: [{ type: 'text', text: resultText(details) }], details }
|
|
682
|
+
}
|
|
683
|
+
const task = registry.find((run) => run.id === resumeKey || run.label === resumeKey)
|
|
684
|
+
if (!task)
|
|
685
|
+
throw new Error(
|
|
686
|
+
`Task "${resumeKey}" not found. Failed ids: ${
|
|
687
|
+
registry
|
|
688
|
+
.filter((run) => run.status === 'failed')
|
|
689
|
+
.map((run) => run.id)
|
|
690
|
+
.join(', ') || 'none'
|
|
691
|
+
}.`
|
|
692
|
+
)
|
|
693
|
+
if (task.status !== 'failed')
|
|
694
|
+
throw new Error(
|
|
695
|
+
`Task "${task.label}" is ${task.status}, not failed, so there is nothing to resume.`
|
|
696
|
+
)
|
|
697
|
+
await resumeKnownTask(task)
|
|
698
|
+
const details: RunDetails = {
|
|
699
|
+
status: task.status === 'failed' ? 'failed' : 'completed',
|
|
700
|
+
runId,
|
|
701
|
+
startedAt,
|
|
702
|
+
finishedAt: Date.now(),
|
|
703
|
+
tasks: [{ ...task }]
|
|
704
|
+
}
|
|
705
|
+
return { content: [{ type: 'text', text: resultText(details) }], details }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const run: DurableRun = {
|
|
709
|
+
version: 1,
|
|
710
|
+
runId,
|
|
711
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
712
|
+
toolCallId: _toolCallId,
|
|
713
|
+
startedAt,
|
|
714
|
+
tasks: specs.map((spec) => ({
|
|
715
|
+
id: spec.id,
|
|
716
|
+
index: spec.index,
|
|
717
|
+
label: spec.label,
|
|
718
|
+
task: spec.task,
|
|
719
|
+
cwd: spec.cwd,
|
|
720
|
+
provider: (spec.model ?? activeModel).provider,
|
|
721
|
+
model: (spec.model ?? activeModel).id,
|
|
722
|
+
thinking: spec.thinking,
|
|
723
|
+
maxTurns: spec.maxTurns,
|
|
724
|
+
timeoutMs: spec.timeoutMs,
|
|
725
|
+
isolation: spec.isolation,
|
|
726
|
+
forkContext: spec.forkContext === true,
|
|
727
|
+
sessionDir: path.join(spec.dir, 'session'),
|
|
728
|
+
agent: spec.agent?.name,
|
|
729
|
+
schema: spec.schema,
|
|
730
|
+
status: 'queued'
|
|
731
|
+
}))
|
|
732
|
+
}
|
|
733
|
+
durableRun = run
|
|
734
|
+
claimRun(run.sessionId, run.runId)
|
|
735
|
+
saveRun(run)
|
|
736
|
+
|
|
737
|
+
pi.events.emit('subagent:started', { runId, count: tasks.length })
|
|
738
|
+
await Promise.allSettled(
|
|
739
|
+
specs.map((spec, index) => runTask(spec, tasks[index] as TaskDetails, run.tasks[index] as DurableTask))
|
|
740
|
+
)
|
|
741
|
+
if (signal?.aborted) throw new Error('Subagent aborted.')
|
|
742
|
+
|
|
743
|
+
for (const task of tasks) {
|
|
744
|
+
if (!task.output) continue
|
|
745
|
+
const truncated = truncateHead(task.output, {
|
|
746
|
+
maxBytes: Math.floor(DEFAULT_MAX_BYTES / tasks.length),
|
|
747
|
+
maxLines: Math.floor(DEFAULT_MAX_LINES / tasks.length)
|
|
748
|
+
})
|
|
749
|
+
task.output = truncated.truncated
|
|
750
|
+
? `${truncated.content}\n\n[Output truncated. Full output is in the child session file.]`
|
|
751
|
+
: truncated.content
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
run.finishedAt = Date.now()
|
|
755
|
+
for (const [index, task] of tasks.entries()) {
|
|
756
|
+
const record = run.tasks[index]
|
|
757
|
+
if (record) syncRecord(record, task)
|
|
758
|
+
}
|
|
759
|
+
saveRun(run)
|
|
760
|
+
|
|
761
|
+
const failed = tasks.filter((task) => task.status === 'failed')
|
|
762
|
+
pi.events.emit('subagent:completed', { runId, failed: failed.length })
|
|
763
|
+
const details = snapshot(failed.length === tasks.length ? 'failed' : 'completed', Date.now())
|
|
764
|
+
return { content: [{ type: 'text', text: resultText(details) }], details }
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
renderCall(args, theme) {
|
|
768
|
+
const inputs = args.tasks?.length ? args.tasks : [args]
|
|
769
|
+
let text = theme.fg('toolTitle', theme.bold('subagent '))
|
|
770
|
+
text += theme.fg('muted', inputs.length > 1 ? `${inputs.length} tasks` : '')
|
|
771
|
+
for (const input of inputs) {
|
|
772
|
+
const first = (input.task ?? '').split('\n', 1)[0]?.trim() ?? ''
|
|
773
|
+
const preview = first.length > 90 ? `${first.slice(0, 90)}…` : first || '...'
|
|
774
|
+
const forkTag = input.context === 'fork' ? theme.fg('accent', ' fork') : ''
|
|
775
|
+
const schemaTag = input.schema !== undefined ? theme.fg('accent', ' structured') : ''
|
|
776
|
+
text += `\n ${theme.fg('accent', input.agent ?? 'default')} ${theme.fg('dim', preview)}${forkTag}${schemaTag}`
|
|
777
|
+
const thinking = (input as { thinking?: string }).thinking
|
|
778
|
+
if (thinking !== undefined) text += `\n ${theme.fg('muted', thinking)}`
|
|
779
|
+
}
|
|
780
|
+
return new Text(text, 0, 0)
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
784
|
+
const details = result.details as RunDetails | undefined
|
|
785
|
+
if (!details) {
|
|
786
|
+
const content = result.content.find((part) => part.type === 'text')
|
|
787
|
+
return new Text(content?.type === 'text' ? content.text : '(no output)', 0, 0)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const running = isPartial || details.status === 'queued' || details.status === 'running'
|
|
791
|
+
const duration = formatDuration(details.startedAt, details.finishedAt)
|
|
792
|
+
let text = theme.fg('toolTitle', theme.bold('subagent'))
|
|
793
|
+
text += theme.fg('muted', ` · ${countByStatus(details.tasks)}${duration ? ` · ${duration}` : ''}`)
|
|
794
|
+
|
|
795
|
+
for (const task of details.tasks) {
|
|
796
|
+
text += `\n\n${statusIcon(task.status, theme)} ${theme.fg('dim', taskHeadline(task))}`
|
|
797
|
+
if (task.error) text += `\n ${theme.fg('error', task.error.split('\n', 1)[0] ?? '')}`
|
|
798
|
+
if (task.sessionFile && !running) text += `\n ${theme.fg('dim', task.sessionFile)}`
|
|
799
|
+
if (task.worktree && !running)
|
|
800
|
+
text += `\n ${theme.fg('dim', task.changes?.length ? `${task.changes.length} changed file(s), branch ${worktreeBranch(task.worktree)}` : 'clean worktree')}`
|
|
801
|
+
|
|
802
|
+
if (task.structured !== undefined && !running) {
|
|
803
|
+
text += `\n ${theme.fg('accent', 'structured:')} ${theme.fg('toolOutput', JSON.stringify(task.structured))}`
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const body = task.status === 'completed' || task.status === 'failed' ? task.output : undefined
|
|
807
|
+
if (!body) continue
|
|
808
|
+
const lines = body.split('\n')
|
|
809
|
+
const visible = expanded ? lines : lines.slice(0, 8)
|
|
810
|
+
text += `\n${visible.map((line) => theme.fg('toolOutput', line)).join('\n')}`
|
|
811
|
+
if (!expanded && lines.length > visible.length) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
|
|
812
|
+
}
|
|
813
|
+
return new Text(text, 0, 0)
|
|
814
|
+
}
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
pi.registerCommand('agents', {
|
|
818
|
+
description: 'List agent profiles (tools, model, limits). Useful after adding or editing profile files.',
|
|
819
|
+
handler: async (_args, ctx) => {
|
|
820
|
+
const agents = [...loadAgents(ctx.cwd).values()]
|
|
821
|
+
const lines =
|
|
822
|
+
agents.length === 0
|
|
823
|
+
? [
|
|
824
|
+
'No agent profiles found. Add markdown files with YAML frontmatter under ~/.pi/agent/agents/ or .pi/agents/.'
|
|
825
|
+
]
|
|
826
|
+
: [
|
|
827
|
+
`${agents.length} agent profile${agents.length !== 1 ? 's' : ''}:`,
|
|
828
|
+
...agents.map(profileLines).flat()
|
|
829
|
+
]
|
|
830
|
+
ctx.ui.notify(lines.join('\n'), 'info')
|
|
831
|
+
}
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function profileLines(a: {
|
|
836
|
+
name: string
|
|
837
|
+
description: string
|
|
838
|
+
model?: string
|
|
839
|
+
tools: string[]
|
|
840
|
+
disallowedTools: string[]
|
|
841
|
+
maxTurns?: number
|
|
842
|
+
maxExecutionTimeMs?: number
|
|
843
|
+
thinking?: string
|
|
844
|
+
}): string[] {
|
|
845
|
+
const bits = [`- ${a.name}: ${a.description}`]
|
|
846
|
+
if (a.model) bits.push(` model: ${a.model}`)
|
|
847
|
+
if (a.tools.length) bits.push(` tools: ${a.tools.join(', ')}`)
|
|
848
|
+
if (a.disallowedTools.length) bits.push(` blocked: ${a.disallowedTools.join(', ')}`)
|
|
849
|
+
if (a.maxTurns) bits.push(` maxTurns: ${a.maxTurns}`)
|
|
850
|
+
if (a.maxExecutionTimeMs) bits.push(` timeout: ${a.maxExecutionTimeMs}ms`)
|
|
851
|
+
if (a.thinking) bits.push(` thinking: ${a.thinking}`)
|
|
852
|
+
return bits
|
|
853
|
+
}
|